Skip to main content

hatchery_node/
server.rs

1use crate::git_worktree::{
2    create_worktree as create_git_worktree,
3    create_worktree_with_timeout as create_git_worktree_with_timeout,
4    list_worktrees as list_git_worktrees,
5    list_worktrees_with_deadline as list_git_worktrees_with_deadline,
6    paths_equal as worktree_paths_equal, remove_worktree as remove_git_worktree,
7    removal_lookup_path as normalize_worktree_removal_target,
8    resolve_base_commit_with_timeout,
9    run_git_read_bounded, GitCommandOutput, GitWorktreeError, GitWorktreeErrorKind,
10    NativeGitWorktreeSnapshot,
11};
12use crate::host_directory_browser::{
13    browse_host_directories, HostDirectoryBrowseError, HostDirectoryBrowseErrorKind,
14};
15use crate::environment_profiles::{
16    EnvironmentProfileBinding, NodeEnvironmentProfile, MAX_NODE_ENVIRONMENT_PROFILES,
17};
18use crate::bundle_catalog::{BundleCatalog, NodeBundle};
19use self::bundle_delivery::{DeliveryStore, DeliveryStoreError};
20use self::context_pack_store::ContextPackStore;
21use crate::bundle_provider::{
22    bundle_launch_arguments, validate_bundle_binding, BundleProviderLayout,
23};
24use crate::context_pack::{
25    ContextPackCatalog, ContextPackRepository, ContextPackRepositoryFileSource,
26    ContextPackSelectedFileSkipReason, NodeContextPack, CONTEXT_PACK_SELECTED_FILES,
27};
28use crate::harness_mcp_proxy::{
29    HarnessMcpProxyError, HarnessMcpProxyRegistry, PreparedHarnessMcpSpawn,
30    ReviewedHarnessMcpProgram,
31};
32use crate::session_environment::{
33    MaterializationId, MaterializationOwner, MaterializationOwnershipRecord,
34    MaterializationState, NodeSecretResolver, NodeSessionMaterializationProfile,
35    SessionEnvironmentMaterializeError, SessionEnvironmentMaterializer,
36};
37use crate::worktree_service::{
38    exact_created_worktree, exact_owned_worktree, ManagedWorktreeLeaseRecord,
39    ManagedWorktreeProfile, ManagedWorktreeRegistry, ManagedWorktreeSessionHolder,
40};
41use crate::session_registry::{
42    self, validate_display_name, LoadedNodeState, ManagedWorktreeSpawnReplayRecordV10,
43    ManagedWorktreeSpawnReplayStateV10, MAX_MANAGED_SESSION_RECORDS,
44    MAX_MANAGED_WORKTREE_SPAWN_REPLAYS,
45};
46#[cfg(unix)]
47use crate::workspace_file_unix::{
48    create_workspace_directory as create_workspace_directory_on_disk,
49    create_workspace_file as create_workspace_file_on_disk,
50    read_workspace_file as read_workspace_file_from_disk,
51    write_workspace_file as write_workspace_file_to_disk, WorkspaceFileBytes,
52    WorkspaceFileReadError, WorkspaceFileReadErrorKind,
53    WORKSPACE_ENTRY_CREATE_CANCELED, WORKSPACE_ENTRY_CREATE_COMMITTING,
54    WORKSPACE_ENTRY_CREATE_PENDING,
55};
56#[cfg(windows)]
57use crate::workspace_file_windows::{
58    create_workspace_directory as create_workspace_directory_on_disk,
59    create_workspace_file as create_workspace_file_on_disk,
60    read_workspace_file as read_workspace_file_from_disk,
61    write_workspace_file as write_workspace_file_to_disk, WorkspaceFileBytes,
62    WorkspaceFileReadError, WorkspaceFileReadErrorKind,
63    WORKSPACE_ENTRY_CREATE_CANCELED, WORKSPACE_ENTRY_CREATE_COMMITTING,
64    WORKSPACE_ENTRY_CREATE_PENDING,
65};
66use crate::platform;
67use crate::provider_runtime::{
68    require_policy, ProviderRuntimeAdmissionError, ProviderRuntimeMonitor,
69    ProviderRuntimeRequirement,
70};
71use crate::spawn_spec::SpawnProfileRegistry;
72use crate::standalone_workspace::{
73    prepare_standalone_workspace, StandaloneWorkspaceError, StandaloneWorkspaceErrorKind,
74};
75use crate::protocol::{
76    read_json_frame_limited_body_timeout, write_json_frame, write_json_frame_limited,
77    validate_node_negotiated_handshake_capacity, validate_provider_contract_manifest,
78    provider_id_is_legacy, AdapterContractRevision, AgentStreamChunkKindV1, AgentStreamChunkV1,
79    AgentStreamInteractionOptionV1, AgentStreamNamedIdV1,
80    CapabilityId, ClientFrame, ClientRole,
81    ControllerState, DeliveryBlobChunkHexV1, DeliveryBlobDigestV1,
82    DeliveryBundleManifestV2, DeliveryCommitReceiptV1, DeliveryStageId,
83    AgentProgressAttentionKindV1, AgentProgressAttentionV1, AgentProgressCurrentV1,
84    AgentProgressEventKindV1, AgentProgressUsageV1, AgentProgressV1, FrameError,
85    GitCommitDetails, GitDiff, GitDiffMode, GitDiffRequest, GitHistoryPage,
86    GitObjectId, GitCommitSummary, GitSignatureStatus, GitSnapshot, GitStatusEntry,
87    GitWorktreeSnapshot, HarnessMcpActivationDigest, HarnessMcpReservationId,
88    HostDirectoryListing,
89    NodeCompatibilitySupport, NodeEvent,
90    NodeEventEnvelope, NodeFailure, NodeFailureCode, NodeHello, NodeId, NodeIncarnationId,
91    NegotiatedNodeCompatibility, NodeRequest, NodeResponse, NodeSnapshot, OpaqueHostPath,
92    ProtocolRange,
93    ProviderAdapterContractSupport,
94    ProviderContractRevision, ProviderContractSupport, RepositoryPath, RequestEnvelope,
95    ProviderRuntimeStatuses, ResolvedBundleReceipt, ResolvedEnvironmentProfileReceipt,
96    ContextPackLineageReceipt, ContextPackBytesRead, ResolvedContextPackReceipt,
97    ResolvedHarnessMcpProxyReceiptV1, ResolvedSpawnReceipt,
98    ResolvedSpawnSpec, ResponseEnvelope,
99    ServerChallenge, ServerFrame, SessionAddress, SessionAgentProgress, SessionKey, SessionMode,
100    ManagedSessionRecord,
101    ManagedSessionState, ManagedWorktreeCleanupFailure, ManagedWorktreeLeaseId,
102    SessionRecordRetentionConfig,
103    ManagedWorktreeLeaseSnapshot, ManagedWorktreeLeaseState, ManagedWorktreeRetention,
104    ManagedWorktreeSpawnReceipt, ManagedWorktreeSpawnRequest,
105    ManagedWorktreeSpawnRequestV2, SessionRecordId,
106    NativeSessionCatalogRoute, NativeSessionSelection, SessionTaskBindingV1,
107    SessionTaskTargetV1, TaskId,
108    BlockAuthorityV1, HarnessMcpLaunchV1, SessionHistorySummaryV1, MAX_ACP_BLOCKED_HELP_BYTES, MAX_ACP_BLOCKED_REASON_BYTES,
109    StateSchemaSupport, WorkspaceEntry, WorkspaceEntryKind, WorktreeProfileId,
110    SpawnContextId, SpawnEnvironmentProfileId, SpawnIdempotencyKey,
111    SpawnProfileDefaults, SpawnRequiredCapabilities, SpawnSpec, SpawnSpecResolveError,
112    WorkspaceFileContent, WorkspaceFileRevision,
113    WorkspaceFileRead, WorkspaceId, WorkspaceInspection, WorkspaceInspectionTruncationV1,
114    WorkspaceSnapshot,
115    WorktreeServiceMode,
116    LaunchInventory, ManagedWorktreeProfileSummary, SpawnProfileSummary,
117    WorktreeProfileInventory, MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
118    DEFAULT_CONTROLLER_LEASE_MS,
119    MAX_CONTROLLER_LEASE_MS, MIN_CONTROLLER_LEASE_MS, NODE_COMPATIBILITY_METADATA_CAPABILITY,
120    CAPABILITY_HOST_DIRECTORY_BROWSE_V1,
121    NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY, NODE_REPOSITORY_PATH_CAPABILITY,
122    NODE_PROVIDER_ID_OPEN_CAPABILITY,
123    NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
124    NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY, NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
125    NODE_HISTORY_CONTEXT_PACK_CAPABILITY, NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
126    NODE_NATIVE_SESSION_CATALOG_CAPABILITY, NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
127    NODE_NATIVE_SESSION_INDEX_CAPABILITY, NODE_NATIVE_SESSION_PREVIEW_CAPABILITY,
128    NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
129    NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY,
130    NODE_HARNESS_MCP_READ_PROXY_CAPABILITY,
131    NODE_SESSION_TASK_CORRELATION_CAPABILITY,
132    NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY,
133    NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
134    NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
135    NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
136    NODE_SPAWN_PROFILE_REVISION_CAPABILITY, NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
137    NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
138    NODE_AGENT_STREAM_EVENTS_CAPABILITY, NODE_ACP_CONTROL_CAPABILITY,
139    NODE_GIT_READ_CAPABILITY, NODE_WORKSPACE_FILE_READ_CAPABILITY,
140    NODE_WORKSPACE_FILE_WRITE_CAPABILITY, NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY,
141    NODE_WORKTREE_SELECTION_CAPABILITY,
142    BUILD_STAMP, MAX_NODE_CLIENT_FRAME_BYTES, MAX_NODE_HELLO_FRAME_BYTES,
143    MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS, MAX_AGENT_PROGRESS_ENTRY_BYTES,
144    MAX_NODE_TERMINAL_BYTES, MAX_NODE_TEXT_BYTES,
145    MAX_REPOSITORY_PATH_BYTES,
146    MAX_GIT_DIFF_BYTES, MAX_GIT_HISTORY_COMMITS, MAX_WORKSPACE_FILE_BYTES,
147    MAX_HARNESS_MCP_PENDING_CALLS_PER_NODE,
148    MAX_WORKSPACE_ROOT_BYTES, NODE_STATE_SCHEMA_V1, NODE_STATE_SCHEMA_V10,
149    SPAWN_RUNTIME_PROVIDER_SESSION_IDENTITY, SPAWN_RUNTIME_RAW_PTY_LIFECYCLE,
150    SPAWN_RUNTIME_SEMANTIC_READINESS, SPAWN_RUNTIME_SEMANTIC_RESUME,
151    SPAWN_RUNTIME_STRUCTURED_PROMPT,
152};
153#[cfg(test)]
154use crate::protocol::WorktreeProfileRevision;
155use ring::digest::{digest, SHA256};
156use crate::protocol::correlation::{
157    interaction_correlation, sanitize_progress_tool_label, tool_class_label, tool_correlation,
158    truncate_text,
159};
160use gate4agent_catalog::{builtin_registry, AgentRegistry};
161use gate4agent_handle::{EventSubscription, Gate4AgentHandle, PortDispatchError};
162use gate4agent_runtime_native::{
163    NativeMcpServerLaunchOverlay, NativeInstanceLaunchOverlay,
164    NativeLaunchEnvironmentOverlay,
165    NativeHistoryConfig, NativeLaunchProfileControl, NativeRuntime, NativeRuntimeConfig,
166    NativeSessionCatalogAuthority, NativeSessionCatalogError, NativeSessionPreviewError,
167    ScopedNativeSessionCatalogEntry,
168};
169use gate4agent_runtime_native::shell_efficiency::ShellEfficiencyProfile;
170use std::sync::OnceLock;
171use gate4agent_runtime_native::tick_profile::TickProfileSnapshot;
172use drive_loop_profile::DriveLoopProfiler;
173#[cfg(test)]
174use gate4agent_runtime_native::{HistorySourceLayout, NativeHistoryRoot};
175use gate4agent_node_wire::{
176    auth_proof, negotiated_auth_proof, proofs_match, random_incarnation_id, random_nonce,
177    AuthDirection, OwnerOnlyLocalListener,
178};
179use gate4agent_types::{
180    AdapterBinding, AdapterFamily, AgentAdapterCapabilities, AgentCapabilities, AgentId,
181    AgentInstanceId, AgentReadinessSpec, AgentSpec, AgentTransportCapabilities, ApprovalLevel,
182    DetectionSpec, InitialPromptMode, LaunchSpec, ProcessMatcher, PromptSpec, SpecVerification,
183    CommandEnvelope, CommandId,
184    validate_candidate_id, ControlCommand, ControlEvent, ControlEventKind,
185    HistoryCandidateSummary, HistoryOperation, HistoryQuery, HistorySessionRecord,
186    HostDecisionAuthority as ProviderHostDecisionAuthority,
187    HostRequestDecision as ProviderHostRequestDecision,
188    InputAction,
189    PromptFraming, PromptPayload, ResumeLaunchRequest,
190    ProviderActivity, ProviderEvent, ProviderInteractionKind, ProviderInteractionStatus,
191    ProviderRuntimeCapability, ProviderRuntimePolicy, ProviderSessionIdentity, ProviderSessionKey,
192    ProviderSnapshot, ProviderStopReason,
193    ResumeTarget,
194    SessionGeneration, StartRequest,
195    TerminalControl, TerminalText,
196    SessionStatus, TerminalFrame, TransportKind, CONTROL_SESSIONS_MAX,
197    HISTORY_DISCOVERY_LIMIT_MAX, WORKING_DIRECTORY_MAX_BYTES,
198};
199use std::collections::{BTreeMap, VecDeque};
200use std::ffi::OsString;
201use std::io;
202use std::path::{Path, PathBuf};
203use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, AtomicUsize, Ordering};
204use std::sync::{Arc, Mutex, RwLock};
205use std::time::{Duration, Instant};
206use thiserror::Error;
207use tokio::sync::{broadcast, mpsc, Mutex as AsyncMutex, Notify, OwnedSemaphorePermit, Semaphore};
208use tokio::task::{AbortHandle, JoinSet};
209use tokio::time::{sleep, timeout};
210
211mod http_api;
212pub mod backend_paths;
213pub mod node_wake;
214
215tokio::task_local! {
216    /// Launch options (model / effort) of a `SpawnWithOptions` request, scoped around that
217    /// spawn so the provider's start carries them (g4a maps them to the CLI's own flags).
218    static SPAWN_SESSION_OPTIONS: Option<gate4agent_types::SessionOptionSelection>;
219    /// Provider session id to resume (node-start restore of journalled sessions).
220    static RESUME_PROVIDER_SESSION: Option<String>;
221    /// Origin (`human`/`agent`) and optional parent signature for the node session registry.
222    static SPAWN_ORIGIN: Option<String>;
223    static SPAWN_PARENT: Option<String>;
224}
225
226/// `SpawnSessionOptions` -> g4a selection. Effort needs a model (g4a options hang off a model).
227fn spawn_selection(
228    options: &gate4agent_node_protocol::SpawnSessionOptions,
229) -> Result<Option<gate4agent_types::SessionOptionSelection>, NodeFailure> {
230    options.validate().map_err(|e| failure(NodeFailureCode::InvalidRequest, e))?;
231    match (&options.model, &options.effort) {
232        (None, None) => Ok(None),
233        (None, Some(_)) => Err(failure(NodeFailureCode::InvalidRequest, "spawn effort needs a model")),
234        (Some(model), effort) => {
235            let mut sel = gate4agent_types::SessionOptionSelection::new(model.clone());
236            if let Some(e) = effort {
237                sel = sel.with_value("effort", e.clone());
238            }
239            Ok(Some(sel))
240        }
241    }
242}
243
244mod browser_bridge;
245
246use gate4agent_node_core::drive_loop_profile;
247
248#[path = "bundle_delivery.rs"]
249mod bundle_delivery;
250
251#[path = "context_pack_store.rs"]
252mod context_pack_store;
253
254#[cfg(windows)]
255pub use crate::platform::DEFAULT_NODE_ENDPOINT;
256
257const NODE_EVENT_HISTORY_MAX: usize = 4_096;
258const NODE_BROADCAST_CAPACITY: usize = 1_024;
259const NODE_TERMINAL_BROADCAST_CAPACITY: usize = 1;
260/// Unlike `NODE_TERMINAL_BROADCAST_CAPACITY`'s single replaceable slot (a
261/// terminal frame is a full-screen snapshot; only the latest ever matters),
262/// an `AgentStreamChunkV1` is one delta in an ordered sequence -- collapsing
263/// two pending sends into one would silently drop content a subscriber can
264/// never recover. This needs headroom for a burst instead.
265const NODE_AGENT_STREAM_BROADCAST_CAPACITY: usize = 256;
266/// The node never has more than `MAX_HARNESS_MCP_PENDING_CALLS_PER_NODE`
267/// `NodeEvent::HarnessMcpReadCall`s in flight at once (`begin_call`'s own
268/// admission check), and `publish_transient` sends each exactly once as a
269/// single-envelope batch -- so this many slots covers the entire pending
270/// set without ever lagging the one subscriber that matters, the harness's
271/// own connection, the way sharing the durable `event_tx`'s bus could.
272const NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY: usize = MAX_HARNESS_MCP_PENDING_CALLS_PER_NODE;
273const NODE_CONNECTION_EVENT_BURST_MAX: usize = 16;
274const CONTROL_EVENT_SUBSCRIPTION_CAPACITY: usize = 1_024;
275const MAX_PREAUTH_CONNECTIONS: usize = 32;
276const MAX_AUTHENTICATED_CONNECTIONS: usize = 16;
277const AUTH_FRAME_TIMEOUT_MS: u64 = 5_000;
278const FRAME_BODY_TIMEOUT_MS: u64 = 5_000;
279const CONNECTION_SHUTDOWN_GRACE_MS: u64 = 250;
280const SPAWN_DISPATCH_TIMEOUT_MS: u64 = 2_000;
281/// Fresh Spawn stays provisional until HQ successfully attaches (first Resize /
282/// TerminalBytes). This is an *attach-never-arrived* timeout from spawn time —
283/// not the disconnect reconnect grace (HQ owns that, starting only on a real
284/// client WS close). A provider conversation id is a session; a PTY with no
285/// attached HQ view is still a draft and is reaped here if attach never lands.
286const PROVISIONAL_SPAWN_GRACE_MS: u64 = 90_000;
287/// How often `drive_runtime_until_shutdown`'s loop re-evaluates the session
288/// record retention policy (`NodeServerConfig::session_record_retention`).
289/// A sweep cadence, not a policy value -- the policy itself lives entirely
290/// in the operator-configured age/keep-N flags; this just bounds how stale
291/// the in-memory view of dead `Unavailable` records can get between sweeps
292/// on an otherwise-idle node, mirroring the once-per-startup sweep that
293/// also runs alongside `reconcile_managed_worktrees`.
294const SESSION_RECORD_RETENTION_SWEEP_PERIOD: Duration = Duration::from_secs(5 * 60);
295/// TTL for `NodeShared::rejected_command_reasons` entries -- see that
296/// field's own doc for why a spawn waiter needs this at all. Must outlive
297/// the longest a waiter can still be polling for this exact command's
298/// outcome: `gate4agent_node_protocol::MAX_SPAWN_DEADLINE_MS` (120s) is the
299/// largest `deadline_ms` any caller may request, and the margin on top
300/// absorbs the waiter's own poll cadence plus clock skew between the drive
301/// loop (writer) and the waiter (reader).
302const REJECTED_COMMAND_REASON_TTL_MS: u64 = 150_000;
303/// Bounds `NodeShared::rejected_command_reasons` against an unbounded
304/// backlog of asynchronously-rejected commands nobody ever collects (a
305/// waiter that stops polling, or a burst of always-rejected commands from a
306/// misbehaving caller): once at capacity, the oldest entry is evicted to
307/// make room for the newest, mirroring the eviction rule already used by
308/// `spawn_idempotency`.
309const REJECTED_COMMAND_REASON_MAX_ENTRIES: usize = 256;
310/// How long `wait_until_removed` waits between two `Remove` re-dispatches
311/// for the same session. The presence poll runs far faster (2ms); this
312/// only paces the sending, so an accepted `Remove` still in flight is not
313/// piled on by a dozen more that land on an already-dropped instance.
314const REMOVE_REDISPATCH_INTERVAL_MS: u64 = 100;
315const SPAWN_IDEMPOTENCY_MAX_ENTRIES: usize = 256;
316const SPAWN_IDEMPOTENCY_TTL_MS: u64 = 15 * 60 * 1_000;
317const PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS: u64 = 2_500;
318const MUTATION_SETTLE_TIMEOUT_MS: u64 = 5_000;
319const READINESS_SETTLE_HEADROOM_MS: u64 = 2_000;
320const MANAGED_RESUME_SETTLE_TIMEOUT_MS: u64 = 30_000;
321const WORKSPACE_INSPECTION_MAX_CONCURRENCY: usize = 4;
322const WORKSPACE_FILE_READ_TIMEOUT_MS: u64 = 2_000;
323const HOST_DIRECTORY_BROWSE_TIMEOUT_MS: u64 = 2_000;
324const NATIVE_SESSION_CATALOG_TIMEOUT_MS: u64 = 30_000;
325const WORKSPACE_TREE_MAX_DEPTH: usize = 6;
326const WORKSPACE_TREE_MAX_ENTRIES: usize = 512;
327/// Default total wall-clock budget for `inspect_workspace`'s walk+git
328/// chain, overridable via `GATE4AGENT_NODE_WORKSPACE_INSPECTION_BUDGET_MS`.
329const WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT: u64 = 8_000;
330/// The harness relay enforces its own flat response deadline
331/// (`HOST_RUN_READ_RESPONSE_DEADLINE`, `hatchery-harness-service::runtime`,
332/// 12s) above this node-side budget. The two constants live in different
333/// crates with no shared definition, so clamp any override here comfortably
334/// below that external ceiling — a misconfigured value above it would
335/// reopen the exact deadline race this budget exists to close.
336const WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX: u64 = 11_000;
337/// Default cap on directory entries visited (not just returned) by one
338/// walk, overridable via `GATE4AGENT_NODE_WORKSPACE_INSPECTION_ENTRY_CAP`.
339const WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT: usize = 50_000;
340const WORKSPACE_INSPECTION_ENTRY_CAP_MAX: usize = 1_000_000;
341const GIT_STATUS_MAX_ENTRIES: usize = 128;
342const GIT_COMMIT_MAX_ENTRIES: usize = 12;
343const GIT_OUTPUT_MAX_BYTES: usize = 64 * 1_024;
344const GIT_DIAGNOSTIC_MAX_BYTES: usize = 1_024;
345const WORKSPACE_FILE_WRITE_TIMEOUT_MS: u64 = 5_000;
346const WORKSPACE_ENTRY_CREATE_TIMEOUT_MS: u64 = 5_000;
347const GIT_COMMAND_TIMEOUT_MS: u64 = 1_500;
348const WORKSPACE_UNAVAILABLE_ERROR: &str = "workspace-unavailable";
349const PROVIDER_SESSION_SCOPE_CONFLICT_ERROR: &str = "provider-session-scope-conflict";
350const PROVIDER_SESSION_LIVE_CONFLICT_ERROR: &str = "provider-session-live-conflict";
351const MANAGED_SESSION_CAPACITY_ERROR: &str = "managed-session-capacity";
352const PROVIDER_IDENTITY_ALLOCATION_ERROR: &str =
353    "provider-session-identity-allocation-failed";
354const PROVIDER_RESUME_REJECTED_ERROR: &str = "provider-resume-rejected";
355const DURABLE_STATE_COMMIT_FAILED_ERROR: &str = "durable-state-commit-failed";
356const DURABLE_STATE_LOCK_FAILED_ERROR: &str = "durable-state-lock-failed";
357const DURABLE_STATE_LOAD_FAILED_ERROR: &str = "durable-state-load-failed";
358const DURABLE_STATE_CONFLICT_ERROR: &str = "durable-state-conflict";
359const DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR: &str = "durable-state-schema-unsupported";
360const DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR: &str =
361    "durable-state-path-semantics-unsupported";
362
363fn opaque_windows_path(value: String) -> OpaqueHostPath {
364    OpaqueHostPath::utf8(value).expect("validated Windows node path must remain wire-safe")
365}
366
367fn agent_progress_from_provider_snapshot(
368    address: SessionAddress,
369    provider: &ProviderSnapshot,
370) -> Option<SessionAgentProgress> {
371    let mut truncated = false;
372    let active_tool_count = bounded_progress_count(provider.active_tools.len(), &mut truncated);
373    let mut active_tool_labels = Vec::with_capacity(
374        provider
375            .active_tools
376            .len()
377            .min(MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS),
378    );
379    for tool in &provider.active_tools {
380        if active_tool_labels.len() == MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS {
381            truncated = true;
382            break;
383        }
384        if let Some(label) = sanitize_progress_tool_label(&tool.name, &mut truncated) {
385            active_tool_labels.push(label);
386        }
387    }
388    let attention = provider
389        .interactions
390        .iter()
391        .find(|interaction| {
392            matches!(
393                interaction.status,
394                ProviderInteractionStatus::Pending | ProviderInteractionStatus::Resolving { .. }
395            )
396        })
397        .map(|interaction| AgentProgressAttentionV1 {
398            kind: match interaction.interaction_kind {
399                ProviderInteractionKind::Approval => AgentProgressAttentionKindV1::Approval,
400                ProviderInteractionKind::Question => AgentProgressAttentionKindV1::Question,
401            },
402            tool_label: sanitize_progress_tool_label(&interaction.tool_name, &mut truncated),
403        });
404    let subagent_count = bounded_progress_count(provider.subagents.len(), &mut truncated);
405    let usage = (provider.completed_turns > 0
406        || provider.usage.input_tokens > 0
407        || provider.usage.output_tokens > 0
408        || provider.usage.cache_read_tokens > 0
409        || provider.usage.cache_write_tokens > 0
410        || provider.usage.reasoning_tokens > 0)
411        .then_some(AgentProgressUsageV1 {
412            input_tokens: provider.usage.input_tokens,
413            output_tokens: provider.usage.output_tokens,
414            cache_read_tokens: provider.usage.cache_read_tokens,
415            cache_write_tokens: provider.usage.cache_write_tokens,
416            reasoning_tokens: provider.usage.reasoning_tokens,
417        });
418    let progress = AgentProgressV1 {
419        provider_sequence: provider.sequence,
420        activity: provider.activity,
421        completed_turns: provider.completed_turns,
422        usage,
423        current: AgentProgressCurrentV1::from(provider.activity),
424        active_tool_labels,
425        active_tool_count,
426        attention,
427        subagent_count,
428        last_event_kind: provider.last_event.as_ref().and_then(agent_progress_event_kind),
429        gap_count: provider.gap_count,
430        stale: provider.stale,
431        truncated,
432    };
433    let entry = SessionAgentProgress { address, progress };
434    serde_json::to_vec(&entry)
435        .ok()
436        .filter(|encoded| encoded.len() <= MAX_AGENT_PROGRESS_ENTRY_BYTES)
437        .map(|_| entry)
438}
439
440fn bounded_progress_count(count: usize, truncated: &mut bool) -> u32 {
441    match u32::try_from(count) {
442        Ok(count) => count,
443        Err(_) => {
444            *truncated = true;
445            u32::MAX
446        }
447    }
448}
449
450fn agent_progress_event_kind(event: &ProviderEvent) -> Option<AgentProgressEventKindV1> {
451    if matches!(event, ProviderEvent::ContextWindowUsage { .. }) {
452        return None;
453    }
454    Some(match event {
455        ProviderEvent::SessionStarted { .. } => AgentProgressEventKindV1::SessionStarted,
456        ProviderEvent::SessionIdentityObserved { .. } => {
457            AgentProgressEventKindV1::SessionIdentityObserved
458        }
459        ProviderEvent::TurnStarted { .. } => AgentProgressEventKindV1::TurnStarted,
460        ProviderEvent::WorkingObserved => AgentProgressEventKindV1::WorkingObserved,
461        ProviderEvent::Text { .. } => AgentProgressEventKindV1::Text,
462        ProviderEvent::Thinking { .. } => AgentProgressEventKindV1::Thinking,
463        ProviderEvent::ToolStarted { .. } => AgentProgressEventKindV1::ToolStarted,
464        ProviderEvent::ToolCompleted { .. } => AgentProgressEventKindV1::ToolCompleted,
465        ProviderEvent::TurnCompleted { .. } => AgentProgressEventKindV1::TurnCompleted,
466        ProviderEvent::TurnInterrupted => AgentProgressEventKindV1::TurnInterrupted,
467        ProviderEvent::SessionEnded { .. } => AgentProgressEventKindV1::SessionEnded,
468        ProviderEvent::Error { .. } => AgentProgressEventKindV1::Error,
469        ProviderEvent::Ready => AgentProgressEventKindV1::Ready,
470        ProviderEvent::InteractionRequested { .. } => {
471            AgentProgressEventKindV1::InteractionRequested
472        }
473        ProviderEvent::InteractionResolved { .. } => AgentProgressEventKindV1::InteractionResolved,
474        ProviderEvent::SubagentStarted { .. } => AgentProgressEventKindV1::SubagentStarted,
475        ProviderEvent::SubagentStopped { .. } => AgentProgressEventKindV1::SubagentStopped,
476        ProviderEvent::RateLimited { .. } => AgentProgressEventKindV1::RateLimited,
477        ProviderEvent::HostRequestObserved { .. } => AgentProgressEventKindV1::HostRequestObserved,
478        ProviderEvent::UnrecognizedNotification { .. } => {
479            AgentProgressEventKindV1::UnrecognizedNotification
480        }
481        ProviderEvent::ContextWindowUsage { .. } => unreachable!("handled above"),
482        // ACP session/update coverage beyond text/tool/turn streaming
483        // (`plan`, `available_commands_update`, `current_mode_update`,
484        // `session_info_update`, `usage_update`, `config_option_update`,
485        // `user_message_chunk`). `AgentProgressEventKindV1` is a versioned
486        // wire enum without a `#[serde(other)]` fallback; minting new
487        // variants for it is a deliberate wire-contract decision for
488        // whoever owns this protocol, not a side effect of parsing more of
489        // ACP's own wire -- until that decision is made, these fold into
490        // the same "we saw something, no dedicated kind for it yet" bucket
491        // as `UnrecognizedNotification`.
492        ProviderEvent::UserMessage { .. }
493        | ProviderEvent::Plan { .. }
494        | ProviderEvent::AvailableCommandsUpdated { .. }
495        | ProviderEvent::ModeChanged { .. }
496        | ProviderEvent::SessionInfoUpdated { .. }
497        | ProviderEvent::UsageUpdated { .. }
498        | ProviderEvent::ConfigOptionsUpdated { .. } => {
499            AgentProgressEventKindV1::UnrecognizedNotification
500        }
501    })
502}
503
504/// Maps this harness's own `HostDecisionAuthority` onto the wider
505/// Maps a host request decider onto the `BlockAuthorityV1` a `Blocked` stream
506/// chunk carries -- the four authorities a `Denied` decision is ever made
507/// under, one-for-one. The remaining `BlockAuthorityV1` variants
508/// (`ProviderClassifier`, `ProviderPermissionRule`, `ProviderSandbox`,
509/// `ProviderRefusal`, `UserRejected`, `ProviderQuota`, `Unknown`) describe a
510/// block the PROVIDER side of the wire reported -- this function never
511/// produces one, because `ProviderHostDecisionAuthority` has no vocabulary for
512/// them; the provider-side blocks below mint those directly from typed fields.
513/// Whether a provider source is a live session transport -- the only kind
514/// whose events reach the agent stream. History, resume, session-option and
515/// capability-probe sources are not sessions and stream nothing.
516fn provider_family_is_live(family: AdapterFamily) -> bool {
517    match family {
518        AdapterFamily::PtySemantic
519        | AdapterFamily::Hook
520        | AdapterFamily::ManagedHook
521        | AdapterFamily::Pipe
522        | AdapterFamily::OneShot
523        | AdapterFamily::Acp => true,
524        AdapterFamily::History
525        | AdapterFamily::Resume
526        | AdapterFamily::SessionOptions
527        | AdapterFamily::CapabilityProbe => false,
528    }
529}
530
531fn block_authority(by: ProviderHostDecisionAuthority) -> BlockAuthorityV1 {
532    match by {
533        ProviderHostDecisionAuthority::Gate => BlockAuthorityV1::HostGate,
534        ProviderHostDecisionAuthority::Policy => BlockAuthorityV1::HostPolicy,
535        ProviderHostDecisionAuthority::Operator => BlockAuthorityV1::Operator,
536        ProviderHostDecisionAuthority::DeadlinePolicy => BlockAuthorityV1::HostDeadline,
537    }
538}
539
540/// The `Blocked::reason` for a `Denied` decision this build's own
541/// `AgentEvent::RpcIncomingRequest::reason` did not attach text to --
542/// reachable today for `Operator`/`HostDeadline`: a deferred `session/
543/// request_permission` request resolved by `AcpSession::
544/// resolve_pending_request`/`expire_deadlines` (`src/acp/session.rs`),
545/// neither of which computes free text (an operator answers approve/reject,
546/// never a comment; an unattended deadline asks nobody anything). `reason`
547/// is a REQUIRED field on the wire (see `AgentStreamChunkKindV1::Blocked`)
548/// -- a categorical fallback naming the authority is the
549/// honest minimum, never a placeholder standing in for detail nobody
550/// reported.
551fn action_blocked_fallback_reason(authority: BlockAuthorityV1) -> &'static str {
552    match authority {
553        BlockAuthorityV1::HostGate => "denied by the dangerous-command gate",
554        BlockAuthorityV1::HostPolicy => "denied by host policy",
555        BlockAuthorityV1::HostDeadline => {
556            "denied by host policy after the operator deadline expired"
557        }
558        BlockAuthorityV1::Operator => "declined by the operator",
559        BlockAuthorityV1::ProviderClassifier => "denied by the provider's classifier",
560        BlockAuthorityV1::ProviderPermissionRule => "denied by a provider permission rule",
561        BlockAuthorityV1::ProviderSandbox => "denied by the provider's sandbox",
562        BlockAuthorityV1::ProviderRefusal => "refused by the provider",
563        BlockAuthorityV1::UserRejected => "rejected by the user",
564        BlockAuthorityV1::ProviderQuota => "the provider's quota or rate limit was exhausted",
565        BlockAuthorityV1::Unknown => "blocked; no reason was reported",
566    }
567}
568
569/// Computes `AgentStreamChunkKindV1::Blocked`'s `tool_class`/`authority`/`reason`
570/// for a `HostRequestObserved` `Denied` decision.
571fn host_request_denied_block(
572    method: &str,
573    by: ProviderHostDecisionAuthority,
574    reason: Option<&str>,
575) -> (String, BlockAuthorityV1, String) {
576    let authority = block_authority(by);
577    let reason_source = reason.unwrap_or_else(|| action_blocked_fallback_reason(authority));
578    // `AgentStreamChunkKindV1::validate` REJECTS an over-long reason outright
579    // rather than truncating -- this cut happens here, at the producer, before
580    // the validator ever sees the text.
581    let (reason_text, _) =
582        truncate_text(reason_source, MAX_ACP_BLOCKED_REASON_BYTES);
583    (tool_class_label(method), authority, reason_text)
584}
585
586// ---------------------------------------------------------------------------
587// K2 -- provider-side blocks read off ACP `tool_call_update._meta.
588// nonExecutionKind` (`ProviderEvent::ToolCompleted::non_execution_kind`) and
589// `SessionEnded::stop_reason` (`stopReason: "refusal"`, or a `session/
590// prompt` RPC error whose vendor code names a quota/limit/rate
591// exhaustion). See
592// `docs/gate4agent/plans/gate4agent-blocked-action-event-2026-09-02.md`
593// §3 slice K2.
594// ---------------------------------------------------------------------------
595
596/// Classifies ACP's `nonExecutionKind` into the typed authority a block
597/// this build mints under, or `None` when the tool truly did not run for a
598/// reason that is NOT a block -- `"interrupted"`/`"cancelled"` mean the
599/// TURN stopped (session closed, the operator cancelled), not that a
600/// policy/classifier/rule refused the call, so those (and any unrecognized
601/// future value) stay a plain `ToolCompleted { success: false }` only, per
602/// this plan's own rule that a tool failure is never inflated into a block
603/// this build cannot actually attribute.
604fn provider_tool_block_authority(kind: Option<&str>) -> Option<(BlockAuthorityV1, &'static str)> {
605    match kind? {
606        "user-rejected" => Some((BlockAuthorityV1::UserRejected, "user-rejected")),
607        "permission-rule" => Some((BlockAuthorityV1::ProviderPermissionRule, "permission-rule")),
608        _ => None,
609    }
610}
611
612/// Splits a provider's own denial sentence into `reason`/`help`, when it
613/// matches the fixed shape Claude Code's classifier/permission-rule denial
614/// uses -- verified live, the owner's sample fixture
615/// (`docs/gate4agent/research/gate4agent-blocked-action-signals-2026-09-02.md`
616/// appendix): "...denied by <authority>. Reason: <reason>. <help...>".
617/// `authority` is NEVER derived from this text (see this module's own rule
618/// that authority comes only from a typed field) -- only `reason`/`help`
619/// are. Falls back to the full text as `reason` with no `help` when the
620/// shape does not match (a provider whose text does not carry this exact
621/// wording) -- the honest minimum: the tool truly did not run, so SOME
622/// reason is always reported, never fabricated detail beyond what arrived.
623fn split_provider_denial_text(text: &str) -> (String, Option<String>) {
624    let trimmed = text.trim();
625    let Some((_, after_reason)) = trimmed.split_once("Reason:") else {
626        return (trimmed.to_owned(), None);
627    };
628    let tail = after_reason.trim_start();
629    let Some((reason, help)) = tail.split_once(". ") else {
630        // "Reason:" present but no ". " to split help off -- the whole
631        // tail is the reason, e.g. "Reason: Blocked by classifier." with
632        // no help text following on some providers/versions.
633        let reason = tail.strip_suffix('.').unwrap_or(tail).trim();
634        return (reason.to_owned(), None);
635    };
636    let help = help.trim();
637    if help.is_empty() {
638        (reason.trim().to_owned(), None)
639    } else {
640        (reason.trim().to_owned(), Some(help.to_owned()))
641    }
642}
643
644/// `tool_class` for a provider-side `ActionBlocked` this build has no typed
645/// tool name for -- `ProviderEvent::ToolCompleted` carries only `id`, never
646/// `name` (see `ProviderEvent::ToolStarted`, which does, but a tool's own
647/// name is never threaded through to its completion). Reads the class off
648/// the guidance tail's own "add a <Class> permission rule" sentence when
649/// present (the shape the owner's sample fixture uses) -- text matching
650/// fills `tool_class` here, same as `reason`/`help`, NEVER `authority` (see
651/// this module's own rule). Falls back to the same generic `"Tool"` class
652/// `ToolCompleted`'s own observation already uses when nothing names one.
653fn tool_class_from_denial_help(help: Option<&str>) -> String {
654    help.and_then(|text| text.split("add a ").nth(1))
655        .and_then(|tail| tail.split(' ').next())
656        .filter(|word| !word.is_empty())
657        .map(str::to_owned)
658        .unwrap_or_else(|| "Tool".to_owned())
659}
660
661/// Whether a `session/prompt` RPC error's vendor code (or, absent one, its
662/// message) names a quota/rate-limit exhaustion -- Codex's
663/// `data.codexErrorInfo: "usageLimitExceeded"` (measured live 2026-09-05)
664/// contains "limit", the word this build actually keys on; "quota"/"rate"
665/// are kept for whichever OTHER vendor's own code names one of those
666/// instead, never confirmed live, but the honest generalization of the
667/// one confirmed sample.
668fn provider_error_is_quota(vendor_code: Option<&str>, message: &str) -> bool {
669    fn names_quota(s: &str) -> bool {
670        let lower = s.to_ascii_lowercase();
671        lower.contains("limit") || lower.contains("quota") || lower.contains("rate")
672    }
673    vendor_code.is_some_and(names_quota) || names_quota(message)
674}
675
676/// The `Blocked` chunk this build mints when a turn stopped via a
677/// typed provider fact rather than a tool call -- `stopReason: "refusal"`
678/// (`authority: ProviderRefusal`) or a `session/prompt` RPC error whose
679/// vendor code (or message) names a quota/limit/rate exhaustion
680/// (`authority: ProviderQuota`, `reason` = the error message verbatim,
681/// `reason_kind` = the vendor code). `None` for every other stop --
682/// `end_turn`, `max_tokens`, `max_turn_requests`, `cancelled`, an untagged
683/// `Other`, or an RPC error whose vendor code/message names none of the
684/// three words: `SessionEnded` stays a plain `Exited`/turn-end fact for
685/// those, never inflated into a block this build cannot actually
686/// attribute. `ProviderRefusal`'s `reason` has no source text to read on
687/// `SessionEnded` itself (ACP's `refusal` stopReason carries none) -- the
688/// honest minimum names the fact that happened, never invented detail; a
689/// later slice that threads the turn's last agent text chunk through can
690/// replace it without changing this function's contract.
691fn provider_session_end_block(
692    stop_reason: &ProviderStopReason,
693) -> Option<(BlockAuthorityV1, String, String)> {
694    match stop_reason {
695        ProviderStopReason::Refusal => Some((
696            BlockAuthorityV1::ProviderRefusal,
697            "refusal".to_owned(),
698            "the provider ended the turn with stopReason: refusal".to_owned(),
699        )),
700        ProviderStopReason::ProviderError { message, vendor_code, .. }
701            if provider_error_is_quota(vendor_code.as_deref(), message) =>
702        {
703            let reason_kind = vendor_code.clone().unwrap_or_else(|| "provider-error".to_owned());
704            Some((BlockAuthorityV1::ProviderQuota, reason_kind, message.clone()))
705        }
706        _ => None,
707    }
708}
709
710/// Builds the `AgentStreamChunkV1` `event`'s `ProviderEvent` carries onto the
711/// `agent-stream-events-v1` channel -- see
712/// `docs/gate4agent/plans/gate4agent-acp-control-plane-on-the-wire-2026-09-02.md`
713/// §5. Two invariants keep the chunk correlatable with the `NodeEvent::Control`
714/// event it derives from, so a client joining the two sees one id and one
715/// ordering:
716///
717/// - `source_sequence` on the chunk is the `source_sequence` the provider
718///   event itself carries.
719/// - `InteractionPrompt::correlation_id` is minted by
720///   `correlation::interaction_correlation` with the
721///   `(instance_id, generation, provider_sequence)` triple -- never the
722///   provider's own `request_id`, whose opacity on the wire is pinned by a
723///   dedicated test.
724///
725/// Returns `None` for every `ControlEventKind` other than `ProviderEvent`
726/// (nothing else carries stream content), for a source that is not a live
727/// session transport, and for the `ProviderEvent` variants below with no
728/// declared chunk kind to carry them, or whose declared chunk kind this build
729/// has no source for yet:
730///
731/// - `ModelCatalog { current, available }` has no `ProviderEvent` source at
732///   all -- grok's vendor model switch is not bridged into `ProviderEvent`
733///   yet, the same gap the plan names explicitly.
734/// - `UserMessage { text, is_delta }` is an echo of the OPERATOR's own
735///   prompt, replayed on session resume -- `Text`/`Thinking` are documented
736///   as "what the agent is saying", so mapping the operator's words onto
737///   `Text` would misrepresent whose content it is. No dedicated
738///   user-message kind is declared, so this stays unproduced rather than
739///   mislabeled.
740///
741/// `HostRequestObserved { .. }` is its own arm below: a `Granted`/`Deferred`
742/// decision returns `None` (nothing for the operator to react to), a `Denied`
743/// decision returns `Some(Blocked { .. })`.
744///
745/// `ToolCompleted { .. }`/`SessionEnded { .. }` are likewise their own arms
746/// below (K2): each returns `None` unless
747/// `provider_tool_block_authority`/`provider_session_end_block` classifies the
748/// event as an actual block (ACP's `_meta.nonExecutionKind`, or `stopReason`/an
749/// RPC error naming a quota).
750fn agent_stream_chunk(event: &ControlEvent) -> Option<AgentStreamChunkV1> {
751    let ControlEventKind::ProviderEvent {
752        sequence: provider_sequence,
753        source,
754        source_sequence,
755        event: provider_event,
756    } = &event.event
757    else {
758        return None;
759    };
760    if !provider_family_is_live(source.family) {
761        return None;
762    }
763    let kind = match provider_event {
764        ProviderEvent::Text { text, is_delta } => AgentStreamChunkKindV1::Text {
765            text: text.clone(),
766            is_delta: *is_delta,
767        },
768        ProviderEvent::Thinking { text } => {
769            AgentStreamChunkKindV1::Thinking { text: text.clone() }
770        }
771        ProviderEvent::InteractionRequested {
772            interaction_kind,
773            tool_name,
774            title,
775            prompt,
776            options,
777            ..
778        } => AgentStreamChunkKindV1::InteractionPrompt {
779            correlation_id: interaction_correlation(
780                event.instance_id,
781                event.generation,
782                *provider_sequence,
783            ),
784            interaction_kind: *interaction_kind,
785            tool_name: tool_name.clone(),
786            // `title`/`options` are carried straight through from
787            // `ProviderEvent::InteractionRequested`, as the agent itself
788            // gave them on `session/request_permission`'s `PermissionTool
789            // Call.title`/`options` (see `gate4agent-shell-native`'s
790            // `provider_event`, the only ACP-transport producer). A PTY- or
791            // hook-sourced interaction carries `title: None` and
792            // `options: []` from its own producer because that source has
793            // none to offer, never because this mapping dropped one.
794            title: title.clone(),
795            prompt: prompt.clone(),
796            options: options
797                .iter()
798                .map(|option| AgentStreamInteractionOptionV1 {
799                    option_id: option.option_id.clone(),
800                    name: option.name.clone(),
801                    kind: option.kind.clone(),
802                })
803                .collect(),
804        },
805        ProviderEvent::ConfigOptionsUpdated { options } => {
806            AgentStreamChunkKindV1::ConfigOptions { options: options.clone() }
807        }
808        // `available` is the mode catalogue `AcpSession::available_modes()`
809        // read back at `session/new` and threaded through by
810        // `gate4agent-shell-native`'s `provider_event` (see that crate's
811        // `ProviderEvent::ModeChanged` arm) -- an empty list here is the
812        // agent's own honest "no modes announced", never "not read": ACP
813        // orders `session/new` before any `session/update`, and the
814        // catalogue is fixed at handshake, so a `ModeChanged` event cannot
815        // exist before the same session object's catalogue is already
816        // populated (see `gate4agent_types::ProviderEvent::ModeChanged`'s
817        // own doc comment for the ordering argument in full).
818        ProviderEvent::ModeChanged { mode_id, available } => AgentStreamChunkKindV1::ModeCatalog {
819            current: Some(mode_id.clone()),
820            available: available
821                .iter()
822                .map(|mode| AgentStreamNamedIdV1 {
823                    id: mode.id.clone(),
824                    name: mode.name.clone(),
825                    description: mode.description.clone(),
826                })
827                .collect(),
828        },
829        // A denied host request is a block -- see this function's own doc
830        // comment for why `HostRequestObserved` is not in the exclusion list
831        // below.
832        ProviderEvent::HostRequestObserved { method, decision, reason, .. } => {
833            let ProviderHostRequestDecision::Denied { by } = decision else {
834                return None;
835            };
836            let (tool_class, authority, reason_text) =
837                host_request_denied_block(method, *by, reason.as_deref());
838            AgentStreamChunkKindV1::Blocked {
839                correlation_id: None,
840                tool_class,
841                authority,
842                reason_kind: None,
843                reason: reason_text,
844                help: None,
845            }
846        }
847        // Same relationship as `HostRequestObserved` above, driven by ACP's
848        // own `_meta.nonExecutionKind` instead of the host's
849        // `HostRequestDecision`.
850        ProviderEvent::ToolCompleted { id, output, non_execution_kind, .. } => {
851            let (authority, reason_kind) =
852                provider_tool_block_authority(non_execution_kind.as_deref())?;
853            let (reason, help) = split_provider_denial_text(output);
854            let (reason, _) =
855                truncate_text(&reason, MAX_ACP_BLOCKED_REASON_BYTES);
856            let help = help.map(|help| {
857                truncate_text(&help, MAX_ACP_BLOCKED_HELP_BYTES).0
858            });
859            AgentStreamChunkKindV1::Blocked {
860                correlation_id: Some(tool_correlation(event, source, id)),
861                tool_class: tool_class_from_denial_help(help.as_deref()),
862                authority,
863                reason_kind: Some(reason_kind.to_owned()),
864                reason,
865                help,
866            }
867        }
868        // Same relationship, driven by `stopReason: "refusal"` or a
869        // `session/prompt` RPC error naming a quota/limit/rate exhaustion
870        // instead of a tool-level fact.
871        ProviderEvent::SessionEnded { stop_reason, .. } => {
872            let (authority, reason_kind, reason) =
873                stop_reason.as_ref().and_then(provider_session_end_block)?;
874            let (reason, _) =
875                truncate_text(&reason, MAX_ACP_BLOCKED_REASON_BYTES);
876            AgentStreamChunkKindV1::Blocked {
877                correlation_id: None,
878                tool_class: "Session".to_owned(),
879                authority,
880                reason_kind: Some(reason_kind),
881                reason,
882                help: None,
883            }
884        }
885        ProviderEvent::SessionStarted { .. }
886        | ProviderEvent::SessionIdentityObserved { .. }
887        | ProviderEvent::TurnStarted { .. }
888        | ProviderEvent::WorkingObserved
889        | ProviderEvent::ToolStarted { .. }
890        | ProviderEvent::TurnCompleted { .. }
891        | ProviderEvent::ContextWindowUsage { .. }
892        | ProviderEvent::TurnInterrupted
893        | ProviderEvent::Error { .. }
894        | ProviderEvent::Ready
895        | ProviderEvent::InteractionResolved { .. }
896        | ProviderEvent::SubagentStarted { .. }
897        | ProviderEvent::SubagentStopped { .. }
898        | ProviderEvent::RateLimited { .. }
899        | ProviderEvent::UnrecognizedNotification { .. }
900        | ProviderEvent::UserMessage { .. }
901        | ProviderEvent::Plan { .. }
902        | ProviderEvent::AvailableCommandsUpdated { .. }
903        | ProviderEvent::SessionInfoUpdated { .. }
904        | ProviderEvent::UsageUpdated { .. } => return None,
905    };
906    Some(AgentStreamChunkV1 { source_sequence: *source_sequence, kind })
907}
908
909async fn run_native_session_catalog_operation<T, E, F>(
910    catalog: Arc<Mutex<NativeSessionCatalogAuthority>>,
911    operation: &'static str,
912    execute: F,
913) -> Result<T, NodeFailure>
914where
915    T: Send + 'static,
916    E: std::fmt::Display + Send + 'static,
917    F: FnOnce(&mut NativeSessionCatalogAuthority) -> Result<T, E> + Send + 'static,
918{
919    let task = tokio::task::spawn_blocking(move || {
920        let mut catalog = match catalog.try_lock() {
921            Ok(catalog) => catalog,
922            Err(std::sync::TryLockError::Poisoned(poisoned)) => poisoned.into_inner(),
923            Err(std::sync::TryLockError::WouldBlock) => {
924                return Err(failure(
925                    NodeFailureCode::BackendBusy,
926                    "native session catalog operation is already running",
927                ));
928            }
929        };
930        execute(&mut catalog).map_err(|error| {
931            failure(
932                NodeFailureCode::BackendOperationFailed,
933                &format!("{operation} failed: {error}"),
934            )
935        })
936    });
937    timeout(Duration::from_millis(NATIVE_SESSION_CATALOG_TIMEOUT_MS), task)
938        .await
939        .map_err(|_| {
940            failure(
941                NodeFailureCode::BackendBusy,
942                &format!("{operation} exceeded its bounded deadline"),
943            )
944        })?
945        .map_err(|_| {
946            failure(
947                NodeFailureCode::BackendOperationFailed,
948                &format!("{operation} task failed"),
949            )
950        })?
951}
952
953fn windows_path_text(path: &OpaqueHostPath) -> &str {
954    path.as_utf8()
955        .expect("Windows node only creates UTF-8 opaque host paths")
956}
957
958fn require_windows_path(path: OpaqueHostPath) -> Result<String, NodeFailure> {
959    path.as_utf8()
960        .map(str::to_owned)
961        .ok_or_else(|| failure(
962            NodeFailureCode::InvalidRequest,
963            "this node requires UTF-8 host paths",
964        ))
965}
966
967fn protocol_worktree(mut worktree: NativeGitWorktreeSnapshot) -> GitWorktreeSnapshot {
968    GitWorktreeSnapshot {
969        path: opaque_windows_path(worktree.path),
970        head: worktree.head,
971        branch: worktree.branch,
972        is_bare: worktree.is_bare,
973        is_main: worktree.is_main,
974        locked: worktree.locked,
975        lock_reason: worktree.lock_reason,
976        prunable: worktree.prunable,
977        prunable_reason: worktree.prunable_reason,
978        workspace_id: worktree.workspace_id.take(),
979    }
980}
981
982#[derive(Clone, Debug, Eq, PartialEq)]
983pub struct WorkspaceConfig {
984    workspace_id: WorkspaceId,
985    canonical_root: String,
986    worktree_service_mode: WorktreeServiceMode,
987    managed_worktree_profiles: BTreeMap<WorktreeProfileId, ManagedWorktreeProfile>,
988}
989
990impl WorkspaceConfig {
991    pub fn new(
992        workspace_id: WorkspaceId,
993        root: impl AsRef<Path>,
994    ) -> Result<Self, NodeServerError> {
995        let root = root.as_ref();
996        if !root.is_absolute() {
997            return Err(NodeServerError::InvalidWorkspaceRoot {
998                workspace_id,
999                path: root.to_string_lossy().into_owned(),
1000                message: "path is not absolute".to_owned(),
1001            });
1002        }
1003        let canonical = std::fs::canonicalize(root).map_err(|error| {
1004            NodeServerError::InvalidWorkspaceRoot {
1005                workspace_id: workspace_id.clone(),
1006                path: root.to_string_lossy().into_owned(),
1007                message: error.to_string(),
1008            }
1009        })?;
1010        if !canonical.is_dir() {
1011            return Err(NodeServerError::InvalidWorkspaceRoot {
1012                workspace_id,
1013                path: root.to_string_lossy().into_owned(),
1014                message: "path is not a directory".to_owned(),
1015            });
1016        }
1017        let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
1018            NodeServerError::InvalidWorkspaceRoot {
1019                workspace_id: workspace_id.clone(),
1020                path: path.to_string_lossy().into_owned(),
1021                message: "canonical path is not valid Unicode".to_owned(),
1022            }
1023        })?;
1024        let canonical_root = platform::normalize_canonical_root(canonical_root);
1025        validate_workspace_root(&workspace_id, &canonical_root)?;
1026        Ok(Self {
1027            workspace_id,
1028            canonical_root,
1029            worktree_service_mode: WorktreeServiceMode::Manual,
1030            managed_worktree_profiles: BTreeMap::new(),
1031        })
1032    }
1033
1034    pub fn workspace_id(&self) -> &WorkspaceId {
1035        &self.workspace_id
1036    }
1037
1038    pub fn canonical_root(&self) -> &str {
1039        &self.canonical_root
1040    }
1041
1042    pub fn with_worktree_service_mode(mut self, mode: WorktreeServiceMode) -> Self {
1043        self.worktree_service_mode = mode;
1044        self
1045    }
1046
1047    pub fn with_managed_worktree_profile(
1048        mut self,
1049        profile: ManagedWorktreeProfile,
1050    ) -> Result<Self, NodeServerError> {
1051        profile.validate_for_workspace(&self.canonical_root).map_err(|message| {
1052            NodeServerError::InvalidManagedWorktreeProfile {
1053                workspace_id: self.workspace_id.clone(),
1054                profile_id: profile.profile_id().clone(),
1055                message,
1056            }
1057        })?;
1058        if self.managed_worktree_profiles.contains_key(profile.profile_id()) {
1059            return Err(NodeServerError::DuplicateManagedWorktreeProfile {
1060                workspace_id: self.workspace_id,
1061                profile_id: profile.profile_id().clone(),
1062            });
1063        }
1064        if self.managed_worktree_profiles.len()
1065            == MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE
1066        {
1067            return Err(NodeServerError::ManagedWorktreeProfileCapacity {
1068                workspace_id: self.workspace_id,
1069                max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
1070            });
1071        }
1072        self.managed_worktree_profiles
1073            .insert(profile.profile_id().clone(), profile);
1074        Ok(self)
1075    }
1076}
1077
1078#[derive(Clone)]
1079pub struct NodeServerConfig {
1080    pub endpoint: String,
1081    api_listen: Option<std::net::SocketAddr>,
1082    /// Opt-in node-envelope HTTP+WS browser bridge (separate from `--api-listen`
1083    /// ops HTTP and from framed C2). Loopback only; default `None` = off.
1084    bridge_listen: Option<std::net::SocketAddr>,
1085    /// Optional bridge shared secret (`GATE4AGENT_BRIDGE_TOKEN`). Distinct from
1086    /// NODE_TOKEN; never logged. `None` = loopback bind is the only gate.
1087    bridge_token: Option<String>,
1088    /// Tip 6: optional mesh underlay UDP bind that relays to local `--bridge-listen`.
1089    /// Requires bridge_listen + bridge_token. HQ never accepts (NodePeer).
1090    bridge_underlay: Option<browser_bridge::BridgeUnderlayAccept>,
1091    /// A relay to CALL, rather than wait to be called by. `None` keeps the
1092    /// node purely a listener, which is what it has always been.
1093    call_home: Option<std::net::SocketAddr>,
1094    /// When set, call-home authenticates by signing the relay's challenge with this
1095    /// identity (enrolled node) and falls back to the token handshake against a relay
1096    /// that sends no challenge.
1097    call_home_identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
1098    pub node_id: NodeId,
1099    pub workspaces: Vec<WorkspaceConfig>,
1100    access_token: String,
1101    pub runtime: NativeRuntimeConfig,
1102    session_record_retention: SessionRecordRetentionConfig,
1103    state_path: Option<PathBuf>,
1104    spawn_profiles: SpawnProfileRegistry,
1105    session_environment: Option<NodeSessionEnvironmentConfig>,
1106    history: Option<NativeHistoryConfig>,
1107    harness_mcp_helper: Option<ReviewedHarnessMcpProgram>,
1108    /// Station network allowlist catalog (opaque ids). Empty default.
1109    /// Loaded at startup from `--network-allowlist-catalog` / env
1110    /// `GATE4AGENT_NODE_NETWORK_ALLOWLIST_CATALOG` file. Never secrets.
1111    network_allowlist_catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog,
1112    #[cfg(feature = "fixture")]
1113    fixture_raw_pty_runtime: bool,
1114}
1115
1116#[derive(Clone)]
1117struct NodeSessionEnvironmentConfig {
1118    root: PathBuf,
1119    resolver: Arc<dyn NodeSecretResolver>,
1120}
1121
1122impl NodeServerConfig {
1123    pub fn new(
1124        endpoint: impl Into<String>,
1125        access_token: impl Into<String>,
1126        node_id: NodeId,
1127        workspaces: impl IntoIterator<Item = WorkspaceConfig>,
1128    ) -> Result<Self, NodeServerError> {
1129        let endpoint = endpoint.into();
1130        let access_token = access_token.into();
1131        if !platform::validate_endpoint(&endpoint) {
1132            return Err(NodeServerError::InvalidEndpoint);
1133        }
1134        if access_token.is_empty() || access_token.len() > 4_096 {
1135            return Err(NodeServerError::InvalidAccessToken);
1136        }
1137        let workspaces = workspaces.into_iter().collect::<Vec<_>>();
1138        if workspaces.is_empty() {
1139            return Err(NodeServerError::NoWorkspaces);
1140        }
1141        let mut workspace_ids = BTreeMap::new();
1142        let mut workspace_roots = BTreeMap::new();
1143        for workspace in &workspaces {
1144            if workspace.worktree_service_mode == WorktreeServiceMode::Managed
1145                && workspace.managed_worktree_profiles.is_empty()
1146            {
1147                return Err(NodeServerError::ManagedWorktreeProfileRequired(
1148                    workspace.workspace_id.clone(),
1149                ));
1150            }
1151            if workspace.worktree_service_mode != WorktreeServiceMode::Managed
1152                && !workspace.managed_worktree_profiles.is_empty()
1153            {
1154                return Err(NodeServerError::ManagedWorktreeProfileModeMismatch(
1155                    workspace.workspace_id.clone(),
1156                ));
1157            }
1158            if workspace_ids
1159                .insert(workspace.workspace_id.clone(), ())
1160                .is_some()
1161            {
1162                return Err(NodeServerError::DuplicateWorkspaceId(
1163                    workspace.workspace_id.clone(),
1164                ));
1165            }
1166            let root_key = platform::root_identity(&workspace.canonical_root);
1167            if let Some(existing) = workspace_roots.insert(
1168                root_key,
1169                workspace.workspace_id.clone(),
1170            ) {
1171                return Err(NodeServerError::DuplicateWorkspaceRoot {
1172                    first: existing,
1173                    second: workspace.workspace_id.clone(),
1174                    root: workspace.canonical_root.clone(),
1175                });
1176            }
1177        }
1178        Ok(Self {
1179            endpoint,
1180            api_listen: None,
1181            bridge_listen: None,
1182            bridge_token: None,
1183            bridge_underlay: None,
1184            call_home: None,
1185            call_home_identity: None,
1186            node_id,
1187            workspaces,
1188            access_token,
1189            // PTY output is polled by the runtime's effect worker; the upstream
1190            // default (20ms) added ~10-20ms to every keystroke echo.
1191            runtime: NativeRuntimeConfig {
1192                worker_poll_interval_ms: 4,
1193                ..NativeRuntimeConfig::default()
1194            },
1195            session_record_retention: SessionRecordRetentionConfig::default(),
1196            state_path: None,
1197            spawn_profiles: SpawnProfileRegistry::default(),
1198            session_environment: None,
1199            history: None,
1200            harness_mcp_helper: None,
1201            network_allowlist_catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog::new(),
1202            #[cfg(feature = "fixture")]
1203            fixture_raw_pty_runtime: false,
1204        })
1205    }
1206
1207    pub fn with_api_listen(
1208        mut self,
1209        api_listen: std::net::SocketAddr,
1210    ) -> Result<Self, NodeServerError> {
1211        if !api_listen.ip().is_loopback() {
1212            return Err(NodeServerError::InvalidApiListen(api_listen));
1213        }
1214        self.api_listen = Some(api_listen);
1215        Ok(self)
1216    }
1217
1218    /// Opt-in node-envelope HTTP+WS bridge listener. Loopback only (same spirit
1219    /// as `with_api_listen`). Does not enable by default — libraries and the
1220    /// binary leave this `None` until `--bridge-listen` / `with_bridge_listen`.
1221    pub fn with_bridge_listen(
1222        mut self,
1223        bridge_listen: std::net::SocketAddr,
1224    ) -> Result<Self, NodeServerError> {
1225        if !bridge_listen.ip().is_loopback() {
1226            return Err(NodeServerError::InvalidBridgeListen(bridge_listen));
1227        }
1228        self.bridge_listen = Some(bridge_listen);
1229        Ok(self)
1230    }
1231
1232    /// Optional bridge shared secret, distinct from the C2/node access token.
1233    /// Never log the value. Empty / oversize refused.
1234    pub fn with_bridge_token(
1235        mut self,
1236        bridge_token: impl Into<String>,
1237    ) -> Result<Self, NodeServerError> {
1238        let bridge_token = bridge_token.into();
1239        if bridge_token.is_empty() || bridge_token.len() > 4_096 {
1240            return Err(NodeServerError::InvalidBridgeToken);
1241        }
1242        self.bridge_token = Some(bridge_token);
1243        Ok(self)
1244    }
1245
1246    /// Tip 6: point bridge reachability over mesh underlay (UDP+AEAD relay to
1247    /// local loopback `--bridge-listen`). Requires bridge listen + bridge token
1248    /// already configured. Transport key is 32 raw bytes (never logged).
1249    /// Underlay auth token is distinct from BRIDGE_TOKEN (crypto ≠ auth).
1250    pub fn with_bridge_underlay(
1251        mut self,
1252        bind: std::net::SocketAddr,
1253        transport_key: [u8; 32],
1254        underlay_token: impl Into<String>,
1255    ) -> Result<Self, NodeServerError> {
1256        if self.bridge_listen.is_none() {
1257            return Err(NodeServerError::BridgeUnderlayRequiresBridgeListen);
1258        }
1259        if self.bridge_token.is_none() {
1260            return Err(NodeServerError::BridgeUnderlayRequiresBridgeToken);
1261        }
1262        let underlay_token = underlay_token.into();
1263        if underlay_token.is_empty() || underlay_token.len() > 4_096 {
1264            return Err(NodeServerError::InvalidBridgeUnderlayToken);
1265        }
1266        // Port 0 is allowed (ephemeral). Role lock (no HQ accept) lives in underlay.
1267        self.bridge_underlay = Some(browser_bridge::BridgeUnderlayAccept {
1268            bind,
1269            transport_key,
1270            underlay_token,
1271        });
1272        Ok(self)
1273    }
1274
1275    /// Also dial `relay` and serve this wire over the connection, in
1276    /// addition to accepting on the local endpoint.
1277    ///
1278    /// Who opens the socket and who is the protocol's server are separate
1279    /// questions, and this is the node's half of separating them. The node
1280    /// stays the server either way -- it answers `NodeRequest`s and emits
1281    /// `NodeEvent`s exactly as it does for a relay that dialled it. All
1282    /// that changes is that a node with no address anyone can reach makes
1283    /// the connection itself, and names itself once it arrives (see
1284    /// `gate4agent_node_wire::write_call_home_announce`).
1285    ///
1286    /// Loopback only, and that is not an oversight to be flagged and
1287    /// worked around. This wire carries terminal contents, keystrokes and
1288    /// file bytes as plaintext JSON; its mutual challenge-response
1289    /// authenticates both ends and encrypts nothing. Every listener in
1290    /// this stack is loopback-guarded for that reason, and a dialler must
1291    /// hold the same line -- a node calling a public relay would put the
1292    /// whole control plane on the wire in the clear. Reaching a relay on
1293    /// another host is a transport question, and it stays unanswered here
1294    /// on purpose: this change is about direction, not distance.
1295    pub fn with_call_home(
1296        mut self,
1297        relay: std::net::SocketAddr,
1298    ) -> Result<Self, NodeServerError> {
1299        // Loopback, or the concrete C2 WireGuard tunnel address. Unspecified
1300        // and port 0 refused — plaintext wire must not dial every interface.
1301        if relay.port() == 0 || relay.ip().is_unspecified() {
1302            return Err(NodeServerError::InvalidCallHome(relay));
1303        }
1304        self.call_home = Some(relay);
1305        Ok(self)
1306    }
1307
1308    /// Authenticate call-home with a node-held identity instead of a standing token.
1309    pub fn with_call_home_identity(mut self, identity: Arc<hatchery_enroll::NodeIdentity>) -> Self {
1310        self.call_home_identity = Some(identity);
1311        self
1312    }
1313
1314    pub fn with_state_path(
1315        mut self,
1316        state_path: impl Into<PathBuf>,
1317    ) -> Result<Self, NodeServerError> {
1318        let state_path = state_path.into();
1319        if !state_path.is_absolute() || state_path.file_name().is_none() {
1320            return Err(NodeServerError::InvalidStatePath(
1321                state_path.to_string_lossy().into_owned(),
1322            ));
1323        }
1324        self.state_path = Some(state_path);
1325        Ok(self)
1326    }
1327
1328    pub fn with_spawn_profiles(mut self, spawn_profiles: SpawnProfileRegistry) -> Self {
1329        self.spawn_profiles = spawn_profiles;
1330        self
1331    }
1332
1333    /// Sets the age/keep-N policy the drive loop's retention sweep uses to
1334    /// retire dead `ManagedSessionState::Unavailable` records. Both fields
1335    /// default to `0` (disabled) via `SessionRecordRetentionConfig::default`
1336    /// when this is never called -- a node never deletes a durable record
1337    /// until an operator opts in with a real value.
1338    pub fn with_session_record_retention(
1339        mut self,
1340        retention: SessionRecordRetentionConfig,
1341    ) -> Self {
1342        self.session_record_retention = retention;
1343        self
1344    }
1345
1346    pub fn with_session_environment_materialization(
1347        mut self,
1348        root: impl Into<PathBuf>,
1349        resolver: Arc<dyn NodeSecretResolver>,
1350    ) -> Result<Self, NodeServerError> {
1351        let root = root.into();
1352        if !root.is_absolute() {
1353            return Err(NodeServerError::InvalidSessionEnvironmentRoot);
1354        }
1355        self.session_environment = Some(NodeSessionEnvironmentConfig { root, resolver });
1356        Ok(self)
1357    }
1358
1359    /// Enables bounded provider-history discovery and loading from explicit
1360    /// host-owned roots. Ambient vendor homes are never inferred here.
1361    pub fn with_history(mut self, history: NativeHistoryConfig) -> Self {
1362        self.history = Some(history);
1363        self
1364    }
1365
1366    /// Enables the H3B read proxy with one exact operator-reviewed helper file.
1367    /// Linux/macOS use owner-only UDS; Windows uses owner-only named pipes.
1368    /// Both platforms review the helper the same way (absolute regular file +
1369    /// identity hash) — the old `cfg(not(windows))` hard-refuse was a leftover
1370    /// gate after Unix listener/peer-cred support landed.
1371    pub fn with_harness_mcp_helper(
1372        mut self,
1373        helper_program: impl Into<PathBuf>,
1374    ) -> Result<Self, NodeServerError> {
1375        self.harness_mcp_helper = Some(
1376            ReviewedHarnessMcpProgram::review(helper_program.into())
1377                .map_err(|_| NodeServerError::InvalidHarnessMcpHelper)?,
1378        );
1379        Ok(self)
1380    }
1381
1382    /// Install an in-memory station network allowlist catalog (opaque ids +
1383    /// optional node-local permits / provider-native mapping). Empty remains
1384    /// the default — unknown spawn ids refuse at resolve. Never cookies /
1385    /// OAuth / proxy credentials. C2 / inventory still list ids only.
1386    pub fn with_network_allowlist_catalog(
1387        mut self,
1388        catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog,
1389    ) -> Result<Self, NodeServerError> {
1390        if catalog.len() > crate::network_allowlist_catalog::MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES {
1391            return Err(NodeServerError::NetworkAllowlistCatalogCapacity {
1392                max: crate::network_allowlist_catalog::MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES,
1393            });
1394        }
1395        self.network_allowlist_catalog = catalog;
1396        Ok(self)
1397    }
1398
1399    /// Load station network allowlist catalog from an absolute regular file
1400    /// (v1 id-list or v2 JSON schema). Unset path is not used here — callers
1401    /// that want env fallback use `resolve_network_allowlist_catalog`.
1402    pub fn with_network_allowlist_catalog_file(
1403        self,
1404        path: impl AsRef<std::path::Path>,
1405    ) -> Result<Self, NodeServerError> {
1406        let catalog = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(path)
1407            .map_err(NodeServerError::from)?;
1408        self.with_network_allowlist_catalog(catalog)
1409    }
1410}
1411
1412pub fn default_state_path(node_id: &NodeId) -> Result<PathBuf, NodeServerError> {
1413    platform::default_state_path(node_id.as_str())
1414        .ok_or(NodeServerError::LocalStateDirectoryUnavailable)
1415}
1416
1417pub fn default_node_endpoint() -> Result<PathBuf, NodeServerError> {
1418    platform::default_node_endpoint().ok_or(NodeServerError::LocalRuntimeDirectoryUnavailable)
1419}
1420
1421fn delivery_store_root_for_state_path(state_path: &Path) -> Option<PathBuf> {
1422    let parent = state_path.parent()?;
1423    let mut name = state_path.file_name()?.to_os_string();
1424    name.push(".delivery-store");
1425    Some(parent.join(name))
1426}
1427
1428fn context_pack_store_root_for_state_path(state_path: &Path) -> Option<PathBuf> {
1429    let parent = state_path.parent()?;
1430    let mut name = state_path.file_name()?.to_os_string();
1431    name.push(".context-pack-store");
1432    Some(parent.join(name))
1433}
1434
1435pub struct NodeServer {
1436    config: NodeServerConfig,
1437    runtime: NativeRuntime,
1438    shared: Arc<NodeShared>,
1439    events: EventSubscription,
1440    state_path_lock: Option<session_registry::StatePathLock>,
1441}
1442
1443#[cfg(feature = "fixture")]
1444#[derive(Clone, Default)]
1445pub struct SpawnManagedWorktreeV2FailureProbe {
1446    latest_code: Arc<Mutex<Option<NodeFailureCode>>>,
1447}
1448
1449#[cfg(feature = "fixture")]
1450impl SpawnManagedWorktreeV2FailureProbe {
1451    pub fn latest_code(&self) -> Option<NodeFailureCode> {
1452        *self
1453            .latest_code
1454            .lock()
1455            .unwrap_or_else(|poisoned| poisoned.into_inner())
1456    }
1457
1458    fn record(&self, code: Option<NodeFailureCode>) {
1459        *self
1460            .latest_code
1461            .lock()
1462            .unwrap_or_else(|poisoned| poisoned.into_inner()) = code;
1463    }
1464}
1465
1466#[cfg(all(feature = "fixture", windows))]
1467struct IsolatedCodexHomeFixtureEnvironment;
1468
1469#[cfg(all(feature = "fixture", windows))]
1470impl gate4agent_runtime_native::NativeChildEnvironmentResolver
1471    for IsolatedCodexHomeFixtureEnvironment
1472{
1473    fn resolve_child_environment(
1474        &self,
1475    ) -> Result<
1476        Vec<gate4agent_catalog::EnvMutation>,
1477        gate4agent_runtime_native::NativeChildEnvironmentResolveError,
1478    > {
1479        Ok(vec![gate4agent_catalog::EnvMutation {
1480            key: OsString::from("USERPROFILE"),
1481            value: None,
1482        }])
1483    }
1484}
1485
1486impl NodeServer {
1487    /// The letter-wake handle for the door, when m4a accounts are on. `token` is what callers
1488    /// must present.
1489    /// Turn on the m4a signer proxy for PTY spawns (replaces `M4A_SESSION_CAP`).
1490    #[cfg(unix)]
1491    pub fn set_signer_proxy(&self, proxy: Arc<crate::keystore_proxy::SignerProxy>) -> bool {
1492        self.shared.signer_proxy.set(proxy).is_ok()
1493    }
1494
1495    pub fn wake_handle(&self, token: String, policy: Arc<crate::wake_pipeline::WakePolicy>) -> Option<node_wake::NodeWake> {
1496        self.shared.m4a_accounts.as_ref()?;
1497        Some(node_wake::NodeWake::new(Arc::clone(&self.shared), token, policy))
1498    }
1499
1500    pub fn new(config: NodeServerConfig) -> Result<Self, NodeServerError> {
1501        let catalog = active_registry()?;
1502        Self::new_with_registry(config, catalog)
1503    }
1504
1505    #[cfg(feature = "fixture")]
1506    pub fn new_fixture(config: NodeServerConfig) -> Result<Self, NodeServerError> {
1507        let mut spec = gate4agent_testkit::interactive_agent_spec();
1508        spec.id = AgentId::new("claude").map_err(|error| NodeServerError::Registry(error.to_string()))?;
1509        spec.display_name = "Controlled Claude PTY fixture".to_owned();
1510        Self::new_fixture_with_spec(config, spec)
1511    }
1512
1513    #[cfg(feature = "fixture")]
1514    pub fn new_clean_exit_fixture(
1515        config: NodeServerConfig,
1516        fixture_root: PathBuf,
1517        started_marker: PathBuf,
1518        release_signal: PathBuf,
1519    ) -> Result<Self, NodeServerError> {
1520        let mut spec = gate4agent_testkit::controlled_clean_exit_agent_spec(
1521            &fixture_root,
1522            &started_marker,
1523            &release_signal,
1524        )
1525        .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1526        spec.id = AgentId::new("claude")
1527            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1528        spec.display_name = "Controlled Claude clean-exit fixture".to_owned();
1529        Self::new_fixture_with_spec(config, spec)
1530    }
1531
1532    /// A `claude` source whose session establishes a verified provider
1533    /// identity via an early `SessionStart` Hook post (so it is eligible for
1534    /// auto-export-at-exit and its native history is independently
1535    /// loadable), waits for `release_signal`, then exits cleanly — paired
1536    /// on the same Node with a plain `codex` target that never validates
1537    /// anything about what it receives. Built for A2 durable ContextPack
1538    /// E2E coverage: the test itself inspects materialized files and
1539    /// managed-session-record fields directly, rather than having the
1540    /// target script self-validate.
1541    #[cfg(all(feature = "fixture", windows))]
1542    pub fn new_durable_context_pack_clean_exit_fixture(
1543        config: NodeServerConfig,
1544        fixture_root: PathBuf,
1545        started_marker: PathBuf,
1546        release_signal: PathBuf,
1547        session_id: &str,
1548    ) -> Result<Self, NodeServerError> {
1549        let mut source_spec = gate4agent_testkit::identified_clean_exit_agent_spec(
1550            &fixture_root,
1551            &started_marker,
1552            &release_signal,
1553            session_id,
1554        )
1555        .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1556        let source_id = AgentId::new("claude")
1557            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1558        let source_provider = builtin_registry().get(&source_id).ok_or_else(|| {
1559            NodeServerError::Registry(
1560                "durable context pack clean-exit fixture source adapter is unavailable"
1561                    .to_owned(),
1562            )
1563        })?;
1564        source_spec.capabilities.adapters.history =
1565            source_provider.capabilities.adapters.history.clone();
1566        source_spec.id = source_id;
1567        source_spec.display_name =
1568            "Controlled Claude durable context-pack clean-exit fixture".to_owned();
1569        // Both specs launch via `powershell.exe`; the registry's ambiguity
1570        // check keys on `detection.command` (+ aliases), not `launch.program`
1571        // -- give each a distinct detection identity, same precedent as
1572        // `context_pack_fixture_catalog`'s per-provider
1573        // `gate4agent-{provider}-context-fixture.cmd` commands.
1574        source_spec.detection.command =
1575            "gate4agent-claude-durable-context-pack-fixture.cmd".to_owned();
1576        source_spec.detection.aliases.clear();
1577
1578        let mut target_spec = gate4agent_testkit::interactive_agent_spec();
1579        target_spec.id = AgentId::new("codex")
1580            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1581        target_spec.display_name =
1582            "Controlled Codex durable context-pack target fixture".to_owned();
1583        target_spec.detection.command =
1584            "gate4agent-codex-durable-context-pack-fixture.cmd".to_owned();
1585        target_spec.detection.aliases.clear();
1586
1587        let catalog = AgentRegistry::new([source_spec, target_spec])
1588            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1589        let mut server = Self::new_with_registry(config, catalog)?;
1590        Arc::get_mut(&mut server.shared)
1591            .expect("fixture Node shared state must remain uniquely owned before run")
1592            .fixture_semantic_hook_policy = true;
1593        Ok(server)
1594    }
1595
1596    #[cfg(feature = "fixture")]
1597    pub fn new_monitoring_hook_fixture(
1598        config: NodeServerConfig,
1599    ) -> Result<Self, NodeServerError> {
1600        let mut spec = gate4agent_testkit::monitoring_hook_agent_spec();
1601        spec.id = AgentId::new("claude")
1602            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1603        let mut server = Self::new_fixture_with_spec(config, spec)?;
1604        Arc::get_mut(&mut server.shared)
1605            .expect("fixture Node shared state must remain uniquely owned before run")
1606            .fixture_semantic_hook_policy = true;
1607        Ok(server)
1608    }
1609
1610    #[cfg(all(feature = "fixture", windows))]
1611    pub fn new_provider_bundle_argv_fixture(
1612        config: NodeServerConfig,
1613        agent_id: AgentId,
1614        proof_path: PathBuf,
1615    ) -> Result<Self, NodeServerError> {
1616        let spec = Self::provider_bundle_argv_fixture_spec(agent_id, proof_path, None)?;
1617        Self::new_fixture_with_spec(config, spec)
1618    }
1619
1620    #[cfg(all(feature = "fixture", windows))]
1621    pub fn new_provider_bundle_argv_hold_fixture(
1622        config: NodeServerConfig,
1623        agent_id: AgentId,
1624        proof_path: PathBuf,
1625        release_signal: PathBuf,
1626    ) -> Result<Self, NodeServerError> {
1627        let spec = Self::provider_bundle_argv_fixture_spec(
1628            agent_id,
1629            proof_path,
1630            Some(release_signal),
1631        )?;
1632        Self::new_fixture_with_spec(config, spec)
1633    }
1634
1635    #[cfg(all(feature = "fixture", windows))]
1636    fn provider_bundle_argv_fixture_spec(
1637        agent_id: AgentId,
1638        proof_path: PathBuf,
1639        release_signal: Option<PathBuf>,
1640    ) -> Result<gate4agent_types::AgentSpec, NodeServerError> {
1641        if !matches!(agent_id.as_str(), "claude" | "codex" | "kimi") {
1642            return Err(NodeServerError::Registry(
1643                "provider bundle argv fixture requires Claude, Codex, or Kimi".to_owned(),
1644            ));
1645        }
1646        let mut spec = gate4agent_testkit::interactive_agent_spec();
1647        spec.id = agent_id.clone();
1648        spec.display_name = format!("Controlled {agent_id} bundle argv fixture");
1649        let provider = builtin_registry()
1650            .get(&agent_id)
1651            .ok_or_else(|| {
1652                NodeServerError::Registry(
1653                    "provider bundle argv fixture adapter is unavailable".to_owned(),
1654                )
1655            })?;
1656        spec.capabilities.adapters.history = provider.capabilities.adapters.history.clone();
1657        if !proof_path.is_absolute()
1658            || !proof_path
1659                .parent()
1660                .is_some_and(|parent| parent.is_dir())
1661        {
1662            return Err(NodeServerError::Registry(
1663                "provider bundle argv proof path requires an existing absolute parent"
1664                    .to_owned(),
1665            ));
1666        }
1667        if let Some(release_signal) = release_signal.as_ref() {
1668            if !release_signal.is_absolute()
1669                || !release_signal
1670                    .parent()
1671                    .is_some_and(|parent| parent.is_dir())
1672            {
1673                return Err(NodeServerError::Registry(
1674                    "provider bundle argv release path requires an existing absolute parent"
1675                        .to_owned(),
1676                ));
1677            }
1678            if release_signal.exists() {
1679                return Err(NodeServerError::Registry(
1680                    "provider bundle argv release path must not exist".to_owned(),
1681                ));
1682            }
1683            let proof_parent = std::fs::canonicalize(
1684                proof_path
1685                    .parent()
1686                    .expect("validated provider bundle proof parent"),
1687            )
1688            .map_err(|_| {
1689                NodeServerError::Registry(
1690                    "provider bundle argv proof parent is unavailable".to_owned(),
1691                )
1692            })?;
1693            let release_parent = std::fs::canonicalize(
1694                release_signal
1695                    .parent()
1696                    .expect("validated provider bundle release parent"),
1697            )
1698            .map_err(|_| {
1699                NodeServerError::Registry(
1700                    "provider bundle argv release parent is unavailable".to_owned(),
1701                )
1702            })?;
1703            if proof_parent != release_parent {
1704                return Err(NodeServerError::Registry(
1705                    "provider bundle argv release path must share the proof parent".to_owned(),
1706                ));
1707            }
1708        }
1709        let proof_path = proof_path.into_os_string().into_string().map_err(|_| {
1710            NodeServerError::Registry(
1711                "provider bundle argv proof path is not valid Unicode".to_owned(),
1712            )
1713        })?;
1714        let release_signal = release_signal.map(|release_signal| {
1715            release_signal.into_os_string().into_string().map_err(|_| {
1716                NodeServerError::Registry(
1717                    "provider bundle argv release path is not valid Unicode".to_owned(),
1718                )
1719            })
1720        }).transpose()?;
1721        let script = spec
1722            .launch
1723            .fixed_args
1724            .pop()
1725            .ok_or_else(|| NodeServerError::Registry("PTY fixture script is unavailable".to_owned()))?;
1726        let bundle_validation = if agent_id.as_str() == "codex" {
1727            "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') }"
1728        } else if agent_id.as_str() == "claude" {
1729            "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 }"
1730        } else {
1731            "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 }"
1732        };
1733        if let Some(release_signal) = release_signal {
1734            spec.launch.fixed_args.push(format!(
1735                "& {{ 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} }}",
1736            ));
1737            spec.launch.fixed_args.push(proof_path);
1738            spec.launch.fixed_args.push(release_signal);
1739        } else {
1740            spec.launch.fixed_args.push(format!(
1741                "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {bundle_validation}; {script} }}",
1742            ));
1743            spec.launch.fixed_args.push(proof_path);
1744        }
1745        Ok(spec)
1746    }
1747
1748    #[cfg(all(feature = "fixture", windows))]
1749    pub fn new_context_pack_fixture(
1750        config: NodeServerConfig,
1751        proof_path: PathBuf,
1752    ) -> Result<Self, NodeServerError> {
1753        let catalog = Self::context_pack_fixture_catalog(proof_path, None, false)?;
1754        Self::new_with_registry(config, catalog)
1755    }
1756
1757    #[cfg(all(feature = "fixture", windows))]
1758    pub fn new_monitoring_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, true)?;
1763        let mut server = Self::new_with_registry(config, catalog)?;
1764        Arc::get_mut(&mut server.shared)
1765            .expect("fixture Node shared state must remain uniquely owned before run")
1766            .fixture_semantic_hook_policy = true;
1767        Ok(server)
1768    }
1769
1770    #[cfg(all(feature = "fixture", windows))]
1771    pub fn install_isolated_codex_home_environment_fixture(
1772        &mut self,
1773        profile_id: SpawnEnvironmentProfileId,
1774        profile_revision: crate::protocol::SpawnEnvironmentProfileRevision,
1775    ) -> Result<(), NodeServerError> {
1776        let provider = AgentId::new("codex")
1777            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1778        let native_profile = gate4agent_runtime_native::NativeLaunchProfile::new(
1779            gate4agent_runtime_native::NativeLaunchProfileId::new(format!(
1780                "{}-pty",
1781                profile_id.as_str(),
1782            ))
1783            .map_err(|error| NodeServerError::Registry(error.to_string()))?,
1784            provider.clone(),
1785            TransportKind::Pty,
1786            vec![OsString::from("USERPROFILE")],
1787            Arc::new(IsolatedCodexHomeFixtureEnvironment),
1788        )
1789        .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1790        let materialization = NodeSessionMaterializationProfile::new(
1791            Vec::new(),
1792            vec![crate::session_environment::NodeSessionPathBinding::new(
1793                "CODEX_HOME",
1794                crate::session_environment::NodeSessionPathClass::ProviderHome,
1795            )
1796            .map_err(|error| NodeServerError::Registry(error.to_string()))?],
1797            Vec::new(),
1798        )
1799        .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1800        self.install_environment_profile(
1801            NodeEnvironmentProfile::new_with_materialization(
1802                profile_id,
1803                profile_revision,
1804                provider,
1805                [native_profile],
1806                Some(materialization),
1807            )
1808            .map_err(|error| NodeServerError::Registry(error.to_string()))?,
1809        )
1810    }
1811
1812    #[cfg(all(feature = "fixture", windows))]
1813    pub fn new_context_only_proof_fixture(
1814        config: NodeServerConfig,
1815        target_provider: AgentId,
1816        proof_path: PathBuf,
1817    ) -> Result<Self, NodeServerError> {
1818        if target_provider.as_str() != "kimi" {
1819            return Err(NodeServerError::Registry(
1820                "context-only proof fixture requires Kimi".to_owned(),
1821            ));
1822        }
1823        let catalog = Self::context_pack_fixture_catalog(
1824            proof_path,
1825            Some(&target_provider),
1826            false,
1827        )?;
1828        Self::new_with_registry(config, catalog)
1829    }
1830
1831    #[cfg(all(feature = "fixture", windows))]
1832    fn context_pack_fixture_catalog(
1833        proof_path: PathBuf,
1834        context_only_target: Option<&AgentId>,
1835        monitoring_claude_source: bool,
1836    ) -> Result<AgentRegistry, NodeServerError> {
1837        if !proof_path.is_absolute()
1838            || !proof_path
1839                .parent()
1840                .is_some_and(|parent| parent.is_dir())
1841        {
1842            return Err(NodeServerError::Registry(
1843                "context pack proof path requires an existing absolute parent".to_owned(),
1844            ));
1845        }
1846        let proof_path = proof_path.into_os_string().into_string().map_err(|_| {
1847            NodeServerError::Registry(
1848                "context pack proof path is not valid Unicode".to_owned(),
1849            )
1850        })?;
1851        let providers = ["claude", "codex", "grok", "kimi"];
1852        let builtins = builtin_registry();
1853        let mut specs = Vec::with_capacity(providers.len());
1854        for provider_id in providers {
1855            let agent_id = AgentId::new(provider_id)
1856                .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1857            let provider = builtins.get(&agent_id).ok_or_else(|| {
1858                NodeServerError::Registry(
1859                    "context pack fixture provider adapter is unavailable".to_owned(),
1860                )
1861            })?;
1862            let monitored_provider =
1863                monitoring_claude_source && matches!(provider_id, "claude" | "codex");
1864            let mut spec = if monitored_provider {
1865                gate4agent_testkit::monitoring_hook_agent_spec()
1866            } else {
1867                gate4agent_testkit::interactive_agent_spec()
1868            };
1869            spec.id = agent_id.clone();
1870            spec.display_name = format!("Controlled {agent_id} context pack fixture");
1871            spec.detection.command = format!("gate4agent-{provider_id}-context-fixture.cmd");
1872            spec.detection.aliases.clear();
1873            spec.capabilities.adapters.history = provider.capabilities.adapters.history.clone();
1874            if provider_id == "codex" {
1875                let script = spec.launch.fixed_args.pop().ok_or_else(|| {
1876                    NodeServerError::Registry("PTY fixture script is unavailable".to_owned())
1877                })?;
1878                let validation = r#"
1879$bundleArgs = @($bundleArgs)
1880if ($bundleArgs.Count -ne 0) { exit 91 }
1881$codexHome = [Environment]::GetEnvironmentVariable('CODEX_HOME', 'Process')
1882$contextRoot = [Environment]::GetEnvironmentVariable('GATE4AGENT_CONTEXT_ROOT', 'Process')
1883$cwd = (Get-Location).ProviderPath
1884if ([string]::IsNullOrEmpty($codexHome) -or -not [IO.Path]::IsPathRooted($codexHome) -or -not (Test-Path -LiteralPath $codexHome -PathType Container)) { exit 92 }
1885$skillPath = Join-Path $codexHome 'skills/review-code/SKILL.md'
1886if (-not (Test-Path -LiteralPath $skillPath -PathType Leaf)) { exit 93 }
1887$homeFiles = @(Get-ChildItem -LiteralPath $codexHome -Recurse -Force -File)
1888if ($homeFiles.Count -ne 1 -or [IO.Path]::GetFullPath($homeFiles[0].FullName) -ine [IO.Path]::GetFullPath($skillPath)) { exit 94 }
1889$sha = [Security.Cryptography.SHA256]::Create()
1890try { $skillHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($skillPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1891if ($skillHash -cne 'd78fe03be106b673fcf5415c8359e99f0298b5071cd11822c58ebcb27e52a68d') { exit 95 }
1892if ([string]::IsNullOrEmpty($contextRoot) -or -not [IO.Path]::IsPathRooted($contextRoot) -or -not (Test-Path -LiteralPath $contextRoot -PathType Container)) { exit 96 }
1893$contextPath = Join-Path $contextRoot 'context-pack.json'
1894if (-not (Test-Path -LiteralPath $contextPath -PathType Leaf)) { exit 97 }
1895$contextFiles = @(Get-ChildItem -LiteralPath $contextRoot -Recurse -Force -File)
1896if ($contextFiles.Count -ne 1 -or [IO.Path]::GetFullPath($contextFiles[0].FullName) -ine [IO.Path]::GetFullPath($contextPath)) { exit 98 }
1897$contextFull = [IO.Path]::GetFullPath($contextRoot).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1898$codexFull = [IO.Path]::GetFullPath($codexHome).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1899$cwdFull = [IO.Path]::GetFullPath($cwd).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1900$separator = [IO.Path]::DirectorySeparatorChar
1901if ($contextFull.Equals($codexFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($codexFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 99 }
1902if ($contextFull.Equals($cwdFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($cwdFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 100 }
1903$rawContext = [IO.File]::ReadAllText($contextPath)
1904if ($rawContext.Contains('g4a-private-provider-session-canary') -or $rawContext.Contains('private-provider-login-identity')) { exit 101 }
1905try { $document = $rawContext | ConvertFrom-Json -ErrorAction Stop } catch { exit 102 }
1906$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')
1907$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')
1908foreach ($property in @($document.PSObject.Properties.Name)) {
1909    if ($allowedTopLevel -cnotcontains [string]$property) { exit 103 }
1910}
1911foreach ($property in $requiredTopLevel) {
1912    if ($null -eq $document.PSObject.Properties[$property]) { exit 104 }
1913}
1914if ($document.schema -cne 'g4a-context-pack-v1' -or $null -ne $document.PSObject.Properties['cwd']) { exit 105 }
1915if (@('claude', 'codex', 'grok', 'kimi') -cnotcontains [string]$document.source_provider) { exit 106 }
1916$messages = @($document.retained_messages)
1917if ($messages.Count -eq 0) { exit 107 }
1918foreach ($message in $messages) {
1919    $messageProperties = @($message.PSObject.Properties.Name)
1920    foreach ($property in $messageProperties) {
1921        if (@('role', 'text') -cnotcontains [string]$property) { exit 108 }
1922    }
1923    if ($messageProperties.Count -ne 2 -or @('user', 'assistant') -cnotcontains [string]$message.role -or [string]::IsNullOrEmpty([string]$message.text)) { exit 109 }
1924}
1925$sha = [Security.Cryptography.SHA256]::Create()
1926try { $contextHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($contextPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1927[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))
1928$rawProof = [IO.File]::ReadAllText($proofPath)
1929if ($rawProof.Contains('g4a-private-provider-session-canary') -or $rawProof.Contains('private-provider-login-identity')) { exit 110 }
1930[Console]::Out.WriteLine('F7_CODEX_BUNDLE_CONTEXT_VALIDATED')
1931"#;
1932                spec.launch.fixed_args.push(format!(
1933                    "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {validation}; {script} }}",
1934                ));
1935                spec.launch.fixed_args.push(proof_path.clone());
1936            } else if context_only_target.map(AgentId::as_str) == Some(provider_id) {
1937                let script = spec.launch.fixed_args.pop().ok_or_else(|| {
1938                    NodeServerError::Registry("PTY fixture script is unavailable".to_owned())
1939                })?;
1940                let validation = r#"
1941$bundleArgs = @($bundleArgs)
1942if ($bundleArgs.Count -ne 0) { exit 91 }
1943$contextRoot = [Environment]::GetEnvironmentVariable('GATE4AGENT_CONTEXT_ROOT', 'Process')
1944$cwd = (Get-Location).ProviderPath
1945if ([string]::IsNullOrEmpty($contextRoot) -or -not [IO.Path]::IsPathRooted($contextRoot) -or -not (Test-Path -LiteralPath $contextRoot -PathType Container)) { exit 92 }
1946$contextPath = Join-Path $contextRoot 'context-pack.json'
1947if (-not (Test-Path -LiteralPath $contextPath -PathType Leaf)) { exit 93 }
1948$entries = @(Get-ChildItem -LiteralPath $contextRoot -Force)
1949if ($entries.Count -ne 1 -or $entries[0] -isnot [IO.FileInfo] -or [IO.Path]::GetFullPath($entries[0].FullName) -ine [IO.Path]::GetFullPath($contextPath)) { exit 94 }
1950$contextFull = [IO.Path]::GetFullPath($contextRoot).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1951$cwdFull = [IO.Path]::GetFullPath($cwd).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1952$separator = [IO.Path]::DirectorySeparatorChar
1953if ($contextFull.Equals($cwdFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($cwdFull + $separator, [StringComparison]::OrdinalIgnoreCase) -or $cwdFull.StartsWith($contextFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 95 }
1954try { $document = [IO.File]::ReadAllText($contextPath) | ConvertFrom-Json -ErrorAction Stop } catch { exit 96 }
1955if ($document.schema -cne 'g4a-context-pack-v1' -or $null -ne $document.PSObject.Properties['cwd']) { exit 97 }
1956if (@('claude', 'codex', 'grok', 'kimi') -cnotcontains [string]$document.source_provider) { exit 98 }
1957$messages = @($document.retained_messages)
1958if ($messages.Count -eq 0) { exit 99 }
1959$hasUser = $false
1960$hasAssistant = $false
1961foreach ($message in $messages) {
1962    if ([string]::IsNullOrWhiteSpace([string]$message.text)) { exit 100 }
1963    if ([string]$message.role -ceq 'user') { $hasUser = $true }
1964    elseif ([string]$message.role -ceq 'assistant') { $hasAssistant = $true }
1965    else { exit 101 }
1966}
1967if (-not $hasUser -or -not $hasAssistant) { exit 102 }
1968$sha = [Security.Cryptography.SHA256]::Create()
1969try { $contextHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($contextPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1970[IO.File]::WriteAllLines($proofPath, [string[]]@('context-only', $contextRoot, $cwd, $contextHash, [string]$document.schema, [string]$document.source_provider, [string]$messages.Count))
1971[Console]::Out.WriteLine('F7_KIMI_CONTEXT_ONLY_VALIDATED')
1972"#;
1973                spec.launch.fixed_args.push(format!(
1974                    "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {validation}; {script} }}",
1975                ));
1976                spec.launch.fixed_args.push(proof_path.clone());
1977            }
1978            specs.push(spec);
1979        }
1980        let catalog = AgentRegistry::new(specs)
1981            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1982        Ok(catalog)
1983    }
1984
1985    #[cfg(feature = "fixture")]
1986    pub fn new_exact_launcher_fixture(
1987        config: NodeServerConfig,
1988        agent_id: AgentId,
1989        launcher: String,
1990    ) -> Result<Self, NodeServerError> {
1991        if !matches!(agent_id.as_str(), "claude" | "codex" | "grok" | "kimi") {
1992            return Err(NodeServerError::Registry(
1993                "exact launcher fixture requires a supported provider".to_owned(),
1994            ));
1995        }
1996        if launcher.contains('\0') {
1997            return Err(NodeServerError::Registry(
1998                "exact launcher fixture path contains NUL".to_owned(),
1999            ));
2000        }
2001        let launcher_path = Path::new(&launcher);
2002        if !launcher_path.is_absolute() {
2003            return Err(NodeServerError::Registry(
2004                "exact launcher fixture path must be absolute".to_owned(),
2005            ));
2006        }
2007        let metadata = std::fs::metadata(launcher_path).map_err(|_| {
2008            NodeServerError::Registry("exact launcher fixture path is unavailable".to_owned())
2009        })?;
2010        if !metadata.is_file() {
2011            return Err(NodeServerError::Registry(
2012                "exact launcher fixture path must be a regular file".to_owned(),
2013            ));
2014        }
2015        let mut spec = builtin_registry()
2016            .get(&agent_id)
2017            .ok_or_else(|| {
2018                NodeServerError::Registry(
2019                    "exact launcher fixture provider is unavailable".to_owned(),
2020                )
2021            })?
2022            .clone();
2023        spec.launch.program = launcher;
2024        Self::new_fixture_with_spec(config, spec)
2025    }
2026
2027    /// Shared H3B validation and spec construction for
2028    /// `new_harness_mcp_proxy_fixture` and
2029    /// `new_harness_mcp_acp_launcher_fixture`: an absolute, exact-regular-
2030    /// file provider launcher plus at most 32 Unicode, NUL-free fixed
2031    /// arguments (65,536 bytes total), replacing `spec.launch` on a clone of
2032    /// the named builtin catalog entry -- detection command and expected
2033    /// process matcher rebased on the launcher's own basename, exactly the
2034    /// same shape `new_harness_mcp_proxy_fixture` always built.
2035    #[cfg(feature = "fixture")]
2036    fn h3b_provider_spec_with_program_and_args(
2037        agent_id: AgentId,
2038        provider_program: PathBuf,
2039        fixed_args: Vec<OsString>,
2040    ) -> Result<AgentSpec, NodeServerError> {
2041        if !provider_program.is_absolute() || fixed_args.len() > 32 {
2042            return Err(NodeServerError::Registry(
2043                "H3B fixture requires an absolute provider and at most 32 arguments".to_owned(),
2044            ));
2045        }
2046        let metadata = std::fs::symlink_metadata(&provider_program).map_err(|_| {
2047            NodeServerError::Registry("H3B fixture provider is unavailable".to_owned())
2048        })?;
2049        if !metadata.file_type().is_file() || metadata.file_type().is_symlink() {
2050            return Err(NodeServerError::Registry(
2051                "H3B fixture provider must be an exact regular file".to_owned(),
2052            ));
2053        }
2054        let program = provider_program.into_os_string().into_string().map_err(|_| {
2055            NodeServerError::Registry("H3B fixture provider path is not Unicode".to_owned())
2056        })?;
2057        let fixed_args = fixed_args.into_iter().map(|argument| {
2058            argument.into_string().map_err(|_| NodeServerError::Registry(
2059                "H3B fixture argument is not Unicode".to_owned(),
2060            ))
2061        }).collect::<Result<Vec<_>, _>>()?;
2062        let argument_bytes = fixed_args.iter()
2063            .try_fold(0usize, |total, argument| total.checked_add(argument.len()));
2064        if program.contains('\0')
2065            || fixed_args.iter().any(|argument| argument.contains('\0'))
2066            || !matches!(argument_bytes, Some(total) if total <= 65_536)
2067        {
2068            return Err(NodeServerError::Registry(
2069                "H3B fixture provider or arguments are invalid".to_owned(),
2070            ));
2071        }
2072        let mut spec = builtin_registry().get(&agent_id).ok_or_else(|| {
2073            NodeServerError::Registry("H3B fixture provider is unavailable".to_owned())
2074        })?.clone();
2075        let process_name = Path::new(&program).file_name().and_then(|name| name.to_str())
2076            .ok_or_else(|| NodeServerError::Registry(
2077                "H3B fixture provider basename is unavailable".to_owned(),
2078            ))?.to_owned();
2079        spec.detection.command = process_name.clone();
2080        spec.expected_processes = vec![gate4agent_types::ProcessMatcher::Exact {
2081            name: process_name,
2082        }];
2083        spec.launch.program = program;
2084        spec.launch.fixed_args = fixed_args;
2085        Ok(spec)
2086    }
2087
2088    #[cfg(feature = "fixture")]
2089    pub fn new_harness_mcp_proxy_fixture(
2090        config: NodeServerConfig,
2091        provider_program: PathBuf,
2092        fixed_args: Vec<OsString>,
2093        harness_mcp_program: PathBuf,
2094    ) -> Result<Self, NodeServerError> {
2095        let mut config = config.with_harness_mcp_helper(harness_mcp_program)?;
2096        let agent_id = AgentId::new("codex")
2097            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2098        let mut spec =
2099            Self::h3b_provider_spec_with_program_and_args(agent_id, provider_program, fixed_args)?;
2100        spec.display_name = "Controlled H3B MCP proxy fixture".to_owned();
2101        config.fixture_raw_pty_runtime = true;
2102        Self::new_fixture_with_spec(config, spec)
2103    }
2104
2105    /// The ACP-mode sibling of `new_harness_mcp_proxy_fixture`: the same
2106    /// `--harness-mcp-helper` wiring and the same absolute-program-plus-
2107    /// fixed-args launcher replacement as `new_exact_launcher_fixture`, but
2108    /// admitting `ProviderRuntimeRequirement::Acp` the honest way instead of
2109    /// hardcoding it. `new_harness_mcp_proxy_fixture` forces `config.
2110    /// fixture_raw_pty_runtime = true`, which tears out the live
2111    /// `ProviderRuntimeMonitor` entirely (`NodeServer::new_with_registry`)
2112    /// and makes `NodeShared::admit_provider_runtime`'s no-monitor branch
2113    /// hardcode `acp_transport = false` for every provider, refusing
2114    /// `ProviderRuntimeRequirement::Acp` unconditionally regardless of what
2115    /// the catalog declares. This constructor leaves `fixture_raw_pty_
2116    /// runtime` at its default `false`, so a real `ProviderRuntimeMonitor`
2117    /// is built from the (cloned, launcher-replaced) catalog spec, and
2118    /// `ProviderRuntimeMonitor::supports_acp_transport` reads the honest,
2119    /// unmodified catalog fact: every builtin provider this accepts
2120    /// (`claude`, `codex`, `grok`, `kimi`) already declares `capabilities.
2121    /// transports.acp` in `gate4agent-catalog::builtin`, so no capability is
2122    /// invented here -- this constructor only removes the fixture-only
2123    /// bypass standing in its way. `provider_runtime::policy_for_transport`'s
2124    /// `TransportKind::Acp` arm (applied unconditionally at spawn-prep time
2125    /// for every ACP session, fixture or not) is what actually grants the
2126    /// ACP-shaped `ProviderRuntimePolicy` downstream of that admission --
2127    /// this constructor never calls it directly, and does not need to.
2128    #[cfg(feature = "fixture")]
2129    pub fn new_harness_mcp_acp_launcher_fixture(
2130        config: NodeServerConfig,
2131        helper_program: PathBuf,
2132        provider: AgentId,
2133        program: PathBuf,
2134        args: Vec<OsString>,
2135    ) -> Result<Self, NodeServerError> {
2136        if !matches!(provider.as_str(), "claude" | "codex" | "grok" | "kimi") {
2137            return Err(NodeServerError::Registry(
2138                "ACP harness-MCP launcher fixture requires a supported provider".to_owned(),
2139            ));
2140        }
2141        let config = config.with_harness_mcp_helper(helper_program)?;
2142        let mut spec = Self::h3b_provider_spec_with_program_and_args(provider, program, args)?;
2143        let launch = spec.launch.clone();
2144        let acp = spec.capabilities.transports.acp.as_mut().ok_or_else(|| {
2145            NodeServerError::Registry(
2146                "ACP harness-MCP launcher fixture provider does not declare an ACP transport"
2147                    .to_owned(),
2148            )
2149        })?;
2150        // Routes the ACP spawn itself through the exact replaced launcher
2151        // (`AcpSession::spawn_with_launch` -> a bare `Command::new(&launch.
2152        // program)`, `gate4agent`'s `src/acp/spawn.rs`) instead of the
2153        // catalog's own vendor-npm resolution (`acp_command`, same file) --
2154        // without this the ACP transport ignores `spec.launch` entirely and
2155        // tries to spawn the real vendor CLI (e.g. `npx -y @agentclientprotocol/
2156        // codex-acp@1.10.0`), which this fixture must never reach.
2157        acp.launch_override = Some(launch);
2158        spec.display_name = "Controlled ACP harness-MCP launcher fixture".to_owned();
2159        Self::new_fixture_with_spec(config, spec)
2160    }
2161
2162    #[cfg(feature = "fixture")]
2163    pub fn new_resume_fixture(config: NodeServerConfig) -> Result<Self, NodeServerError> {
2164        let mut spec = gate4agent_testkit::interactive_agent_spec();
2165        let claude_id = AgentId::new("claude")
2166            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2167        let claude = builtin_registry()
2168            .get(&claude_id)
2169            .ok_or_else(|| NodeServerError::Registry("Claude fixture adapter is unavailable".to_owned()))?;
2170        spec.id = claude_id;
2171        spec.display_name = "Resumable Claude PTY fixture".to_owned();
2172        spec.expected_processes = vec![gate4agent_types::ProcessMatcher::Exact {
2173            name: "claude".to_owned(),
2174        }];
2175        spec.capabilities.transports.pty_adapter = claude
2176            .capabilities
2177            .transports
2178            .pty_adapter
2179            .clone();
2180        spec.capabilities.adapters.resume = claude
2181            .capabilities
2182            .adapters
2183            .resume
2184            .clone();
2185        let script = spec
2186            .launch
2187            .fixed_args
2188            .pop()
2189            .ok_or_else(|| NodeServerError::Registry("PTY fixture script is unavailable".to_owned()))?;
2190        spec.launch.fixed_args.push(format!(
2191            "& {{ param([Parameter(ValueFromRemainingArguments=$true)][string[]]$ignored) {script} }}",
2192        ));
2193        Self::new_fixture_with_spec(config, spec)
2194    }
2195
2196    #[cfg(feature = "fixture")]
2197    pub fn new_cmd_cwd_fixture(
2198        config: NodeServerConfig,
2199        command: String,
2200    ) -> Result<Self, NodeServerError> {
2201        let mut spec = gate4agent_testkit::interactive_agent_spec();
2202        spec.id = AgentId::new("claude")
2203            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2204        spec.display_name = "Controlled cmd cwd fixture".to_owned();
2205        spec.detection.command = "gate4agent-cwd-fixture.cmd".to_owned();
2206        spec.launch.program = command;
2207        spec.launch.fixed_args.clear();
2208        Self::new_fixture_with_spec(config, spec)
2209    }
2210
2211    #[cfg(feature = "fixture")]
2212    fn new_fixture_with_spec(
2213        config: NodeServerConfig,
2214        spec: gate4agent_types::AgentSpec,
2215    ) -> Result<Self, NodeServerError> {
2216        let catalog = AgentRegistry::new([spec])
2217            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2218        Self::new_with_registry(config, catalog)
2219    }
2220
2221    fn new_with_registry(
2222        config: NodeServerConfig,
2223        catalog: AgentRegistry,
2224    ) -> Result<Self, NodeServerError> {
2225        for profile in config.spawn_profiles.iter() {
2226            if catalog.get(&profile.provider).is_none() {
2227                return Err(NodeServerError::Registry(
2228                    "spawn profile references an unavailable provider".to_owned(),
2229                ));
2230            }
2231        }
2232        let input_settle_timeout_ms = catalog
2233            .iter()
2234            .map(|spec| spec.readiness.timeout_ms)
2235            .max()
2236            .unwrap_or(MUTATION_SETTLE_TIMEOUT_MS)
2237            .saturating_add(READINESS_SETTLE_HEADROOM_MS)
2238            .max(MUTATION_SETTLE_TIMEOUT_MS);
2239        let (provider_contracts, provider_adapter_contracts) =
2240            provider_contract_manifest(&catalog)?;
2241        let compatibility_support = node_compatibility_support_for_manifest(
2242            &provider_contracts,
2243            &provider_adapter_contracts,
2244        )?;
2245        validate_node_negotiated_handshake_capacity(&compatibility_support)
2246            .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2247        let enabled_providers = provider_contracts
2248            .iter()
2249            .map(|contract| contract.provider.clone())
2250            .collect();
2251        #[cfg(feature = "fixture")]
2252        let fixture_raw_pty_runtime = config.fixture_raw_pty_runtime;
2253        #[cfg(not(feature = "fixture"))]
2254        let fixture_raw_pty_runtime = false;
2255        let (provider_runtime_monitor, provider_runtime_statuses) = if fixture_raw_pty_runtime {
2256            let statuses = ProviderRuntimeStatuses::new(catalog.iter().map(|spec| {
2257                crate::protocol::ProviderRuntimeStatus::raw_passthrough(spec.id.clone(), None)
2258            })).expect("fixture provider catalog remains bounded");
2259            (None, statuses)
2260        } else {
2261            let monitor = Arc::new(ProviderRuntimeMonitor::new(&catalog));
2262            let statuses = monitor.collect();
2263            (Some(monitor), statuses)
2264        };
2265        let state_path_lock =
2266            session_registry::StatePathLock::acquire(config.state_path.as_deref())
2267                .map_err(|error| {
2268                    durable_state_server_error(error, DURABLE_STATE_LOCK_FAILED_ERROR)
2269                })?;
2270        let (delivery_store, delivered_bundles) = match config.state_path.as_ref() {
2271            Some(state_path) => {
2272                let delivery_root = delivery_store_root_for_state_path(state_path)
2273                    .ok_or_else(|| {
2274                        NodeServerError::DeliveryStore(
2275                            "delivery store root could not be derived from state path".to_owned(),
2276                        )
2277                    })?;
2278                let (store, bundles) = DeliveryStore::open(delivery_root).map_err(|error| {
2279                    let detail = match &error {
2280                        DeliveryStoreError::Storage(io_error) => {
2281                            format!("{error}: {io_error}")
2282                        }
2283                        _ => error.to_string(),
2284                    };
2285                    NodeServerError::DeliveryStore(detail)
2286                })?;
2287                (Some(store), bundles)
2288            }
2289            None => (None, Vec::new()),
2290        };
2291        let delivered_catalog = BundleCatalog::new(delivered_bundles).map_err(|error| {
2292            NodeServerError::DeliveryStore(format!("bundle catalog: {error}"))
2293        })?;
2294        let (context_pack_store, durable_context_packs) = match config.state_path.as_ref() {
2295            Some(state_path) => {
2296                let context_pack_root = context_pack_store_root_for_state_path(state_path)
2297                    .ok_or(NodeServerError::ContextPackStore)?;
2298                let (store, packs) = ContextPackStore::open(context_pack_root)
2299                    .map_err(|_| NodeServerError::ContextPackStore)?;
2300                (Some(store), packs)
2301            }
2302            None => (None, Vec::new()),
2303        };
2304        let mut durable_context_catalog = ContextPackCatalog::default();
2305        for pack in durable_context_packs {
2306            durable_context_catalog
2307                .insert(pack)
2308                .map_err(|_| NodeServerError::ContextPackStore)?;
2309        }
2310        let session_environment_materializer = config
2311            .session_environment
2312            .as_ref()
2313            .map(|environment| {
2314                SessionEnvironmentMaterializer::new(
2315                    environment.root.clone(),
2316                    Arc::clone(&environment.resolver),
2317                )
2318                .map_err(|_| NodeServerError::SessionEnvironmentMaterializer)
2319            })
2320            .transpose()?;
2321        // Session launchers go through the unprivileged `exec-as` wrapper when run-as is set
2322        // (the provider version probe above already used the original catalog).
2323        #[cfg(unix)]
2324        let catalog = crate::isolation::wrap_registry(catalog).map_err(NodeServerError::Registry)?;
2325        let native_session_catalog = config.history.clone().map(|history| {
2326            Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)))
2327        });
2328        let (handle, runtime) = match config.history.clone() {
2329            Some(history) => NativeRuntime::new_with_history(catalog, config.runtime, history),
2330            None => NativeRuntime::new(catalog, config.runtime),
2331        };
2332        let native_launch_profile_control = runtime.native_launch_profile_control();
2333        let harness_mcp_registry = config
2334            .harness_mcp_helper
2335            .clone()
2336            .map(HarnessMcpProxyRegistry::new);
2337        let events = handle.subscribe(CONTROL_EVENT_SUBSCRIPTION_CAPACITY);
2338        let incarnation_id = random_incarnation_id()
2339            .map_err(NodeServerError::IncarnationIdentity)?;
2340        let mut loaded = session_registry::load(config.state_path.as_deref(), &config.node_id)
2341            .map_err(durable_state_load_error)?;
2342        let managed_worktrees = std::mem::take(&mut loaded.managed_worktrees);
2343        let managed_worktree_tombstones =
2344            std::mem::take(&mut loaded.managed_worktree_tombstones);
2345        let materializations = std::mem::take(&mut loaded.materializations);
2346        let managed_spawn_replays = std::mem::take(&mut loaded.managed_spawn_replays);
2347        let (workspaces, records, persistence_warning) =
2348            merge_durable_state(&config.workspaces, loaded)?;
2349        let mut shared = NodeShared::new_with_incarnation(
2350            handle,
2351            config.access_token.clone(),
2352            config.node_id.clone(),
2353            incarnation_id,
2354            workspaces,
2355            enabled_providers,
2356            provider_runtime_statuses,
2357            provider_runtime_monitor,
2358            provider_contracts,
2359            provider_adapter_contracts,
2360            config.spawn_profiles.clone(),
2361            Some(native_launch_profile_control),
2362            native_session_catalog,
2363            session_environment_materializer,
2364            config.state_path.clone(),
2365            records,
2366            managed_worktrees,
2367            managed_worktree_tombstones,
2368            materializations,
2369            persistence_warning,
2370            input_settle_timeout_ms,
2371        );
2372        shared.harness_mcp_registry = harness_mcp_registry.clone();
2373        shared.session_record_retention = config.session_record_retention;
2374        shared.network_allowlist_catalog =
2375            RwLock::new(config.network_allowlist_catalog.clone());
2376        shared.bundle_catalog = RwLock::new(delivered_catalog);
2377        shared.delivery_store = Mutex::new(delivery_store);
2378        shared.context_catalog = RwLock::new(durable_context_catalog);
2379        shared.context_pack_store = Mutex::new(context_pack_store);
2380        shared.managed_spawn_replays = Mutex::new(
2381            managed_spawn_replays
2382                .into_iter()
2383                .map(|record| (record.idempotency_key.clone(), record))
2384                .collect(),
2385        );
2386        let shared = Arc::new(shared);
2387        if let Some(registry) = harness_mcp_registry {
2388            let weak = Arc::downgrade(&shared);
2389            registry.set_event_sink(Arc::new(move |event| match weak.upgrade() {
2390                Some(shared) => shared.publish_transient(event),
2391                None => false,
2392            }));
2393        }
2394        if shared.persistence_error().is_none() {
2395            shared.persist_state().map_err(|error| {
2396                durable_state_server_error(error, DURABLE_STATE_COMMIT_FAILED_ERROR)
2397            })?;
2398        }
2399        Ok(Self {
2400            config,
2401            runtime,
2402            shared,
2403            events,
2404            state_path_lock,
2405        })
2406    }
2407
2408    pub fn shutdown_handle(&self) -> NodeShutdownHandle {
2409        NodeShutdownHandle {
2410            shared: Arc::clone(&self.shared),
2411        }
2412    }
2413
2414    #[cfg(feature = "fixture")]
2415    pub fn spawn_managed_worktree_v2_failure_probe_fixture(
2416        &self,
2417    ) -> SpawnManagedWorktreeV2FailureProbe {
2418        self.shared
2419            .fixture_spawn_managed_worktree_v2_failure_probe
2420            .clone()
2421    }
2422
2423    /// Installs one immutable host-local environment profile before the server runs.
2424    ///
2425    /// Native resolvers remain inside the runtime; the shared node registry keeps
2426    /// only public IDs, revisions, provider bindings, and opaque native profile IDs.
2427    pub fn install_environment_profile(
2428        &mut self,
2429        profile: NodeEnvironmentProfile,
2430    ) -> Result<(), NodeServerError> {
2431        if !self.shared.enabled_providers.contains(profile.provider()) {
2432            return Err(NodeServerError::EnvironmentProfileProviderUnavailable(
2433                profile.provider().clone(),
2434            ));
2435        }
2436        let (binding, native_profiles, materialization) = profile.into_parts();
2437        if materialization.is_some() && self.shared.session_environment_materializer.is_none() {
2438            return Err(NodeServerError::SessionEnvironmentMaterializerRequired);
2439        }
2440        let mut profiles = self
2441            .shared
2442            .environment_profiles
2443            .write()
2444            .unwrap_or_else(|poisoned| poisoned.into_inner());
2445        if profiles.contains_key(&binding.id) {
2446            return Err(NodeServerError::DuplicateEnvironmentProfile(
2447                binding.id.clone(),
2448            ));
2449        }
2450        if profiles.len() >= MAX_NODE_ENVIRONMENT_PROFILES {
2451            return Err(NodeServerError::EnvironmentProfileCapacity {
2452                max: MAX_NODE_ENVIRONMENT_PROFILES,
2453            });
2454        }
2455        for native_id in binding.native_profile_ids() {
2456            if profiles.values().any(|current| {
2457                current.native_profile_ids().any(|current_id| current_id == native_id)
2458            }) {
2459                return Err(NodeServerError::DuplicateNativeEnvironmentProfile(
2460                    native_id.to_string(),
2461                ));
2462            }
2463        }
2464
2465        let mut installed = Vec::with_capacity(native_profiles.len());
2466        for native_profile in native_profiles {
2467            let native_id = native_profile.id().clone();
2468            if let Err(error) = self.runtime.upsert_native_launch_profile(native_profile) {
2469                for installed_id in installed.into_iter().rev() {
2470                    let _ = self.runtime.remove_native_launch_profile(&installed_id);
2471                }
2472                return Err(NodeServerError::NativeEnvironmentProfile(error.to_string()));
2473            }
2474            installed.push(native_id);
2475        }
2476        if let Some(materialization) = materialization {
2477            self.shared
2478                .environment_materialization_profiles
2479                .write()
2480                .unwrap_or_else(|poisoned| poisoned.into_inner())
2481                .insert(binding.id.clone(), materialization);
2482        }
2483        profiles.insert(binding.id.clone(), binding);
2484        Ok(())
2485    }
2486
2487    /// Installs one immutable host-local bundle before the server runs.
2488    pub fn install_bundle(&mut self, bundle: NodeBundle) -> Result<(), NodeServerError> {
2489        if self.shared.session_environment_materializer.is_none() {
2490            return Err(NodeServerError::SessionEnvironmentMaterializerRequired);
2491        }
2492        let mut catalog = self
2493            .shared
2494            .bundle_catalog
2495            .write()
2496            .unwrap_or_else(|poisoned| poisoned.into_inner());
2497        catalog
2498            .insert_idempotent(bundle)
2499            .map_err(|error| NodeServerError::BundleCatalog(error.to_string()))?;
2500        Ok(())
2501    }
2502
2503    pub async fn run_until_ctrl_signal(self) -> Result<(), NodeServerError> {
2504        let shutdown = self.shutdown_handle();
2505        let run = self.run();
2506        tokio::pin!(run);
2507        tokio::select! {
2508            result = &mut run => result,
2509            signal = wait_for_ctrl_signal() => {
2510                signal?;
2511                let shutdown_result = shutdown.request_shutdown().await;
2512                let run_result = run.await;
2513                shutdown_result.and(run_result)
2514            }
2515        }
2516    }
2517
2518    pub async fn run(self) -> Result<(), NodeServerError> {
2519        let NodeServer {
2520            config,
2521            mut runtime,
2522            shared,
2523            events,
2524            state_path_lock: _state_path_lock,
2525        } = self;
2526        shared.reconcile_materializations();
2527        shared.reconcile_managed_worktrees().await;
2528        shared.retire_unavailable_session_records().await;
2529        shared.reconcile_context_pack_exports().await;
2530        {
2531            let restore = Arc::clone(&shared);
2532            tokio::spawn(async move {
2533                sleep(Duration::from_secs(3)).await;
2534                restore.restore_journalled_sessions().await;
2535                loop {
2536                    restore.refresh_session_journal();
2537                    if restore.shutdown.load(Ordering::Acquire) { break; }
2538                    sleep(Duration::from_secs(10)).await;
2539                }
2540            });
2541        }
2542        // Lifecycle hooks are retired (owner ruling 2026-09-25): the node
2543        // observes sessions through ACP where a provider has it; there is no
2544        // hook ingress.
2545        let endpoint = config.endpoint.clone();
2546        let call_home = config.call_home;
2547        let call_home_identity = config.call_home_identity.clone();
2548        let api_listen = config.api_listen;
2549        let bridge_listen = config.bridge_listen;
2550        let bridge_underlay = config.bridge_underlay;
2551        let bridge_auth = browser_bridge::BridgeAuth::from_optional(config.bridge_token)
2552            .map_err(|_| NodeServerError::InvalidBridgeToken)?;
2553        let accept_shared = Arc::clone(&shared);
2554        let api_shared = Arc::clone(&shared);
2555        let bridge_shared = Arc::clone(&shared);
2556        let shutdown_shared = Arc::clone(&shared);
2557        let shutdown_timeout = Duration::from_millis(
2558            config.runtime.provider_shutdown_timeout_ms.max(1),
2559        );
2560        let result = {
2561            let runtime_loop = drive_runtime_until_shutdown(
2562                &mut runtime,
2563                events,
2564                Arc::clone(&shared),
2565                shutdown_timeout,
2566            );
2567            let accept_loop = accept_connections(&endpoint, call_home, call_home_identity, accept_shared);
2568            let api_loop = http_api::run(api_listen, api_shared);
2569            let bridge_loop = browser_bridge::run(bridge_listen, bridge_auth, bridge_shared, bridge_underlay);
2570            tokio::pin!(runtime_loop);
2571            tokio::pin!(accept_loop);
2572            tokio::pin!(api_loop);
2573            tokio::pin!(bridge_loop);
2574            tokio::select! {
2575                runtime_result = &mut runtime_loop => {
2576                    let shutdown_result = shutdown_shared
2577                        .begin_shutdown()
2578                        .await
2579                        .map_err(NodeServerError::ShutdownDispatch);
2580                    let (accept_result, api_result, bridge_result) =
2581                        tokio::join!(&mut accept_loop, &mut api_loop, &mut bridge_loop);
2582                    match runtime_result {
2583                        Err(error) => Err(error),
2584                        Ok(()) => shutdown_result
2585                            .and(accept_result)
2586                            .and(api_result.map_err(NodeServerError::HttpApi))
2587                            .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2588                    }
2589                }
2590                accept_result = &mut accept_loop => {
2591                    let shutdown_result = shutdown_shared
2592                        .begin_shutdown()
2593                        .await
2594                        .map_err(NodeServerError::ShutdownDispatch);
2595                    let (runtime_result, api_result, bridge_result) =
2596                        tokio::join!(&mut runtime_loop, &mut api_loop, &mut bridge_loop);
2597                    match accept_result {
2598                        Err(error) => Err(error),
2599                        Ok(()) => shutdown_result
2600                            .and(runtime_result)
2601                            .and(api_result.map_err(NodeServerError::HttpApi))
2602                            .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2603                    }
2604                }
2605                api_result = &mut api_loop => {
2606                    let shutdown_result = shutdown_shared
2607                        .begin_shutdown()
2608                        .await
2609                        .map_err(NodeServerError::ShutdownDispatch);
2610                    let (runtime_result, accept_result, bridge_result) =
2611                        tokio::join!(&mut runtime_loop, &mut accept_loop, &mut bridge_loop);
2612                    match api_result {
2613                        Err(error) => Err(NodeServerError::HttpApi(error)),
2614                        Ok(()) => shutdown_result
2615                            .and(runtime_result)
2616                            .and(accept_result)
2617                            .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2618                    }
2619                }
2620                bridge_result = &mut bridge_loop => {
2621                    let shutdown_result = shutdown_shared
2622                        .begin_shutdown()
2623                        .await
2624                        .map_err(NodeServerError::ShutdownDispatch);
2625                    let (runtime_result, accept_result, api_result) =
2626                        tokio::join!(&mut runtime_loop, &mut accept_loop, &mut api_loop);
2627                    match bridge_result {
2628                        Err(error) => Err(NodeServerError::BridgeApi(error)),
2629                        Ok(()) => shutdown_result
2630                            .and(runtime_result)
2631                            .and(accept_result)
2632                            .and(api_result.map_err(NodeServerError::HttpApi)),
2633                    }
2634                }
2635            }
2636        };
2637        // Persistence gap fix: durable DeliveryStore / ContextPackStore hold
2638        // exclusive flock (Unix) / share-mode locks (Windows) inside NodeShared.
2639        // NodeShutdownHandle also clones that Arc, so without an explicit release
2640        // here a supervisor that keeps the shutdown handle cannot reopen the same
2641        // state path after run() returns (EAGAIN / Resource temporarily unavailable).
2642        // StatePathLock already drops with this stack frame (`_state_path_lock`).
2643        if let Ok(mut guard) = shared.delivery_store.lock() {
2644            *guard = None;
2645        }
2646        if let Ok(mut guard) = shared.context_pack_store.lock() {
2647            *guard = None;
2648        }
2649        result
2650    }
2651}
2652
2653async fn drive_runtime_until_shutdown(
2654    runtime: &mut NativeRuntime,
2655    events: EventSubscription,
2656    shared: Arc<NodeShared>,
2657    shutdown_timeout: Duration,
2658) -> Result<(), NodeServerError> {
2659    // Published once, never per iteration. The worker loops inside
2660    // `NativeRuntime` fold into this profile continuously; turning it into
2661    // percentiles is what costs, and that is `GET /metrics`'s bill to pay.
2662    let _ = shared
2663        .shell_efficiency_profile
2664        .set(runtime.shell_efficiency_profile());
2665    let mut shutdown_started = None;
2666    let mut last_retention_sweep = Instant::now();
2667    let mut last_provisional_reap = Instant::now();
2668    loop {
2669        // Every phase below is timed unconditionally -- see
2670        // `drive_loop_profile`'s own doc comment for why an always-on,
2671        // allocation-free ring is cheap enough to never need a feature
2672        // flag. This is the only place these numbers can be taken: the
2673        // phases are calls made directly from this loop, not something a
2674        // callee could time on our behalf.
2675        let runtime_tick_start = Instant::now();
2676        let tick_result = runtime.tick().await;
2677        let runtime_tick_elapsed = runtime_tick_start.elapsed();
2678
2679        let event_drain_start = Instant::now();
2680        // Every command the kernel refused, named. `NativeRuntimeTick`
2681        // has carried these outcomes all along and nothing read them, so a
2682        // rejected command -- a hook event denied by runtime policy, a
2683        // stale generation, an unsupported capability -- vanished at the
2684        // last step of the loop that produced it. A refusal the operator
2685        // cannot see is indistinguishable from an event that never arrived,
2686        // and telling those two apart is most of debugging this stack.
2687        for outcome in &tick_result.command_outcomes {
2688            if let Err(error) = &outcome.result {
2689                shared.rejected_commands_total.fetch_add(1, Ordering::Relaxed);
2690                tracing::warn!(
2691                    command_id = outcome.command_id.0,
2692                    cause = %error,
2693                    "control command rejected"
2694                );
2695                shared.record_rejected_command(
2696                    outcome.command_id,
2697                    error.to_string(),
2698                    error.is_unsupported_transport(),
2699                );
2700            }
2701        }
2702        let mut events_drained: u64 = 0;
2703        while let Ok(event) = events.try_recv() {
2704            events_drained += 1;
2705            let clean_exit = matches!(
2706                event.event,
2707                ControlEventKind::Exited { exit_code: Some(0), forced: false },
2708            );
2709            let instance_id = event.instance_id;
2710            let generation = event.generation;
2711            // Resolve and spawn before publish_control: publish_control's own
2712            // managed-record bookkeeping (reconcile_managed_record) downgrades
2713            // this exact record from Live to Dormant and clears
2714            // active_session for every Exited event, inside the same
2715            // synchronous call. Capturing (record_id, session) here — before
2716            // that runs — is the only way to observe them still Live and
2717            // still populated; the export itself must still run on its own
2718            // detached task (never awaited inline on this loop) because it
2719            // needs NativeRuntime::tick() to keep running to ever settle, and
2720            // tick() is only ever driven by this same loop. See
2721            // reconcile_context_pack_export_for_record's doc comment.
2722            if clean_exit {
2723                if let Some((record_id, session)) =
2724                    shared.managed_record_export_target_for_instance(instance_id, generation)
2725                {
2726                    let export_shared = Arc::clone(&shared);
2727                    tokio::spawn(async move {
2728                        export_shared
2729                            .reconcile_context_pack_export_for_record(&record_id, session)
2730                            .await;
2731                    });
2732                }
2733            }
2734            shared.publish_control(event);
2735        }
2736        let event_drain_elapsed = event_drain_start.elapsed();
2737
2738        let publish_terminal_frames_start = Instant::now();
2739        shared.publish_terminal_frames();
2740        let publish_terminal_frames_elapsed = publish_terminal_frames_start.elapsed();
2741
2742        // Physical PTY count and the runtime's own per-phase tick timings
2743        // both live inside `NativeRuntime`, which `GET /metrics` (reading
2744        // only `NodeShared`) cannot reach directly -- this loop is the one
2745        // place holding `&mut NativeRuntime`, so it is the only place that
2746        // can copy either out.
2747        shared
2748            .native_session_gauge
2749            .store(runtime.active_native_sessions(), Ordering::Relaxed);
2750        *shared
2751            .runtime_tick_profile
2752            .lock()
2753            .unwrap_or_else(|poisoned| poisoned.into_inner()) = runtime.tick_profile_snapshot();
2754
2755        let remainder_start = Instant::now();
2756        if shared.shutdown.load(Ordering::Acquire) {
2757            let started = *shutdown_started.get_or_insert_with(Instant::now);
2758            let snapshot = shared.handle.snapshot();
2759            let sessions_settled = snapshot.sessions.iter().all(|session| {
2760                !matches!(
2761                    session.status,
2762                    gate4agent_types::SessionStatus::Starting
2763                        | gate4agent_types::SessionStatus::Running
2764                        | gate4agent_types::SessionStatus::Stopping
2765                )
2766            });
2767            if runtime.active_native_sessions() == 0 && sessions_settled {
2768                return Ok(());
2769            }
2770            if started.elapsed() >= shutdown_timeout {
2771                return Err(NodeServerError::ShutdownTimedOut {
2772                    active_native_sessions: runtime.active_native_sessions(),
2773                });
2774            }
2775        }
2776        let remainder_elapsed = remainder_start.elapsed();
2777
2778        {
2779            let mut profile = shared
2780                .drive_loop_profile
2781                .lock()
2782                .unwrap_or_else(|poisoned| poisoned.into_inner());
2783            profile.record_runtime_tick(runtime_tick_elapsed);
2784            profile.record_event_drain(event_drain_elapsed);
2785            profile.record_publish_terminal_frames(publish_terminal_frames_elapsed);
2786            profile.record_remainder(remainder_elapsed);
2787            profile.note_iteration(Instant::now());
2788        }
2789
2790        // "Produced nothing" means this iteration's `NativeRuntime::tick`
2791        // applied zero observations (`tick_result.observations_applied`,
2792        // which already folds in any terminal frames collected -- see
2793        // `NativeEffectDispatcher::drain_observations`) AND the
2794        // control-event subscription drained above forwarded zero events.
2795        // Command ingress and dispatched effects are deliberately NOT part
2796        // of this definition: an idle node still runs `step_control_plane`
2797        // and every provider supervisor's `tick` regardless, every 10ms --
2798        // so "nothing" here means nothing OBSERVABLE left this iteration,
2799        // matching what a reader would assume from `drive_loop_iterations`'s
2800        // own field name, not that the tick itself was free of work.
2801        shared.drive_loop_iterations_total.fetch_add(1, Ordering::Relaxed);
2802        if tick_result.observations_applied == 0 && events_drained == 0 {
2803            shared.drive_loop_iterations_idle.fetch_add(1, Ordering::Relaxed);
2804        }
2805
2806        // A sweep cadence, not a policy value -- see
2807        // `SESSION_RECORD_RETENTION_SWEEP_PERIOD`'s own doc comment. The
2808        // policy itself (whether this does anything at all) lives entirely
2809        // in `NodeShared::session_record_retention`.
2810        if last_retention_sweep.elapsed() >= SESSION_RECORD_RETENTION_SWEEP_PERIOD {
2811            shared.retire_unavailable_session_records().await;
2812            last_retention_sweep = Instant::now();
2813        }
2814        // Provisional Spawn grace: kill PTYs that never got an HQ attach.
2815        if last_provisional_reap.elapsed() >= Duration::from_secs(2) {
2816            for address in shared.take_due_provisional_spawns() {
2817                shared.reap_provisional_spawn(address).await;
2818            }
2819            last_provisional_reap = Instant::now();
2820        }
2821
2822        // Fast-poll for 300ms after PTY input so its echo is read and published
2823        // within ~1ms; otherwise nap 10ms but wake at once on new input.
2824        let hot = shared
2825            .pty_input_marks
2826            .lock()
2827            .unwrap_or_else(|poisoned| poisoned.into_inner())
2828            .values()
2829            .any(|(at, _)| at.elapsed() < Duration::from_millis(300));
2830        if hot {
2831            sleep(Duration::from_millis(1)).await;
2832        } else {
2833            tokio::select! {
2834                _ = sleep(Duration::from_millis(10)) => {}
2835                _ = shared.pty_input_wake.notified() => {}
2836            }
2837        }
2838    }
2839}
2840
2841#[derive(Clone)]
2842pub struct NodeShutdownHandle {
2843    shared: Arc<NodeShared>,
2844}
2845
2846impl NodeShutdownHandle {
2847    pub async fn request_shutdown(&self) -> Result<(), NodeServerError> {
2848        self.shared
2849            .begin_shutdown()
2850            .await
2851            .map_err(NodeServerError::ShutdownDispatch)
2852    }
2853}
2854
2855async fn wait_for_ctrl_signal() -> Result<(), NodeServerError> {
2856    #[cfg(windows)]
2857    {
2858    let mut ctrl_c = tokio::signal::windows::ctrl_c()?;
2859    let mut ctrl_break = tokio::signal::windows::ctrl_break()?;
2860    return tokio::select! {
2861        signal = ctrl_c.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2862        signal = ctrl_break.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2863    };
2864    }
2865    #[cfg(unix)]
2866    {
2867        let mut interrupt = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())?;
2868        let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
2869        tokio::select! {
2870            signal = interrupt.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2871            signal = terminate.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2872        }
2873    }
2874}
2875
2876/// The providers this node actually runs.
2877///
2878/// `builtin_registry()` is a reference catalog of everything the project has
2879/// ever described; the fleet is this list, and it is deliberately short. A
2880/// provider only belongs here if it can be exercised for real -- there is a
2881/// subscription behind it and a failure in it can be reproduced and
2882/// debugged. Carrying one that cannot is carrying code nobody can verify,
2883/// which is worse than not supporting it: it looks supported.
2884///
2885/// A provider dropped from the fleet by this rule is removed outright, not
2886/// carried dark: no catalog entry, no adapter, no dead code claiming support
2887/// for something nobody can verify runs.
2888/// Host interactive shell for HQ terminal attach (no provider CLI, no network).
2889fn shell_host_spec() -> AgentSpec {
2890    // Windows nodes (incl. MSYS-hosted) have no `/bin/bash` for Win32 Path::is_file;
2891    // fleet inventory on op-box resolves `bash` to the WSL stub at
2892    // `/c/Windows/system32/bash`, which is not a host shell. Use ComSpec/cmd.
2893    #[cfg(windows)]
2894    let (detect_command, program, fixed_args, process_name) = {
2895        let program = std::env::var("ComSpec").unwrap_or_else(|_| {
2896            r"C:\Windows\System32\cmd.exe".to_owned()
2897        });
2898        ("cmd".to_owned(), program, Vec::<String>::new(), "cmd".to_owned())
2899    };
2900    #[cfg(not(windows))]
2901    let (detect_command, program, fixed_args, process_name) = (
2902        "bash".to_owned(),
2903        "/bin/bash".to_owned(),
2904        vec!["-i".to_owned()],
2905        "bash".to_owned(),
2906    );
2907    AgentSpec {
2908        id: AgentId::new("shell").expect("shell agent id"),
2909        revision: "hatchery:shell-pty-1".to_owned(),
2910        display_name: "Host shell".to_owned(),
2911        detection: DetectionSpec {
2912            command: detect_command,
2913            aliases: vec!["sh".to_owned(), "shell".to_owned(), "cmd".to_owned()],
2914            required_commands: Vec::new(),
2915            unsupported_platforms: Vec::new(),
2916        },
2917        launch: LaunchSpec {
2918            program,
2919            fixed_args,
2920        },
2921        expected_processes: vec![ProcessMatcher::Exact {
2922            name: process_name,
2923        }],
2924        prompt: PromptSpec {
2925            initial: InitialPromptMode::None,
2926            native_draft: None,
2927        },
2928        readiness: AgentReadinessSpec::default(),
2929        capabilities: AgentCapabilities {
2930            agent_commands: None,
2931            transports: AgentTransportCapabilities {
2932                pty: true,
2933                pty_adapter: None,
2934                pipe: None,
2935                acp: None,
2936            },
2937            adapters: AgentAdapterCapabilities::default(),
2938        },
2939        verification: SpecVerification::Gate4AgentVerified,
2940    }
2941}
2942
2943fn active_registry() -> Result<AgentRegistry, NodeServerError> {
2944    let mut specs = vec![shell_host_spec()];
2945    specs.extend(
2946        builtin_registry()
2947            .iter()
2948            .filter(|spec| matches!(spec.id.as_str(), "claude" | "codex" | "grok" | "kimi"))
2949            .cloned(),
2950    );
2951    AgentRegistry::new(specs).map_err(|error| NodeServerError::Registry(error.to_string()))
2952}
2953
2954fn provider_contract_manifest(
2955    catalog: &AgentRegistry,
2956) -> Result<
2957    (
2958        Vec<ProviderContractSupport>,
2959        Vec<ProviderAdapterContractSupport>,
2960    ),
2961    NodeServerError,
2962> {
2963    let mut provider_contracts = Vec::new();
2964    let mut provider_adapter_contracts = Vec::new();
2965    for spec in catalog.iter() {
2966        let provider = spec.id.clone();
2967        let revision = ProviderContractRevision::new(spec.revision.clone())
2968            .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2969        provider_contracts.push(ProviderContractSupport {
2970            provider: provider.clone(),
2971            revision,
2972        });
2973        for (family, binding) in declared_adapter_bindings(spec)? {
2974            let revision = AdapterContractRevision::new(binding.revision.clone())
2975                .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2976            provider_adapter_contracts.push(ProviderAdapterContractSupport {
2977                provider: provider.clone(),
2978                family,
2979                adapter_id: binding.id.clone(),
2980                revision,
2981            });
2982        }
2983    }
2984    validate_provider_contract_manifest(&provider_contracts, &provider_adapter_contracts)
2985        .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2986    Ok((provider_contracts, provider_adapter_contracts))
2987}
2988
2989fn declared_adapter_bindings(
2990    spec: &AgentSpec,
2991) -> Result<Vec<(AdapterFamily, &AdapterBinding)>, NodeServerError> {
2992    let mut bindings = Vec::new();
2993    push_declared_binding(
2994        &mut bindings,
2995        AdapterFamily::PtySemantic,
2996        spec.capabilities.transports.pty_adapter.as_ref(),
2997    )?;
2998    push_declared_binding(
2999        &mut bindings,
3000        AdapterFamily::Pipe,
3001        spec.capabilities.adapters.pty_sidecar.as_ref(),
3002    )?;
3003    let pipe_transport = spec.capabilities.transports.pipe.as_ref();
3004    let pipe_family = pipe_transport.map(|transport| match transport.protocol {
3005        gate4agent_types::PipeProtocol::SemanticNdjson
3006        | gate4agent_types::PipeProtocol::StructuredJsonl => AdapterFamily::Pipe,
3007        gate4agent_types::PipeProtocol::OneShotText => AdapterFamily::OneShot,
3008    });
3009    push_declared_binding(
3010        &mut bindings,
3011        AdapterFamily::Pipe,
3012        pipe_transport
3013            .filter(|_| pipe_family == Some(AdapterFamily::Pipe))
3014            .map(|transport| &transport.adapter),
3015    )?;
3016    push_declared_binding(
3017        &mut bindings,
3018        AdapterFamily::Acp,
3019        spec.capabilities
3020            .transports
3021            .acp
3022            .as_ref()
3023            .map(|transport| &transport.adapter),
3024    )?;
3025    push_declared_binding(
3026        &mut bindings,
3027        AdapterFamily::Hook,
3028        spec.capabilities.adapters.hook.as_ref(),
3029    )?;
3030    push_declared_binding(
3031        &mut bindings,
3032        AdapterFamily::ManagedHook,
3033        spec.capabilities.adapters.managed_hook.as_ref(),
3034    )?;
3035    push_declared_binding(
3036        &mut bindings,
3037        AdapterFamily::OneShot,
3038        pipe_transport
3039            .filter(|_| pipe_family == Some(AdapterFamily::OneShot))
3040            .map(|transport| &transport.adapter),
3041    )?;
3042    push_declared_binding(
3043        &mut bindings,
3044        AdapterFamily::OneShot,
3045        spec.capabilities.adapters.one_shot.as_ref(),
3046    )?;
3047    push_declared_binding(
3048        &mut bindings,
3049        AdapterFamily::History,
3050        spec.capabilities.adapters.history.as_ref(),
3051    )?;
3052    push_declared_binding(
3053        &mut bindings,
3054        AdapterFamily::Resume,
3055        spec.capabilities.adapters.resume.as_ref(),
3056    )?;
3057    push_declared_binding(
3058        &mut bindings,
3059        AdapterFamily::SessionOptions,
3060        spec.capabilities.adapters.session_options.as_ref(),
3061    )?;
3062    push_declared_binding(
3063        &mut bindings,
3064        AdapterFamily::CapabilityProbe,
3065        spec.capabilities.adapters.capability_probe.as_ref(),
3066    )?;
3067    Ok(bindings)
3068}
3069
3070fn push_declared_binding<'a>(
3071    bindings: &mut Vec<(AdapterFamily, &'a AdapterBinding)>,
3072    family: AdapterFamily,
3073    binding: Option<&'a AdapterBinding>,
3074) -> Result<(), NodeServerError> {
3075    let Some(binding) = binding else {
3076        return Ok(());
3077    };
3078    if let Some((_, existing)) = bindings
3079        .iter()
3080        .find(|(existing_family, _)| *existing_family == family)
3081    {
3082        if *existing == binding {
3083            return Ok(());
3084        }
3085        return Err(NodeServerError::ProviderContractManifest(format!(
3086            "provider declares conflicting {family:?} adapter bindings"
3087        )));
3088    }
3089    bindings.push((family, binding));
3090    Ok(())
3091}
3092
3093fn merge_durable_state(
3094    configured: &[WorkspaceConfig],
3095    mut loaded: LoadedNodeState,
3096) -> Result<(Vec<WorkspaceConfig>, Vec<ManagedSessionRecord>, Option<String>), NodeServerError> {
3097    let mut workspaces = configured
3098        .iter()
3099        .cloned()
3100        .map(|workspace| (workspace.workspace_id.clone(), workspace))
3101        .collect::<BTreeMap<_, _>>();
3102    let mut roots = workspaces
3103        .values()
3104        .map(|workspace| {
3105            (
3106                platform::root_identity(&workspace.canonical_root),
3107                workspace.workspace_id.clone(),
3108            )
3109        })
3110        .collect::<BTreeMap<_, _>>();
3111    let mut warning = loaded
3112        .warning
3113        .take()
3114        .map(|message| session_registry::sanitized_persistence_summary(&message));
3115
3116    for (workspace_id, canonical_root) in loaded.workspaces {
3117        if let Some(existing) = workspaces.get(&workspace_id) {
3118            if !platform::roots_equal(&existing.canonical_root, &canonical_root) {
3119                return Err(NodeServerError::DurableState(io::Error::new(
3120                    io::ErrorKind::InvalidData,
3121                    DURABLE_STATE_CONFLICT_ERROR,
3122                )));
3123            }
3124            continue;
3125        }
3126        if roots.get(&platform::root_identity(&canonical_root)).is_some() {
3127            return Err(NodeServerError::DurableState(io::Error::new(
3128                io::ErrorKind::InvalidData,
3129                DURABLE_STATE_CONFLICT_ERROR,
3130            )));
3131        }
3132        if !Path::new(&canonical_root).is_dir() {
3133            warning.get_or_insert_with(|| {
3134                session_registry::DURABLE_STATE_WORKSPACE_WARNING.to_owned()
3135            });
3136            continue;
3137        }
3138        let workspace = WorkspaceConfig::new(workspace_id.clone(), &canonical_root).map_err(|_| {
3139            NodeServerError::DurableState(io::Error::new(
3140                io::ErrorKind::InvalidData,
3141                DURABLE_STATE_CONFLICT_ERROR,
3142            ))
3143        })?;
3144        roots.insert(
3145            platform::root_identity(&workspace.canonical_root),
3146            workspace.workspace_id.clone(),
3147        );
3148        workspaces.insert(workspace_id, workspace);
3149    }
3150
3151    for record in &mut loaded.records {
3152        match workspaces.get(&record.workspace_id) {
3153            Some(workspace)
3154                if platform::roots_equal(
3155                    &workspace.canonical_root,
3156                    windows_path_text(&record.canonical_root),
3157                ) => {}
3158            Some(_) => {
3159                return Err(NodeServerError::DurableState(io::Error::new(
3160                    io::ErrorKind::InvalidData,
3161                    DURABLE_STATE_CONFLICT_ERROR,
3162                )));
3163            }
3164            None => {
3165                record.state = ManagedSessionState::Unavailable;
3166                record.active_session = None;
3167                record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
3168            }
3169        }
3170    }
3171    Ok((workspaces.into_values().collect(), loaded.records, warning))
3172}
3173
3174/// One entry of `NodeShared::rejected_command_reasons` -- see that field's
3175/// own doc for the mechanism this exists to close.
3176struct RejectedCommandReason {
3177    message: String,
3178    /// True when the kernel refused this command specifically because the
3179    /// agent does not declare the requested transport (`KernelCommandError::
3180    /// UnsupportedTransport`) -- lets the reader choose the named
3181    /// `NodeFailureCode::UnsupportedTransport` over the generic
3182    /// `BackendOperationFailed` bucket, so this one well-known rejection
3183    /// keeps its identity all the way to the operator wire.
3184    unsupported_transport: bool,
3185    expires_at: Instant,
3186}
3187
3188struct NodeShared {
3189    handle: Gate4AgentHandle,
3190    access_token: String,
3191    node_id: NodeId,
3192    incarnation_id: NodeIncarnationId,
3193    started_at_unix_ms: u64,
3194    workspaces: RwLock<BTreeMap<WorkspaceId, String>>,
3195    worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode>,
3196    managed_worktree_profiles:
3197        BTreeMap<WorkspaceId, BTreeMap<WorktreeProfileId, ManagedWorktreeProfile>>,
3198    managed_worktrees: Mutex<ManagedWorktreeRegistry>,
3199    enabled_providers: Vec<AgentId>,
3200    provider_runtime_statuses: ProviderRuntimeStatuses,
3201    provider_runtime_status_updates:
3202        RwLock<BTreeMap<AgentId, crate::protocol::ProviderRuntimeStatus>>,
3203    provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
3204    provider_contracts: Vec<ProviderContractSupport>,
3205    provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
3206    spawn_profiles: SpawnProfileRegistry,
3207    environment_profiles:
3208        RwLock<BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>>,
3209    environment_materialization_profiles:
3210        RwLock<BTreeMap<SpawnEnvironmentProfileId, NodeSessionMaterializationProfile>>,
3211    /// Station **network** allowlist catalog (opaque policy ids only).
3212    /// Empty by default — any `network_allowlist` on spawn must be registered
3213    /// here or resolve refuses with `UnknownNetworkAllowlist`. Optional
3214    /// startup load via `NodeServerConfig::with_network_allowlist_catalog(_file)`
3215    /// / env `GATE4AGENT_NODE_NETWORK_ALLOWLIST_CATALOG`. Plan
3216    /// `station-network-and-browser-profile-knobs-2026-10-02.md` §2.1 / §4.
3217    /// Never holds cookies / OAuth / proxy credentials.
3218    network_allowlist_catalog: RwLock<crate::network_allowlist_catalog::NetworkAllowlistCatalog>,
3219    /// Node-local exclusive dig2browser station profile leases (feature
3220    /// `dig2-station-probe`). Opaque `browser_profile_id` ↔ session holder;
3221    /// cleared on session end. Never ImportSession / cookies on C2.
3222    #[cfg(feature = "dig2-station-probe")]
3223    browser_station_leases: crate::dig2_station_lease::BrowserStationLeaseTable,
3224    bundle_catalog: RwLock<BundleCatalog>,
3225    delivery_store: Mutex<Option<DeliveryStore>>,
3226    context_pack_store: Mutex<Option<ContextPackStore>>,
3227    context_catalog: RwLock<ContextPackCatalog>,
3228    native_launch_profile_control: Option<NativeLaunchProfileControl>,
3229    harness_mcp_registry: Option<HarnessMcpProxyRegistry>,
3230    native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
3231    session_environment_materializer: Option<SessionEnvironmentMaterializer>,
3232    /// Per-session m4a accounts assigned at spawn (see [`crate::m4a_accounts`]).
3233    m4a_accounts: Option<crate::m4a_accounts::M4aSessionAccounts>,
3234    /// nick -> the session that holds that m4a account (for letter wakes).
3235    m4a_live: Mutex<BTreeMap<String, (AgentInstanceId, AgentId)>>,
3236    /// m4a session key per runtime this node gave an account.
3237    m4a_keys: Mutex<BTreeMap<AgentInstanceId, String>>,
3238    /// Keys a resume hands to its new runtime (same nick and mailbox).
3239    m4a_reuse_keys: Mutex<BTreeMap<AgentInstanceId, String>>,
3240    /// Per-session grok leader sockets (never the operator's shared leader).
3241    grok_leaders: Mutex<BTreeMap<AgentInstanceId, std::path::PathBuf>>,
3242    /// m4a signer proxy (option A): when set, PTY sessions get a process-bound handle
3243    /// instead of a bearer capability.
3244    #[cfg(unix)]
3245    signer_proxy: std::sync::OnceLock<Arc<crate::keystore_proxy::SignerProxy>>,
3246    materializations: Mutex<BTreeMap<MaterializationId, MaterializationOwnershipRecord>>,
3247    spawn_idempotency: Mutex<SpawnIdempotencyCache>,
3248    managed_spawn_replays:
3249        Mutex<BTreeMap<SpawnIdempotencyKey, ManagedWorktreeSpawnReplayRecordV10>>,
3250    controller: Mutex<Option<ControllerLease>>,
3251    /// Fresh spawns waiting for their first successful HQ attach. Removed on
3252    /// Resize/TerminalBytes (sessionize) or when the grace reaper stops them.
3253    provisional_spawns: Mutex<BTreeMap<AgentInstanceId, (SessionAddress, Instant)>>,
3254    /// ACP launch options waiting for the agent to advertise its config options.
3255    pending_acp_options: Mutex<BTreeMap<AgentInstanceId, (Option<String>, Option<String>)>>,
3256    history: Mutex<NodeEventHistory>,
3257    event_tx: broadcast::Sender<NodeEventEnvelope>,
3258    terminal_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3259    /// The outbound `NodeEvent::AgentStream` push channel -- its own
3260    /// dedicated, capability-gated broadcast, mirroring `terminal_event_tx`
3261    /// exactly rather than riding the durable `event_tx` history (see
3262    /// `publish`'s own assertion): a subscriber who never negotiated
3263    /// `NODE_AGENT_STREAM_EVENTS_CAPABILITY` must never receive one of
3264    /// these, the same guarantee `include_terminal_frame_events` already
3265    /// gives terminal frames.
3266    agent_stream_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3267    /// The outbound `NodeEvent::HarnessMcpReadCall` push channel -- mirrors
3268    /// `agent_stream_event_tx` exactly, its own dedicated, capability-gated
3269    /// broadcast rather than a ride on the durable `event_tx` history. A
3270    /// `HarnessMcpReadCall` is a request/response call a live proxy call is
3271    /// waiting on (`harness_mcp_proxy.rs`'s `begin_call`); the shared bus's
3272    /// finite capacity and its willingness to silently evict a lagging
3273    /// subscriber's backlog is exactly how the call vanished with neither
3274    /// side ever logging it (see `publish_transient`'s own doc).
3275    harness_mcp_proxy_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3276    terminal_frame_watermarks:
3277        Mutex<BTreeMap<AgentInstanceId, (SessionAddress, u64)>>,
3278    /// Wakes the drive loop as soon as PTY input is queued (no 10ms nap).
3279    pty_input_wake: tokio::sync::Notify,
3280    /// Per session: when the last PTY input was dispatched (latency probe +
3281    /// a short fast-poll window so the echo is published within ~1ms).
3282    pty_input_marks: Mutex<BTreeMap<AgentInstanceId, (Instant, bool)>>,
3283    next_connection_id: AtomicU64,
3284    next_instance_id: AtomicU64,
3285    next_command_id: AtomicU64,
3286    shutdown: AtomicBool,
3287    shutdown_notify: Notify,
3288    preauth_slots: Arc<Semaphore>,
3289    authenticated_slots: Arc<Semaphore>,
3290    inspection_slots: Arc<Semaphore>,
3291    mutation_gate: AsyncMutex<()>,
3292    session_bindings: Mutex<BTreeMap<AgentInstanceId, SessionBinding>>,
3293    session_records: Mutex<SessionRecords>,
3294    state_path: Option<PathBuf>,
3295    persistence_error: RwLock<Option<String>>,
3296    state_transaction: Mutex<()>,
3297    input_settle_timeout_ms: u64,
3298    /// Drive-loop phase/rate timings -- see `drive_loop_profile`'s own doc
3299    /// comment. Written once per iteration by
3300    /// `drive_runtime_until_shutdown`, read by `GET /metrics`.
3301    drive_loop_profile: Mutex<DriveLoopProfiler>,
3302    /// Latest copy of `NativeRuntime::tick_profile_snapshot()` -- `Copy`,
3303    /// so an overwrite each iteration is cheap. The runtime itself is not
3304    /// reachable from here (it is owned locally by
3305    /// `drive_runtime_until_shutdown`), so this is how its own per-phase
3306    /// tick timings reach `GET /metrics`.
3307    runtime_tick_profile: Mutex<TickProfileSnapshot>,
3308    /// The live shell-efficiency profile, shared with the per-instance
3309    /// worker loops that fold into it.
3310    ///
3311    /// Deliberately NOT the per-iteration snapshot copy `runtime_tick_
3312    /// profile` above keeps: producing a snapshot sorts every ring behind
3313    /// it, and that sort belongs on the read. A `OnceLock` rather than a
3314    /// field set at construction because `NodeShared` is built before the
3315    /// `NativeRuntime` that owns the profile exists -- the drive loop
3316    /// publishes it once, before its first iteration. Unset means the
3317    /// runtime has not started driving yet, which `GET /metrics` reports as
3318    /// empty series rather than as zeroes it never measured.
3319    shell_efficiency_profile: OnceLock<Arc<Mutex<ShellEfficiencyProfile>>>,
3320    /// Physical PTY sessions the native runtime currently owns
3321    /// (`NativeRuntime::active_native_sessions`), refreshed once per drive
3322    /// loop iteration -- deliberately a separate count from
3323    /// `handle.snapshot().sessions.len()` (the control-plane kernel's own
3324    /// session bookkeeping), since the two can disagree and the gap is
3325    /// itself a diagnostic signal `GET /metrics` exposes.
3326    native_session_gauge: AtomicUsize,
3327    /// Lifetime totals for the per-connection serve loop, summed across
3328    /// every live connection.
3329    ///
3330    /// The drive loop's own phase timings account for about ten
3331    /// microseconds an iteration on an idle node, yet the process burns a
3332    /// quarter of a core — and burns it ONLY while a c2 is attached (0.5%
3333    /// with none, 23% with two, measured). That gap is somewhere in
3334    /// serving a connection, and there was no way to see it: iterations
3335    /// says whether the loop is spinning rather than sleeping, and events
3336    /// says whether it is spinning over real work or over nothing.
3337    connection_loop_iterations: AtomicU64,
3338    connection_events_sent: AtomicU64,
3339    /// Lifetime totals for `drive_runtime_until_shutdown`'s own iterations
3340    /// -- `drive_loop_iterations_idle` counts iterations that applied zero
3341    /// `NativeRuntime::tick` observations AND forwarded zero control
3342    /// events; see the exact "produced nothing" definition at the site in
3343    /// `drive_runtime_until_shutdown` that increments these. Read twice
3344    /// and divide by the interval, same rule as `connections` above --
3345    /// `idle / total` is backlog item 5's "wake on work" number.
3346    /// Lifetime count of control commands the kernel refused.
3347    rejected_commands_total: AtomicU64,
3348    /// The named reason behind a recent kernel rejection, by the
3349    /// `CommandId` `dispatch_bounded` handed back when it enqueued that
3350    /// command -- populated by `drive_runtime_until_shutdown` for every
3351    /// `Err` in a tick's `command_outcomes`, and drained by whichever
3352    /// spawn-dispatch waiter recognizes its own `Register`/`Start`
3353    /// `CommandId` among them.
3354    ///
3355    /// A `ControlCommand` only ever fails synchronously (a `NodeFailure`
3356    /// out of `dispatch`/`dispatch_bounded` itself) for a port-level ingress
3357    /// problem -- full or disconnected -- never for the kernel refusing the
3358    /// command's content (an unsupported transport, an unknown agent, ...);
3359    /// that verdict exists only once `NativeRuntime::tick` has processed the
3360    /// command, one drive-loop iteration later, in a different task than
3361    /// whichever caller is awaiting the spawn. Without this map, a spawn
3362    /// wait loop that polls `self.handle.snapshot()` for a session that
3363    /// will now never appear has no way to learn why -- it can only spin
3364    /// until its own commit deadline and report a generic "did not commit"
3365    /// timeout, discarding a reason the kernel already named precisely (see
3366    /// `KernelCommandError`'s variants, e.g. `UnsupportedTransport`).
3367    rejected_command_reasons: Mutex<BTreeMap<CommandId, RejectedCommandReason>>,
3368    drive_loop_iterations_total: AtomicU64,
3369    drive_loop_iterations_idle: AtomicU64,
3370    /// The operator-chosen age/keep-N policy the retention sweep applies to
3371    /// `ManagedSessionState::Unavailable` records. Both `0` by default --
3372    /// see `SessionRecordRetentionConfig`'s own doc comment.
3373    session_record_retention: SessionRecordRetentionConfig,
3374    /// Lifetime count of managed session records the retention sweep has
3375    /// retired. Same tier as `rejected_commands_total` above: a live,
3376    /// process-lifetime instrument, never persisted and never reloaded from
3377    /// `state-v1.json` -- a restart legitimately resets it, matching every
3378    /// other `*_total` counter here.
3379    retired_records_total: AtomicU64,
3380    #[cfg(feature = "fixture")]
3381    fixture_semantic_hook_policy: bool,
3382    #[cfg(feature = "fixture")]
3383    fixture_spawn_managed_worktree_v2_failure_probe: SpawnManagedWorktreeV2FailureProbe,
3384}
3385
3386impl Drop for NodeShared {
3387    fn drop(&mut self) {
3388        SessionLifecycleHook::on_shutdown(self);
3389    }
3390}
3391
3392#[derive(Clone, Debug, Eq, PartialEq)]
3393struct SessionBinding {
3394    workspace_id: WorkspaceId,
3395    generation: SessionGeneration,
3396    runtime_policy: ProviderRuntimePolicy,
3397    pending_resume: Option<(SessionGeneration, CommandId, ProviderRuntimePolicy)>,
3398    record_id: Option<SessionRecordId>,
3399    managed_worktree_lease_id: Option<ManagedWorktreeLeaseId>,
3400    environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
3401    bundle: Option<ResolvedBundleReceipt>,
3402    context: Option<ResolvedContextPackReceipt>,
3403    materialization_id: Option<MaterializationId>,
3404}
3405
3406#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3407enum SpawnRecordPolicy {
3408    ProviderIdentityOnly,
3409    Always,
3410}
3411
3412struct NativeEnvironmentSelectionGuard {
3413    control: Option<NativeLaunchProfileControl>,
3414    instance_id: AgentInstanceId,
3415}
3416
3417struct NativeInstanceOverlayGuard {
3418    control: Option<NativeLaunchProfileControl>,
3419    instance_id: AgentInstanceId,
3420}
3421
3422struct NativeHarnessMcpOverlayGuard {
3423    control: NativeLaunchProfileControl,
3424    instance_id: AgentInstanceId,
3425}
3426
3427impl NativeHarnessMcpOverlayGuard {
3428    fn retain(self) { std::mem::forget(self); }
3429}
3430
3431impl Drop for NativeHarnessMcpOverlayGuard {
3432    fn drop(&mut self) {
3433        self.control
3434            .clear_native_mcp_server_launch_overlay(self.instance_id);
3435    }
3436}
3437
3438impl NativeInstanceOverlayGuard {
3439    fn retain(mut self) {
3440        self.control = None;
3441    }
3442}
3443
3444impl Drop for NativeInstanceOverlayGuard {
3445    fn drop(&mut self) {
3446        if let Some(control) = self.control.as_ref() {
3447            control.clear_native_instance_launch_overlay(self.instance_id);
3448        }
3449    }
3450}
3451
3452enum PreparedNativeLaunchOverlay {
3453    Environment(NativeLaunchEnvironmentOverlay),
3454    Instance(NativeInstanceLaunchOverlay),
3455}
3456
3457impl NativeEnvironmentSelectionGuard {
3458    fn retain(mut self) {
3459        self.control = None;
3460    }
3461}
3462
3463impl Drop for NativeEnvironmentSelectionGuard {
3464    fn drop(&mut self) {
3465        if let Some(control) = self.control.as_ref() {
3466            control.clear_native_launch_profile_selection(self.instance_id);
3467        }
3468    }
3469}
3470
3471struct SessionMaterializationGuard<'a> {
3472    shared: &'a NodeShared,
3473    id: Option<MaterializationId>,
3474    /// Absolute ProviderHome (`home/`) under the session materialization root.
3475    /// Used for Claude station network `--settings` sidecar writes.
3476    provider_home: Option<PathBuf>,
3477}
3478
3479impl SessionMaterializationGuard<'_> {
3480    fn id(&self) -> Option<&MaterializationId> {
3481        self.id.as_ref()
3482    }
3483
3484    fn provider_home(&self) -> Option<&Path> {
3485        self.provider_home.as_deref()
3486    }
3487
3488    fn retain(mut self) {
3489        self.id = None;
3490    }
3491}
3492
3493impl Drop for SessionMaterializationGuard<'_> {
3494    fn drop(&mut self) {
3495        if let Some(id) = self.id.take() {
3496            let _ = self.shared.cleanup_materialization(&id);
3497        }
3498    }
3499}
3500
3501
3502/// Port: product-side reactions to session lifecycle transitions
3503/// (station lease, harness MCP overlay/registry, environment launch overlays).
3504trait SessionLifecycleHook {
3505    /// A binding was removed (unbind or spawn rollback).
3506    fn on_binding_removed(&self, address: &SessionAddress, removed: Option<&SessionBinding>);
3507    /// A session reached a terminal control event that revokes its harness MCP door.
3508    fn on_session_ended(&self, address: &SessionAddress);
3509    /// Node shutdown (or drop): revoke every harness MCP reservation.
3510    fn on_shutdown(&self);
3511    /// A harness MCP reservation was aborted before activation.
3512    fn on_harness_reservation_aborted(&self, instance_id: AgentInstanceId);
3513    /// Release session-owned materialization for a removed binding.
3514    fn cleanup_session_materialization(
3515        &self,
3516        address: &SessionAddress,
3517        binding: &SessionBinding,
3518    ) -> Result<(), NodeFailure>;
3519}
3520
3521impl SessionLifecycleHook for NodeShared {
3522    fn on_session_ended(&self, address: &SessionAddress) {
3523        self.retire_session_grok_leader(address.session.instance_id);
3524        if !self.shutdown.load(Ordering::Acquire) {
3525            if let Some(file) = self.session_resume_journal() {
3526                crate::pty_resume::remove(&file, address.session.instance_id.0);
3527            }
3528        }
3529        if let Some(accounts) = self.m4a_accounts.as_ref() {
3530            let instance = address.session.instance_id;
3531            let live = self.m4a_live.lock().unwrap_or_else(|p| p.into_inner());
3532            for (nick, _) in live.iter().filter(|(_, (i, _))| *i == instance) {
3533                accounts.remove_session_file(nick);
3534            }
3535        }
3536        if let Some(registry) = self.harness_mcp_registry.as_ref() {
3537            registry.revoke_session(address);
3538        }
3539        if let Some(control) = self.native_launch_profile_control.as_ref() {
3540            control.clear_native_mcp_server_launch_overlay(address.session.instance_id);
3541        }
3542    }
3543
3544    fn on_shutdown(&self) {
3545        let leaders: Vec<_> = std::mem::take(&mut *self.grok_leaders.lock().unwrap_or_else(|p| p.into_inner()))
3546            .into_values()
3547            .collect();
3548        for socket in leaders {
3549            crate::grok_leader::retire(&socket);
3550        }
3551        if let Some(registry) = self.harness_mcp_registry.as_ref() {
3552            let instances = registry.shutdown();
3553            if let Some(control) = self.native_launch_profile_control.as_ref() {
3554                for instance_id in instances {
3555                    control.clear_native_mcp_server_launch_overlay(instance_id);
3556                }
3557            }
3558        }
3559    }
3560
3561    fn on_harness_reservation_aborted(&self, instance_id: AgentInstanceId) {
3562        if let Some(control) = self.native_launch_profile_control.as_ref() {
3563            control.clear_native_mcp_server_launch_overlay(instance_id);
3564        }
3565    }
3566
3567    fn cleanup_session_materialization(
3568        &self,
3569        address: &SessionAddress,
3570        binding: &SessionBinding,
3571    ) -> Result<(), NodeFailure> {
3572        self.cleanup_session_owned_materialization(address, binding)
3573    }
3574
3575    fn on_binding_removed(&self, address: &SessionAddress, removed: Option<&SessionBinding>) {
3576        // Exclusive dig2browser station profile lease is node-local; clear
3577        // on every session unbind / rollback. Never ImportSession/cookies.
3578        #[cfg(feature = "dig2-station-probe")]
3579        {
3580            crate::dig2_station_lease::release_lease_for_holder(
3581                &self.browser_station_leases,
3582                address.session.instance_id,
3583            );
3584        }
3585        if let Some(control) = self.native_launch_profile_control.as_ref() {
3586            control.clear_native_mcp_server_launch_overlay(
3587                address.session.instance_id,
3588            );
3589        }
3590        if let Some(registry) = self.harness_mcp_registry.as_ref() {
3591            registry.revoke_session(address);
3592        }
3593        if removed
3594            .is_some_and(|binding| {
3595                binding.environment_profile.is_some()
3596                    || binding.bundle.is_some()
3597                    || binding.context.is_some()
3598            })
3599        {
3600            if let Some(control) = self.native_launch_profile_control.as_ref() {
3601                control.clear_native_launch_profile_selection(
3602                    address.session.instance_id,
3603                );
3604                control.clear_native_instance_launch_overlay(
3605                    address.session.instance_id,
3606                );
3607            }
3608        }
3609    }
3610}
3611
3612struct SpawnPrepareRequest<'r> {
3613    instance_id: AgentInstanceId,
3614    address: &'r SessionAddress,
3615    provider: &'r AgentId,
3616    mode: SessionMode,
3617    environment_profile: Option<&'r ResolvedEnvironmentProfileReceipt>,
3618    bundle: Option<&'r ResolvedBundleReceipt>,
3619    context: Option<&'r ResolvedContextPackReceipt>,
3620    managed_authority: Option<&'r ManagedWorktreeLeaseId>,
3621    harness_mcp: Option<&'r PreparedHarnessMcpSpawn>,
3622    approval_level: ApprovalLevel,
3623    network_allowlist: Option<&'r crate::protocol::SpawnNetworkAllowlistId>,
3624    browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
3625}
3626
3627/// Guards held between "session identity minted" and "launch committed".
3628/// Field order is the drop order and matches the pre-refactor local order
3629/// (instance overlay, environment selection, materialization, harness, station lease).
3630struct PreparedSpawn<'a> {
3631    instance_overlay: Option<NativeInstanceOverlayGuard>,
3632    environment_selection: Option<NativeEnvironmentSelectionGuard>,
3633    materialization_guard: Option<SessionMaterializationGuard<'a>>,
3634    harness_mcp_overlay: Option<NativeHarnessMcpOverlayGuard>,
3635    #[cfg(feature = "dig2-station-probe")]
3636    browser_station_lease_guard: Option<crate::dig2_station_lease::BrowserStationLeaseGuard>,
3637    materialization_id: Option<MaterializationId>,
3638}
3639
3640impl PreparedSpawn<'_> {
3641    /// Session-owned launch state now belongs to the binding.
3642    fn retain_session_guards(&mut self) {
3643        if let Some(selection) = self.environment_selection.take() {
3644            selection.retain();
3645        }
3646        if let Some(overlay) = self.instance_overlay.take() {
3647            overlay.retain();
3648        }
3649        if let Some(guard) = self.materialization_guard.take() {
3650            guard.retain();
3651        }
3652    }
3653
3654    /// Launch committed: keep harness overlay and station lease for the session lifetime.
3655    fn retain_launch_guards(&mut self) {
3656        if let Some(overlay) = self.harness_mcp_overlay.take() {
3657            overlay.retain();
3658        }
3659        #[cfg(feature = "dig2-station-probe")]
3660        if let Some(guard) = self.browser_station_lease_guard.take() {
3661            guard.retain();
3662        }
3663    }
3664}
3665
3666/// Port: everything product-side that must be prepared before a session is
3667/// bound and started (station lease, harness MCP overlay, materialization,
3668/// environment selection, network overlays, launch overlay).
3669trait SpawnPreparer {
3670    fn prepare<'a>(&'a self, request: SpawnPrepareRequest<'_>) -> Result<PreparedSpawn<'a>, NodeFailure>;
3671    /// Select the native launch profile for an environment-profile session.
3672    fn select_environment(
3673        &self,
3674        instance_id: AgentInstanceId,
3675        provider: &AgentId,
3676        mode: SessionMode,
3677        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
3678    ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure>;
3679    /// Install a prepared launch overlay for the instance.
3680    fn install_launch_overlay(
3681        &self,
3682        instance_id: AgentInstanceId,
3683        overlay: PreparedNativeLaunchOverlay,
3684    ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure>;
3685}
3686
3687impl NodeShared {
3688    /// m4a session key of a new runtime: a resume reuses the key (and so the nick and mailbox)
3689    /// of the runtime it replaces; a fresh spawn gets `<incarnation>-<instance>`.
3690    fn m4a_session_key(&self, instance_id: AgentInstanceId) -> String {
3691        let reused = self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).remove(&instance_id);
3692        let key = reused.unwrap_or_else(|| format!("{}-{}", self.incarnation_id, instance_id.0));
3693        self.m4a_keys.lock().unwrap_or_else(|p| p.into_inner()).insert(instance_id, key.clone());
3694        if let Some(file) = self.session_resume_journal() {
3695            crate::pty_resume::set_session_key(&file, instance_id.0, &key);
3696        }
3697        key
3698    }
3699
3700    fn session_workdir(&self, root: &std::path::Path, instance_id: AgentInstanceId, provider: &AgentId) -> Option<std::path::PathBuf> {
3701        let accounts = self.m4a_accounts.as_ref()?;
3702        let nick = self.m4a_nick_of(instance_id)?;
3703        match accounts.session_workdir(root, provider.as_str(), &nick) {
3704            Ok(dir) => dir,
3705            Err(error) => {
3706                tracing::warn!(%nick, %error, "session folder not prepared; the session runs in the workspace root");
3707                None
3708            }
3709        }
3710    }
3711
3712    fn m4a_nick_of(&self, instance_id: AgentInstanceId) -> Option<String> {
3713        self.m4a_live.lock().unwrap_or_else(|p| p.into_inner()).iter().find(|(_, (i, _))| *i == instance_id).map(|(n, _)| n.clone())
3714    }
3715
3716    /// The managed record of a node session carries its m4a nick as its name (HQ shows it,
3717    /// `/api/spawn` returns it).
3718    fn name_record_after_nick(&self, instance_id: AgentInstanceId, nick: &str) {
3719        let mut records = self.session_records.lock().unwrap_or_else(|p| p.into_inner());
3720        for record in records.records.values_mut() {
3721            if record.active_session.as_ref().is_some_and(|a| a.session.instance_id == instance_id) {
3722                record.display_name = nick.to_owned();
3723            }
3724        }
3725    }
3726
3727    /// Gives a node session its own m4a account: identity env, session file, MCP entry and
3728    /// the mailbox listener that wakes it.
3729    fn m4a_session_overlay(
3730        &self,
3731        accounts: &crate::m4a_accounts::M4aSessionAccounts,
3732        m4a_provider: &str,
3733        provider: &AgentId,
3734        instance_id: AgentInstanceId,
3735        environment_overlay: Option<PreparedNativeLaunchOverlay>,
3736        session_key: &str,
3737        mode: SessionMode,
3738    ) -> Result<Option<PreparedNativeLaunchOverlay>, NodeFailure> {
3739        let session_key = session_key.to_owned();
3740        if mode == SessionMode::Acp {
3741            return self.m4a_acp_mcp_overlay(accounts, m4a_provider, provider, instance_id, environment_overlay, &session_key);
3742        }
3743        Ok(                match accounts.provision(m4a_provider, &session_key) {
3744                    Ok(account) => {
3745                        crate::pty_resume::note_account_ref(&session_key, &account.session_id);
3746                        let mut identity = account.env_mutations();
3747                        identity.extend(crate::m4a_accounts::provider_session_env(provider.as_str()));
3748                        let merged = match environment_overlay {
3749                            None => NativeInstanceLaunchOverlay::new_session_identity(provider.clone(), identity)
3750                                .map(PreparedNativeLaunchOverlay::Instance),
3751                            Some(PreparedNativeLaunchOverlay::Instance(mut overlay)) => {
3752                                overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
3753                            }
3754                            Some(PreparedNativeLaunchOverlay::Environment(mut overlay)) => {
3755                                overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Environment(overlay))
3756                            }
3757                        }
3758                        .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a session account overlay is invalid"))?;
3759                        tracing::info!(provider = %provider, instance_id = ?instance_id, nick = %account.nick, "m4a session account assigned at spawn");
3760                        let mut merged = merged;
3761                        match accounts.write_session_file(&account) {
3762                            Err(error) => tracing::warn!(nick = %account.nick, %error, "m4a session file not written"),
3763                            Ok(_) => match accounts.mcp_launch_args(provider.as_str(), &account.nick) {
3764                                Ok(args) if !args.is_empty() => {
3765                                    merged = match merged {
3766                                        PreparedNativeLaunchOverlay::Instance(mut overlay) => overlay
3767                                            .append_extra_args(args)
3768                                            .map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
3769                                            .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a mcp overlay is invalid"))?,
3770                                        PreparedNativeLaunchOverlay::Environment(overlay) => overlay
3771                                            .into_instance_with_extra_args(args)
3772                                            .map(PreparedNativeLaunchOverlay::Instance)
3773                                            .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a mcp overlay is invalid"))?,
3774                                    };
3775                                    tracing::info!(nick = %account.nick, "m4a mcp server delivered at spawn");
3776                                }
3777                                Ok(_) => {}
3778                                Err(error) => tracing::warn!(nick = %account.nick, %error, "m4a mcp server not delivered"),
3779                            },
3780                        }
3781                        self.m4a_live
3782                            .lock()
3783                            .unwrap_or_else(|p| p.into_inner())
3784                            .insert(account.nick.clone(), (instance_id, provider.clone()));
3785                        self.name_record_after_nick(instance_id, &account.nick);
3786                        tracing::info!(nick = %account.nick, instance = instance_id.0, "m4a nick bound to session");
3787                        // Letters to this account wake the session: the node listens on its mailbox.
3788                        crate::m4a_wake::watch_spawned(provider.as_str(), &account.nick, &account.session_id, account.store_root.as_deref(), None);
3789                        Some(merged)
3790                    }
3791                    Err(error) => {
3792                        tracing::warn!(provider = %provider, instance_id = ?instance_id, %error, "m4a session account not assigned");
3793                        environment_overlay
3794                    }
3795                })
3796    }
3797}
3798
3799impl NodeShared {
3800    /// ACP has no env/argv overlay for this: the session's m4a account rides as an MCP server
3801    /// handed to the agent in session/new, with the account env on that server only.
3802    fn m4a_acp_mcp_overlay(
3803        &self,
3804        accounts: &crate::m4a_accounts::M4aSessionAccounts,
3805        m4a_provider: &str,
3806        provider: &AgentId,
3807        instance_id: AgentInstanceId,
3808        environment_overlay: Option<PreparedNativeLaunchOverlay>,
3809        session_key: &str,
3810    ) -> Result<Option<PreparedNativeLaunchOverlay>, NodeFailure> {
3811        let Some(control) = self.native_launch_profile_control.as_ref() else {
3812            tracing::warn!(%provider, "m4a acp account not assigned: no launch profile control");
3813            return Ok(environment_overlay);
3814        };
3815        let account = match accounts.provision(m4a_provider, session_key) {
3816            Ok(a) => a,
3817            Err(error) => {
3818                tracing::warn!(%provider, instance_id = ?instance_id, %error, "m4a session account not assigned");
3819                return Ok(environment_overlay);
3820            }
3821        };
3822        let session_file = match accounts.write_session_file(&account) {
3823            Ok(p) => p,
3824            Err(error) => {
3825                tracing::warn!(nick = %account.nick, %error, "m4a session file not written");
3826                return Ok(environment_overlay);
3827            }
3828        };
3829        let env_entries: Vec<(OsString, OsString)> = account
3830            .env_mutations()
3831            .into_iter()
3832            .chain(crate::m4a_accounts::provider_session_env(provider.as_str()))
3833            .filter_map(|m| m.value.map(|v| (m.key, v)))
3834            .collect();
3835        let args: Vec<OsString> = vec![
3836            "--session-file".into(),
3837            session_file.into_os_string(),
3838            "mcp".into(),
3839            "--provider".into(),
3840            provider.as_str().into(),
3841        ];
3842        let overlay = NativeMcpServerLaunchOverlay::new(
3843            provider.clone(),
3844            crate::m4a_accounts::mcp_server_name(provider.as_str()).as_str(),
3845            accounts.bin().as_os_str().to_os_string(),
3846            args,
3847            env_entries,
3848            Vec::new(),
3849        );
3850        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())) {
3851            Ok(_) => tracing::info!(nick = %account.nick, instance = instance_id.0, "m4a mcp server delivered over acp"),
3852            Err(error) => {
3853                tracing::warn!(nick = %account.nick, %error, "m4a acp mcp server not delivered");
3854                return Ok(environment_overlay);
3855            }
3856        }
3857        self.m4a_live
3858            .lock()
3859            .unwrap_or_else(|p| p.into_inner())
3860            .insert(account.nick.clone(), (instance_id, provider.clone()));
3861        self.name_record_after_nick(instance_id, &account.nick);
3862        crate::m4a_wake::watch_spawned(provider.as_str(), &account.nick, &account.session_id, account.store_root.as_deref(), None);
3863        Ok(environment_overlay)
3864    }
3865}
3866
3867impl SpawnPreparer for NodeShared {
3868    fn select_environment(
3869        &self,
3870        instance_id: AgentInstanceId,
3871        provider: &AgentId,
3872        mode: SessionMode,
3873        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
3874    ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
3875        self.select_environment_profile(instance_id, provider, mode, environment_profile)
3876    }
3877
3878    fn install_launch_overlay(
3879        &self,
3880        instance_id: AgentInstanceId,
3881        overlay: PreparedNativeLaunchOverlay,
3882    ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
3883        self.install_prepared_launch_overlay(instance_id, overlay)
3884    }
3885
3886    fn prepare<'a>(&'a self, request: SpawnPrepareRequest<'_>) -> Result<PreparedSpawn<'a>, NodeFailure> {
3887        let SpawnPrepareRequest {
3888            instance_id,
3889            address,
3890            provider,
3891            mode,
3892            environment_profile,
3893            bundle,
3894            context,
3895            managed_authority,
3896            harness_mcp,
3897            approval_level,
3898            network_allowlist,
3899            browser_profile_id,
3900        } = request;
3901        // Track A follow-on: exclusive node-local BrowserStationLease after
3902        // dig2 probe (feature dig2-station-probe). Opaque profile id only —
3903        // never ImportSession / cookies on C2. Linux/feature-off: probe
3904        // already refused or stub; this block is cfg'd out when feature off.
3905        #[cfg(feature = "dig2-station-probe")]
3906        let mut browser_station_lease_guard = if let Some(profile_id) = browser_profile_id {
3907            use crate::dig2_station_lease::{acquire_lease, BrowserStationLeaseError};
3908            match acquire_lease(
3909                &self.browser_station_leases,
3910                profile_id,
3911                instance_id,
3912                unix_time_ms(),
3913            ) {
3914                Ok(guard) => Some(guard),
3915                Err(BrowserStationLeaseError::ProfileBusy { .. }) => {
3916                    return Err(failure(
3917                        NodeFailureCode::BrowserStationProfileBusy,
3918                        "dig2browser station profile is already leased by another session",
3919                    ));
3920                }
3921                Err(BrowserStationLeaseError::CapacityExceeded) => {
3922                    return Err(failure(
3923                        NodeFailureCode::BrowserStationProfileBusy,
3924                        "dig2browser station profile lease capacity exhausted",
3925                    ));
3926                }
3927                Err(BrowserStationLeaseError::HolderAlreadyLeased) => {
3928                    return Err(failure(
3929                        NodeFailureCode::BrowserStationProfileBusy,
3930                        "session already holds a dig2browser station profile lease",
3931                    ));
3932                }
3933            }
3934        } else {
3935            None
3936        };
3937        #[cfg(not(feature = "dig2-station-probe"))]
3938        let _ = browser_profile_id;
3939        let mut harness_mcp_overlay = if let Some(prepared) = harness_mcp {
3940            // Slice A(ii) of gate4agent-arc-mailbox-and-task-layer: the door
3941            // exists for PTY (env vars) and ACP (`session/new.mcpServers`,
3942            // `gate4agent-shell-native`'s `mcp_server_acp_entry`) alike --
3943            // both read the identical overlay resolved below, one as child
3944            // environment, the other translated into one stdio MCP server
3945            // entry. `Inline`/`Pipe` stays refused: this arc's proof is ACP
3946            // only, and nothing downstream reads a harness-MCP overlay for
3947            // that transport yet.
3948            if !matches!(mode, SessionMode::Pty | SessionMode::Acp) || prepared.provider != *provider
3949            {
3950                return Err(failure(
3951                    NodeFailureCode::BindingMismatch,
3952                    "harness MCP reservation does not match the exact PTY/ACP provider",
3953                ));
3954            }
3955            prepared.verify_helper_program().map_err(harness_mcp_failure)?;
3956            let control = self.native_launch_profile_control.clone().ok_or_else(|| {
3957                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP launch control is unavailable")
3958            })?;
3959            // Every name below is the caller's, carried in the reservation's
3960            // launch description: which environment variables receive the
3961            // endpoint, token and helper program, what the MCP server is called,
3962            // which argv it gets and which stale variables are erased.
3963            let launch = prepared.launch();
3964            let mut env_entries = vec![
3965                (
3966                    OsString::from(launch.endpoint_env.as_str()),
3967                    prepared.endpoint().as_os_str().to_os_string(),
3968                ),
3969                (
3970                    OsString::from(launch.token_env.as_str()),
3971                    prepared.token().expose().into(),
3972                ),
3973                (
3974                    OsString::from(launch.program_env.as_str()),
3975                    prepared.helper_program().as_os_str().to_os_string(),
3976                ),
3977            ];
3978            if let Some(trace_entry) = harness_mcp_trace_env_entry(launch) {
3979                env_entries.push(trace_entry);
3980            }
3981            if let Some(door) = crate::door::door_listen_addr() {
3982                env_entries.push((
3983                    OsString::from("HATCHERY_NODE_DOOR"),
3984                    OsString::from(door.to_string()),
3985                ));
3986                // Same key shape as HQ browser/draft ids:
3987                // `pty-{incarnation}-{workspace}-{instance}` — never reuse a
3988                // slot across node boots when the instance counter resets.
3989                let session_key = format!(
3990                    "pty-{}-{}-{}",
3991                    self.incarnation_id,
3992                    address.workspace_id.as_str(),
3993                    address.session.instance_id.0
3994                );
3995                env_entries.push((
3996                    OsString::from("HATCHERY_LOCAL_SERVER_SESSION"),
3997                    OsString::from(session_key),
3998                ));
3999            }
4000            let overlay = NativeMcpServerLaunchOverlay::new(
4001                provider.clone(),
4002                launch.server_name.as_str(),
4003                prepared.helper_program().as_os_str().to_os_string(),
4004                launch.args.iter().map(OsString::from).collect(),
4005                env_entries,
4006                launch.scrub_env.iter().map(OsString::from).collect(),
4007            ).map_err(|_| failure(
4008                NodeFailureCode::HarnessMcpUnavailable,
4009                "harness MCP launch overlay is unavailable",
4010            ))?;
4011            control
4012                .install_native_mcp_server_launch_overlay(instance_id, overlay)
4013                .map_err(|_| failure(
4014                    NodeFailureCode::HarnessMcpUnavailable,
4015                    "harness MCP launch overlay could not be installed",
4016                ))?;
4017            // State-change line: the overlay is installed and will be read
4018            // back by the PTY child's environment or, for ACP,
4019            // `gate4agent-shell-native`'s `mcp_server_acp_entry` at spawn
4020            // time -- no secrets here, only which instance/provider/mode
4021            // now carries a harness-MCP door.
4022            tracing::info!(
4023                node_id = %self.node_id,
4024                instance_id = ?instance_id,
4025                provider = %provider,
4026                mode = ?mode,
4027                "harness MCP overlay installed for spawn",
4028            );
4029            Some(NativeHarnessMcpOverlayGuard { control, instance_id })
4030        } else {
4031            None
4032        };
4033        let prepared_materialization = self.prepare_session_materialization(
4034            &address,
4035            &provider,
4036            mode,
4037            environment_profile,
4038            bundle,
4039            context,
4040            managed_authority.cloned(),
4041        )?;
4042        let (environment_overlay, mut materialization_guard) = match prepared_materialization {
4043            Some((overlay, guard)) => (Some(overlay), Some(guard)),
4044            None => (None, None),
4045        };
4046        let materialization_id = materialization_guard
4047            .as_ref()
4048            .and_then(SessionMaterializationGuard::id)
4049            .cloned();
4050        let mut environment_selection = SpawnPreparer::select_environment(self, 
4051            instance_id,
4052            &provider,
4053            mode,
4054            environment_profile,
4055        )?;
4056        // Station network overlays: Codex `-c network_access` and/or Claude
4057        // ProviderHome `--settings` sidecar (permits → allowedDomains).
4058        let provider_home = materialization_guard
4059            .as_ref()
4060            .and_then(SessionMaterializationGuard::provider_home);
4061        let network_overlay_args = self.resolve_network_allowlist_overlay_args(
4062            &provider,
4063            mode,
4064            approval_level,
4065            network_allowlist,
4066            provider_home,
4067        )?;
4068        let mut network_overlay_args = network_overlay_args;
4069        // Auto-permit (default on): pre-trust cwd / onboarding in provider config + auto/yolo flags.
4070        {
4071            let cwd = self.workspace_root(&address.workspace_id).ok().map(PathBuf::from);
4072            let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
4073            if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") {
4074                homes.extend(std::env::split_paths(&extra));
4075            }
4076            if let Some(h) = provider_home { homes.push(h.to_path_buf()); }
4077            crate::auto_permit::prepare(provider.as_str(), &homes, cwd.as_deref());
4078            network_overlay_args.extend(crate::auto_permit::launch_args(provider.as_str(), mode));
4079        }
4080        if mode == SessionMode::Acp && crate::auto_permit::acp_auto_approve(provider.as_str()) {
4081            crate::auto_permit::register_acp(instance_id.0, provider.as_str());
4082        }
4083        // Session registry: journal the session so it is resumed after a node restart.
4084        if matches!(mode, SessionMode::Pty | SessionMode::Acp) && crate::pty_resume::enabled() && crate::pty_resume::supported(provider.as_str()) {
4085            if let Some(file) = self.session_resume_journal() {
4086                crate::pty_resume::upsert(&file, crate::pty_resume::Entry {
4087                    instance_id: instance_id.0,
4088                    provider: provider.as_str().to_owned(),
4089                    workspace_id: address.workspace_id.as_str().to_owned(),
4090                    provider_session_id: String::new(),
4091                    session_key: self.m4a_keys.lock().unwrap_or_else(|p| p.into_inner()).get(&instance_id).cloned()
4092                        .or_else(|| self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).get(&instance_id).cloned())
4093                        .unwrap_or_else(|| format!("{}-{}", self.incarnation_id, instance_id.0)),
4094                    cwd: String::new(),
4095                    acp: mode == SessionMode::Acp,
4096                    options: SPAWN_SESSION_OPTIONS.try_with(|o| o.clone()).ok().flatten(),
4097                    cols: 0,
4098                    rows: 0,
4099                    origin: SPAWN_ORIGIN.try_with(|o| o.clone()).ok().flatten(),
4100                    parent_session_id: SPAWN_PARENT.try_with(|o| o.clone()).ok().flatten(),
4101                });
4102                if let Some(dir) = file.parent() {
4103                    crate::session_meta::upsert(dir, instance_id.0, crate::session_meta::Meta {
4104                        origin: SPAWN_ORIGIN.try_with(|o| o.clone()).ok().flatten(),
4105                        parent_session_id: SPAWN_PARENT.try_with(|o| o.clone()).ok().flatten(),
4106                        provider: Some(provider.as_str().to_owned()),
4107                        ..crate::session_meta::Meta::default()
4108                    });
4109                    crate::session_meta::revive(dir, instance_id.0);
4110                }
4111                tracing::info!(instance_id = instance_id.0, provider = %provider, "session journalled for restart resume");
4112            }
4113        }
4114        if let Some(socket) = self.session_grok_leader_socket(&provider, mode, instance_id) {
4115            network_overlay_args.extend(crate::grok_leader::leader_socket_args(&socket));
4116        }
4117        let environment_overlay = match (environment_overlay.flatten(), network_overlay_args) {
4118            (prepared, args) if args.is_empty() => prepared,
4119            (Some(PreparedNativeLaunchOverlay::Instance(mut overlay)), args) => {
4120                overlay.append_extra_args(args).map_err(|_| {
4121                    failure(
4122                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4123                        "network allowlist overlay could not merge with instance launch args",
4124                    )
4125                })?;
4126                Some(PreparedNativeLaunchOverlay::Instance(overlay))
4127            }
4128            (Some(PreparedNativeLaunchOverlay::Environment(overlay)), args) => {
4129                Some(PreparedNativeLaunchOverlay::Instance(
4130                    overlay
4131                        .into_instance_with_extra_args(args)
4132                        .map_err(|_| {
4133                            failure(
4134                                NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4135                                "network allowlist overlay could not merge with environment overlay",
4136                            )
4137                        })?,
4138                ))
4139            }
4140            (None, args) => Some(PreparedNativeLaunchOverlay::Instance(
4141                NativeInstanceLaunchOverlay::new(
4142                    provider.clone(),
4143                    match mode {
4144                        SessionMode::Pty => TransportKind::Pty,
4145                        SessionMode::Acp => TransportKind::Acp,
4146                        SessionMode::Inline => TransportKind::Pipe,
4147                    },
4148                    Vec::new(),
4149                    args,
4150                )
4151                .map_err(|_| {
4152                    failure(
4153                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4154                        "network allowlist overlay is invalid for this transport",
4155                    )
4156                })?,
4157            )),
4158        };
4159        // Per-session m4a account: own nick + capability for this PTY child only, on its own
4160        // overlay or appended to the overlay the spawn already carries.
4161        #[cfg(unix)]
4162        // `HATCHERY_M4A_SIGNER_SHELL=1` also gives operator shells an identity (live canaries).
4163        let signer_provider = crate::m4a_accounts::m4a_provider(provider.as_str()).or_else(|| {
4164            (provider.as_str() == "shell" && std::env::var_os("HATCHERY_M4A_SIGNER_SHELL").is_some_and(|v| v == "1")).then_some("shell")
4165        });
4166        #[cfg(unix)]
4167        // A provider session with its own m4a account (MCP server + cap) takes that path; the
4168        // signer handle is for sessions without one (operator shells).
4169        let signer_provider = signer_provider.filter(|p| *p == "shell" || self.m4a_accounts.is_none());
4170        #[cfg(unix)]
4171        let signer_identity = match (mode, self.signer_proxy.get(), signer_provider) {
4172            (SessionMode::Pty, Some(proxy), Some(m4a_provider)) => {
4173                let session_key = format!("{}-{}", self.incarnation_id, instance_id.0);
4174                let uid = unsafe { libc_geteuid() };
4175                let handle = proxy.reserve(&session_key, m4a_provider, uid);
4176                let sock = std::env::var_os(crate::keystore_proxy::SOCKET_ENV).unwrap_or_default();
4177                tracing::info!(provider = %provider, instance_id = ?instance_id, "m4a signer handle reserved at spawn");
4178                Some(vec![
4179                    gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from(crate::keystore_proxy::HANDLE_ENV), value: Some(std::ffi::OsString::from(handle)) },
4180                    gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from(crate::keystore_proxy::SOCKET_ENV), value: Some(sock) },
4181                    gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from("M4A_SESSION_ID"), value: Some(std::ffi::OsString::from(session_key)) },
4182                ])
4183            }
4184            _ => None,
4185        };
4186        #[cfg(not(unix))]
4187        let signer_identity: Option<Vec<gate4agent_catalog::EnvMutation>> = None;
4188        let environment_overlay = match (mode, self.m4a_accounts.as_ref(), crate::m4a_accounts::m4a_provider(provider.as_str()), signer_identity) {
4189            (SessionMode::Pty, _, _, Some(identity)) => {
4190                let merged = match environment_overlay {
4191                    None => NativeInstanceLaunchOverlay::new_session_identity(provider.clone(), identity).map(PreparedNativeLaunchOverlay::Instance),
4192                    Some(PreparedNativeLaunchOverlay::Instance(mut overlay)) => {
4193                        overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
4194                    }
4195                    Some(PreparedNativeLaunchOverlay::Environment(mut overlay)) => {
4196                        overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Environment(overlay))
4197                    }
4198                }
4199                .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a signer handle overlay is invalid"))?;
4200                Some(merged)
4201            }
4202            (SessionMode::Pty | SessionMode::Acp, Some(accounts), Some(m4a_provider), None) => {
4203                let session_key = self.m4a_session_key(instance_id);
4204                self.m4a_session_overlay(accounts, m4a_provider, provider, instance_id, environment_overlay, &session_key, mode)?
4205            }
4206            (SessionMode::Pty, Some(_), None, _) => {
4207                tracing::warn!(provider = %provider, instance_id = ?instance_id, "m4a session account not assigned: provider has no m4a mapping");
4208                environment_overlay
4209            }
4210            _ => environment_overlay,
4211        };
4212        let mut instance_overlay = environment_overlay
4213            .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
4214            .transpose()?
4215            .flatten();
4216        Ok(PreparedSpawn {
4217            instance_overlay,
4218            environment_selection,
4219            materialization_guard,
4220            harness_mcp_overlay,
4221            #[cfg(feature = "dig2-station-probe")]
4222            browser_station_lease_guard,
4223            materialization_id,
4224        })
4225    }
4226}
4227
4228struct SessionRecords {
4229    records: BTreeMap<SessionRecordId, ManagedSessionRecord>,
4230}
4231
4232struct SpawnIdempotencyCache {
4233    entries: BTreeMap<SpawnIdempotencyKey, SpawnIdempotencyEntry>,
4234}
4235
4236struct SpawnIdempotencyEntry {
4237    value: SpawnIdempotencyValue,
4238    expires_at: Instant,
4239}
4240
4241enum SpawnIdempotencyValue {
4242    Standard {
4243        spec: SpawnSpec,
4244        result: Result<ResolvedSpawnReceipt, NodeFailure>,
4245    },
4246    Managed {
4247        request: ManagedWorktreeSpawnRequest,
4248        result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
4249    },
4250    HarnessMcp,
4251}
4252
4253enum DurableManagedSpawnReplayDecision {
4254    Reserved,
4255    PendingLinked,
4256    Committed(ManagedWorktreeSpawnReceipt),
4257}
4258
4259impl SpawnIdempotencyValue {
4260    fn references_context(&self, context_id: &SpawnContextId) -> bool {
4261        match self {
4262            Self::Standard { result, .. } => result
4263                .as_ref()
4264                .ok()
4265                .and_then(|receipt| receipt.context.as_ref()),
4266            Self::Managed { result, .. } => result
4267                .as_ref()
4268                .ok()
4269                .and_then(|receipt| receipt.spawn.context.as_ref()),
4270            Self::HarnessMcp => None,
4271        }
4272        .is_some_and(|receipt| &receipt.id == context_id)
4273    }
4274}
4275
4276#[derive(Clone, Copy)]
4277struct ControllerLease {
4278    connection_id: u64,
4279    expires_at: Instant,
4280}
4281
4282struct NodeEventHistory {
4283    last_sequence: u64,
4284    replay_floor_sequence: u64,
4285    events: VecDeque<NodeEventEnvelope>,
4286    record_providers: BTreeMap<SessionRecordId, AgentId>,
4287    removed_record_providers: BTreeMap<u64, AgentId>,
4288}
4289
4290impl NodeEventHistory {
4291    fn new(record_providers: BTreeMap<SessionRecordId, AgentId>) -> Self {
4292        Self {
4293            last_sequence: 0,
4294            replay_floor_sequence: 1,
4295            events: VecDeque::with_capacity(NODE_EVENT_HISTORY_MAX),
4296            record_providers,
4297            removed_record_providers: BTreeMap::new(),
4298        }
4299    }
4300}
4301
4302impl NodeShared {
4303    /// A node-spawned grok PTY session gets its own leader socket, so its agent backend runs
4304    /// as a descendant of this session's process (with the session's own environment) and
4305    /// never attaches to an operator's shared leader. Same grok home, same login.
4306    fn session_resume_journal(&self) -> Option<PathBuf> {
4307        self.state_path.as_ref().and_then(|p| p.parent()).map(crate::pty_resume::path)
4308    }
4309
4310    /// Node start: resume every journalled session through its provider's resume path,
4311    /// under its old instance number, so HQ shows the same chat.
4312    async fn restore_journalled_sessions(&self) {
4313        let Some(file) = self.session_resume_journal() else { return };
4314        if !crate::pty_resume::enabled() { return; }
4315        for e in crate::pty_resume::load(&file) {
4316            crate::pty_resume::remove(&file, e.instance_id);
4317            // Same m4a session key as before the restart: the session keeps its account and nick.
4318            if !e.session_key.is_empty() {
4319                self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).insert(AgentInstanceId(e.instance_id), e.session_key.clone());
4320            }
4321            let (Ok(ws), Ok(provider)) = (WorkspaceId::new(e.workspace_id.clone()), AgentId::new(e.provider.clone())) else { continue };
4322            if e.acp {
4323                let record_id = {
4324                    let records = self.session_records.lock().unwrap_or_else(|p| p.into_inner());
4325                    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())
4326                };
4327                let cur = self.next_instance_id.load(Ordering::Acquire);
4328                if e.instance_id >= cur { self.next_instance_id.store(e.instance_id, Ordering::Release); }
4329                let size = gate4agent_types::TerminalSize { rows: 40, columns: 120 };
4330                let resumed = match record_id {
4331                    Some(rid) => SPAWN_SESSION_OPTIONS.scope(e.options.clone(), self.resume_session_record(&rid, size, None)).await.map(|(_, a)| a),
4332                    None => Err(failure(NodeFailureCode::SessionRecordNotResumable, "no record")),
4333                };
4334                // An agent without ACP session/load (claude) cannot resume: same number, fresh chat.
4335                let r = match resumed {
4336                    Ok(a) => Ok(a),
4337                    Err(err) => {
4338                        tracing::info!(instance_id = e.instance_id, message = %err.message, "acp resume unavailable; starting fresh under the same number");
4339                        self.next_instance_id.store(e.instance_id.max(self.next_instance_id.load(Ordering::Acquire).min(e.instance_id)), Ordering::Release);
4340                        SPAWN_SESSION_OPTIONS.scope(e.options.clone(), self.spawn_session(ws, provider, SessionMode::Acp, size, None)).await
4341                    }
4342                };
4343                match r {
4344                    Ok(addr) => tracing::info!(instance_id = addr.session.instance_id.0, provider = %e.provider, "journalled acp session restored"),
4345                    Err(err) => tracing::warn!(instance_id = e.instance_id, provider = %e.provider, message = %err.message, "journalled acp session restore failed"),
4346                }
4347                continue;
4348            }
4349            if e.provider_session_id.is_empty() || !provider_session_exists(&e.provider, &e.provider_session_id) {
4350                // Nothing said yet in that chat: start it fresh under the same number.
4351                let cur = self.next_instance_id.load(Ordering::Acquire);
4352                if e.instance_id >= cur { self.next_instance_id.store(e.instance_id, Ordering::Release); }
4353                let size = gate4agent_types::TerminalSize { rows: 40, columns: 120 };
4354                match SPAWN_SESSION_OPTIONS.scope(e.options.clone(), self.spawn_session(ws, provider, SessionMode::Pty, size, None)).await {
4355                    Ok(addr) => tracing::info!(instance_id = addr.session.instance_id.0, provider = %e.provider, "journalled session had no transcript; started fresh"),
4356                    Err(err) => tracing::warn!(instance_id = e.instance_id, provider = %e.provider, message = %err.message, "journalled session restart failed"),
4357                }
4358                continue;
4359            }
4360            let folder = std::path::Path::new(&e.cwd);
4361            let in_session_dir = folder.parent().and_then(|p| p.file_name()).is_some_and(|n| n == ".hatchery-sessions");
4362            let name = match (in_session_dir, folder.file_name()) {
4363                (true, Some(n)) => n.to_string_lossy().into_owned(),
4364                _ => format!("{} #{}", e.provider, e.instance_id),
4365            };
4366            let identity = ProviderSessionIdentity { key: gate4agent_types::ProviderSessionKey::SessionId, id: e.provider_session_id.clone(), transcript_path: None };
4367            let record = match self.index_provider_session(ws, provider, identity, name) {
4368                Ok(r) => r,
4369                Err(err) => { tracing::warn!(instance_id = e.instance_id, message = %err.message, "journalled session not indexed"); continue; }
4370            };
4371            let cur = self.next_instance_id.load(Ordering::Acquire);
4372            if e.instance_id >= cur { self.next_instance_id.store(e.instance_id, Ordering::Release); }
4373            let size = gate4agent_types::TerminalSize { rows: if e.rows > 0 { e.rows } else { 40 }, columns: if e.cols > 0 { e.cols } else { 120 } };
4374            let r = SPAWN_SESSION_OPTIONS.scope(e.options.clone(), self.resume_session_record(&record.record_id, size, None)).await;
4375            match r {
4376                Ok((_, addr)) => tracing::info!(instance_id = addr.session.instance_id.0, provider = %e.provider, "journalled session resumed"),
4377                Err(err) => tracing::warn!(instance_id = e.instance_id, provider = %e.provider, message = %err.message, "journalled session resume failed"),
4378            }
4379        }
4380    }
4381
4382    fn refresh_session_journal(&self) {
4383        let Some(file) = self.session_resume_journal() else { return };
4384        let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
4385        if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") { homes.extend(std::env::split_paths(&extra)); }
4386        crate::pty_resume::refresh(&file, &homes);
4387        let ids: Vec<(u64, String)> = {
4388            let records = self.session_records.lock().unwrap_or_else(|p| p.into_inner());
4389            records.records.values().filter_map(|r| Some((r.active_session.as_ref()?.session.instance_id.0, r.provider_session.as_ref()?.id.clone()))).collect()
4390        };
4391        for (inst, sid) in ids {
4392            crate::pty_resume::set_session_id(&file, inst, &sid);
4393        }
4394    }
4395
4396    fn session_grok_leader_socket(
4397        &self,
4398        provider: &AgentId,
4399        mode: SessionMode,
4400        instance_id: AgentInstanceId,
4401    ) -> Option<std::path::PathBuf> {
4402        if provider.as_str() != "grok" || mode != SessionMode::Pty || crate::grok_leader::disabled() {
4403            return None;
4404        }
4405        let root = self
4406            .m4a_accounts
4407            .as_ref()
4408            .and_then(|a| a.base().parent().map(std::path::Path::to_path_buf))
4409            .unwrap_or_else(std::env::temp_dir);
4410        let socket = crate::grok_leader::socket_path(&root, &format!("{}-{}", self.incarnation_id, instance_id.0));
4411        if let Some(previous) = self
4412            .grok_leaders
4413            .lock()
4414            .unwrap_or_else(|p| p.into_inner())
4415            .insert(instance_id, socket.clone())
4416        {
4417            crate::grok_leader::retire(&previous);
4418        }
4419        Some(socket)
4420    }
4421
4422    /// Stops this session's own grok leader, if it had one. Never touches any other leader.
4423    fn retire_session_grok_leader(&self, instance_id: AgentInstanceId) {
4424        let socket = self.grok_leaders.lock().unwrap_or_else(|p| p.into_inner()).remove(&instance_id);
4425        if let Some(socket) = socket {
4426            crate::grok_leader::retire(&socket);
4427        }
4428    }
4429
4430    fn new_with_incarnation(
4431        handle: Gate4AgentHandle,
4432        access_token: String,
4433        node_id: NodeId,
4434        incarnation_id: NodeIncarnationId,
4435        workspaces: Vec<WorkspaceConfig>,
4436        enabled_providers: Vec<AgentId>,
4437        provider_runtime_statuses: ProviderRuntimeStatuses,
4438        provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
4439        provider_contracts: Vec<ProviderContractSupport>,
4440        provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
4441        spawn_profiles: SpawnProfileRegistry,
4442        native_launch_profile_control: Option<NativeLaunchProfileControl>,
4443        native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
4444        session_environment_materializer: Option<SessionEnvironmentMaterializer>,
4445        state_path: Option<PathBuf>,
4446        records: Vec<ManagedSessionRecord>,
4447        managed_worktrees: Vec<ManagedWorktreeLeaseRecord>,
4448        managed_worktree_tombstones: Vec<ManagedWorktreeLeaseRecord>,
4449        materializations: Vec<MaterializationOwnershipRecord>,
4450        persistence_warning: Option<String>,
4451        input_settle_timeout_ms: u64,
4452    ) -> Self {
4453        let (event_tx, _) = broadcast::channel(NODE_BROADCAST_CAPACITY);
4454        let (terminal_event_tx, _) = broadcast::channel(NODE_TERMINAL_BROADCAST_CAPACITY);
4455        let (agent_stream_event_tx, _) = broadcast::channel(NODE_AGENT_STREAM_BROADCAST_CAPACITY);
4456        let (harness_mcp_proxy_event_tx, _) =
4457            broadcast::channel(NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY);
4458        let record_providers = records
4459            .iter()
4460            .map(|record| (record.record_id.clone(), record.provider.clone()))
4461            .collect();
4462        let mut worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode> = workspaces
4463            .iter()
4464            .map(|workspace| {
4465                (workspace.workspace_id.clone(), workspace.worktree_service_mode)
4466            })
4467            .collect();
4468        for lease in &managed_worktrees {
4469            worktree_service_modes.insert(lease.workspace_id.clone(), WorktreeServiceMode::Off);
4470        }
4471        let managed_worktree_profiles = workspaces.iter()
4472            .map(|workspace| (
4473                workspace.workspace_id.clone(),
4474                workspace.managed_worktree_profiles.clone(),
4475            ))
4476            .collect();
4477        let mut managed_worktrees = ManagedWorktreeRegistry::from_records(
4478            managed_worktrees,
4479            managed_worktree_tombstones,
4480        ).expect("durable managed worktree registry was validated while loading");
4481        managed_worktrees.clear_stale_session_holders(incarnation_id, unix_time_ms());
4482        managed_worktrees.reattach_record_holders(
4483            &managed_worktree_record_holders(&records),
4484            unix_time_ms(),
4485        );
4486        Self {
4487            handle,
4488            access_token,
4489            node_id,
4490            incarnation_id,
4491            started_at_unix_ms: std::time::SystemTime::now()
4492                .duration_since(std::time::UNIX_EPOCH)
4493                .unwrap_or_default()
4494                .as_millis()
4495                .min(u64::MAX as u128) as u64,
4496            workspaces: RwLock::new(
4497                workspaces
4498                    .into_iter()
4499                    .map(|workspace| (workspace.workspace_id, workspace.canonical_root))
4500                    .collect(),
4501            ),
4502            worktree_service_modes,
4503            managed_worktree_profiles,
4504            managed_worktrees: Mutex::new(managed_worktrees),
4505            enabled_providers,
4506            provider_runtime_statuses,
4507            provider_runtime_status_updates: RwLock::new(BTreeMap::new()),
4508            provider_runtime_monitor,
4509            provider_contracts,
4510            provider_adapter_contracts,
4511            spawn_profiles,
4512            environment_profiles: RwLock::new(BTreeMap::new()),
4513            environment_materialization_profiles: RwLock::new(BTreeMap::new()),
4514            network_allowlist_catalog: RwLock::new(crate::network_allowlist_catalog::NetworkAllowlistCatalog::new()),
4515            #[cfg(feature = "dig2-station-probe")]
4516            browser_station_leases: crate::dig2_station_lease::new_lease_table(),
4517            bundle_catalog: RwLock::new(BundleCatalog::default()),
4518            delivery_store: Mutex::new(None),
4519            context_pack_store: Mutex::new(None),
4520            context_catalog: RwLock::new(ContextPackCatalog::default()),
4521            native_launch_profile_control,
4522            harness_mcp_registry: None,
4523            native_session_catalog,
4524            session_environment_materializer,
4525            m4a_accounts: crate::m4a_accounts::M4aSessionAccounts::from_env(),
4526            m4a_live: Mutex::new(BTreeMap::new()),
4527            m4a_keys: Mutex::new(BTreeMap::new()),
4528            m4a_reuse_keys: Mutex::new(BTreeMap::new()),
4529            grok_leaders: Mutex::new(BTreeMap::new()),
4530            #[cfg(unix)]
4531            #[cfg(unix)]
4532            signer_proxy: std::sync::OnceLock::new(),
4533            materializations: Mutex::new(
4534                materializations
4535                    .into_iter()
4536                    .map(|record| (record.id().clone(), record))
4537                    .collect(),
4538            ),
4539            spawn_idempotency: Mutex::new(SpawnIdempotencyCache {
4540                entries: BTreeMap::new(),
4541            }),
4542            managed_spawn_replays: Mutex::new(BTreeMap::new()),
4543            controller: Mutex::new(None),
4544            provisional_spawns: Mutex::new(BTreeMap::new()),
4545            pending_acp_options: Mutex::new(BTreeMap::new()),
4546            history: Mutex::new(NodeEventHistory::new(record_providers)),
4547            event_tx,
4548            terminal_event_tx,
4549            agent_stream_event_tx,
4550            harness_mcp_proxy_event_tx,
4551            terminal_frame_watermarks: Mutex::new(BTreeMap::new()),
4552            pty_input_wake: tokio::sync::Notify::new(),
4553            pty_input_marks: Mutex::new(BTreeMap::new()),
4554            next_connection_id: AtomicU64::new(1),
4555            next_instance_id: AtomicU64::new(1),
4556            next_command_id: AtomicU64::new(1),
4557            shutdown: AtomicBool::new(false),
4558            shutdown_notify: Notify::new(),
4559            preauth_slots: Arc::new(Semaphore::new(MAX_PREAUTH_CONNECTIONS)),
4560            authenticated_slots: Arc::new(Semaphore::new(MAX_AUTHENTICATED_CONNECTIONS)),
4561            inspection_slots: Arc::new(Semaphore::new(WORKSPACE_INSPECTION_MAX_CONCURRENCY)),
4562            mutation_gate: AsyncMutex::new(()),
4563            session_bindings: Mutex::new(BTreeMap::new()),
4564            session_records: Mutex::new(SessionRecords {
4565                records: records
4566                    .into_iter()
4567                    .map(|record| (record.record_id.clone(), record))
4568                    .collect(),
4569            }),
4570            state_path,
4571            persistence_error: RwLock::new(
4572                persistence_warning
4573                    .map(|message| session_registry::sanitized_persistence_summary(&message)),
4574            ),
4575            state_transaction: Mutex::new(()),
4576            input_settle_timeout_ms,
4577            drive_loop_profile: Mutex::new(DriveLoopProfiler::default()),
4578            runtime_tick_profile: Mutex::new(TickProfileSnapshot::default()),
4579            shell_efficiency_profile: OnceLock::new(),
4580            native_session_gauge: AtomicUsize::new(0),
4581            connection_loop_iterations: AtomicU64::new(0),
4582            connection_events_sent: AtomicU64::new(0),
4583            rejected_commands_total: AtomicU64::new(0),
4584            rejected_command_reasons: Mutex::new(BTreeMap::new()),
4585            drive_loop_iterations_total: AtomicU64::new(0),
4586            drive_loop_iterations_idle: AtomicU64::new(0),
4587            session_record_retention: SessionRecordRetentionConfig::default(),
4588            retired_records_total: AtomicU64::new(0),
4589            #[cfg(feature = "fixture")]
4590            fixture_semantic_hook_policy: false,
4591            #[cfg(feature = "fixture")]
4592            fixture_spawn_managed_worktree_v2_failure_probe:
4593                SpawnManagedWorktreeV2FailureProbe::default(),
4594        }
4595    }
4596
4597    #[cfg(test)]
4598    fn new(
4599        handle: Gate4AgentHandle,
4600        access_token: String,
4601        node_id: NodeId,
4602        workspaces: Vec<WorkspaceConfig>,
4603        enabled_providers: Vec<AgentId>,
4604    ) -> Self {
4605        Self::new_with_incarnation(
4606            handle,
4607            access_token,
4608            node_id,
4609            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
4610            workspaces,
4611            enabled_providers,
4612            ProviderRuntimeStatuses::default(),
4613            None,
4614            Vec::new(),
4615            Vec::new(),
4616            SpawnProfileRegistry::default(),
4617            None,
4618            None,
4619            None,
4620            None,
4621            Vec::new(),
4622            Vec::new(),
4623            Vec::new(),
4624            Vec::new(),
4625            None,
4626            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
4627        )
4628    }
4629
4630    fn persistence_error(&self) -> Option<String> {
4631        self.persistence_error
4632            .read()
4633            .unwrap_or_else(|poisoned| poisoned.into_inner())
4634            .clone()
4635    }
4636
4637    async fn admit_provider_runtime(
4638        &self,
4639        provider: &AgentId,
4640        requirement: ProviderRuntimeRequirement,
4641    ) -> Result<ProviderRuntimePolicy, NodeFailure> {
4642        let admission = if let Some(monitor) = self.provider_runtime_monitor.clone() {
4643            let pipe_transport = monitor.supports_pipe_transport(provider);
4644            let acp_transport = monitor.supports_acp_transport(provider);
4645            let provider = provider.clone();
4646            let refresh = tokio::task::spawn_blocking(move || monitor.evaluate(&provider));
4647            let (status, admission) = timeout(
4648                Duration::from_millis(PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS),
4649                refresh,
4650            )
4651            .await
4652            .map_err(|_| failure(
4653                NodeFailureCode::BackendBusy,
4654                "provider runtime probe exceeded its bounded deadline",
4655            ))?
4656            .map_err(|_| failure(
4657                NodeFailureCode::BackendOperationFailed,
4658                "provider runtime probe task failed",
4659            ))?;
4660            if let Some(status) = status {
4661                self
4662                    .provider_runtime_status_updates
4663                    .write()
4664                    .unwrap_or_else(|poisoned| poisoned.into_inner())
4665                    .insert(status.provider().clone(), status);
4666            }
4667            admission.and_then(|policy| {
4668                require_policy(policy, pipe_transport, acp_transport, requirement).map(|()| policy)
4669            })
4670        } else {
4671            // No live monitor means no catalog to consult -- there is
4672            // nothing here to confirm this provider declares a Pipe or ACP
4673            // transport, so Inline/Acp are rejected the same conservative
4674            // way an undeclared provider always was, rather than admitted
4675            // on trust.
4676            crate::provider_runtime::admit_status(
4677                &self.provider_runtime_statuses,
4678                provider,
4679                false,
4680                false,
4681                requirement,
4682            )
4683        };
4684        let admission = admission.map(|policy| self.admit_declared_hook_semantics(provider, policy));
4685        admission.map_err(|error| match error {
4686            ProviderRuntimeAdmissionError::LauncherUnavailable => failure(
4687                NodeFailureCode::BackendOperationFailed,
4688                "provider launcher is unavailable",
4689            ),
4690            ProviderRuntimeAdmissionError::SemanticCapabilityUnverified => failure(
4691                NodeFailureCode::UnsupportedCapability,
4692                "provider semantic capability is not verified",
4693            ),
4694            ProviderRuntimeAdmissionError::ProbeBusy => failure(
4695                NodeFailureCode::BackendBusy,
4696                "provider runtime probe is already in progress",
4697            ),
4698        })
4699    }
4700
4701    /// Whether the catalog declares a Pipe transport for `provider` --
4702    /// the fact `require_policy` needs to admit or reject
4703    /// `ProviderRuntimeRequirement::Inline`. With no live monitor there is no
4704    /// catalog to consult, so this stays conservative and rejects, matching
4705    /// `admit_provider_runtime`'s own no-monitor branch.
4706    fn provider_supports_pipe_transport(&self, provider: &AgentId) -> bool {
4707        match self.provider_runtime_monitor.as_deref() {
4708            Some(monitor) => monitor.supports_pipe_transport(provider),
4709            None => false,
4710        }
4711    }
4712
4713    /// Whether the catalog declares an ACP transport for `provider` -- the
4714    /// fact `require_policy` needs to admit or reject
4715    /// `ProviderRuntimeRequirement::Acp`. Mirrors
4716    /// `provider_supports_pipe_transport` exactly, including the
4717    /// conservative no-monitor default.
4718    fn provider_supports_acp_transport(&self, provider: &AgentId) -> bool {
4719        match self.provider_runtime_monitor.as_deref() {
4720            Some(monitor) => monitor.supports_acp_transport(provider),
4721            None => false,
4722        }
4723    }
4724
4725    /// Grants hook ingestion to a provider whose catalog entry declares a
4726    /// Hook adapter, whatever runtime mode its launcher probe landed in.
4727    ///
4728    /// `admit_status`'s `RawPassthrough` arm hands back
4729    /// `ProviderRuntimePolicy::raw_pty()`, which grants nothing semantic --
4730    /// correct for everything it was written to answer, and wrong for hooks
4731    /// specifically. A hook event is not inferred from terminal behaviour,
4732    /// so it has nothing to do with whether a launcher probe verified this
4733    /// CLI version's PTY contract; the authority is the catalog declaring
4734    /// the adapter plus the ingress authenticating the route. Without this
4735    /// the node installs a provider's hooks, the CLI faithfully calls them,
4736    /// and every event is refused -- which is exactly the state grok was in.
4737    ///
4738    /// Applied to both admission paths so the monitor's own derivation and
4739    /// this coarse fallback cannot disagree about the same provider.
4740    fn admit_declared_hook_semantics(
4741        &self,
4742        provider: &AgentId,
4743        policy: ProviderRuntimePolicy,
4744    ) -> ProviderRuntimePolicy {
4745        if policy.hook_semantics || !policy.raw_pty_lifecycle {
4746            return policy;
4747        }
4748        let declares_hook = self.provider_adapter_contracts.iter().any(|contract| {
4749            contract.provider == *provider && contract.family == AdapterFamily::Hook
4750        });
4751        if !declares_hook {
4752            return policy;
4753        }
4754        ProviderRuntimePolicy::new(
4755            policy.raw_pty_lifecycle,
4756            policy.semantic_readiness,
4757            policy.structured_prompt,
4758            policy.provider_session_identity,
4759            policy.semantic_resume,
4760            true,
4761        )
4762        .expect("granting hook semantics over a raw-PTY policy is internally valid")
4763    }
4764
4765    /// Station network allowlist → provider launch overlay argv.
4766    ///
4767    /// - **Codex**: first-slice `-c sandbox_workspace_write.network_access` from
4768    ///   `provider_native` under Moderate (PTY or ACP).
4769    /// - **Claude**: catalog `permits` → ProviderHome sidecar settings JSON +
4770    ///   documented `--settings <path>` on **PTY** only (Linux/macOS/WSL2).
4771    ///   ACP refuses (no measured ACP settings-path surface). Native Windows
4772    ///   refuses (no vendor Bash sandbox). Membership-only (empty permits) =
4773    ///   no overlay.
4774    /// - **Kimi / Grok**: no first-party settings/argv network surface here —
4775    ///   empty overlay when no `provider_native`; invented native keys already
4776    ///   refuse at load / resolve.
4777    ///
4778    /// Empty when no allowlist / nothing to map. Never cookies / secrets.
4779    fn resolve_network_allowlist_overlay_args(
4780        &self,
4781        provider: &AgentId,
4782        mode: SessionMode,
4783        approval_level: ApprovalLevel,
4784        network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4785        provider_home: Option<&Path>,
4786    ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4787        let Some(allowlist_id) = network_allowlist else {
4788            return Ok(Vec::new());
4789        };
4790        let catalog = self
4791            .network_allowlist_catalog
4792            .read()
4793            .unwrap_or_else(|poisoned| poisoned.into_inner());
4794        let Some(entry) = catalog.get(allowlist_id) else {
4795            return Err(failure(
4796                NodeFailureCode::UnknownNetworkAllowlist,
4797                "spawn network allowlist is unavailable on this node",
4798            ));
4799        };
4800
4801        // Claude: settings-shaped overlay from permits (not provider_native).
4802        if provider.as_str() == "claude" && !entry.permits.is_empty() {
4803            crate::network_allowlist_catalog::claude_bash_sandbox_network_os_supported().map_err(
4804                |message| {
4805                    failure(
4806                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4807                        &message,
4808                    )
4809                },
4810            )?;
4811            if !matches!(mode, SessionMode::Pty) {
4812                return Err(failure(
4813                    NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4814                    "Claude sandbox.network --settings overlay requires PTY transport; ACP has no measured settings-path surface (refuse rather than invent RPC)",
4815                ));
4816            }
4817            let Some(home) = provider_home else {
4818                return Err(failure(
4819                    NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4820                    "Claude sandbox.network --settings overlay requires ProviderHome materialization (CLAUDE_CONFIG_DIR)",
4821                ));
4822            };
4823            let domains =
4824                crate::network_allowlist_catalog::claude_allowed_domains_from_permits(&entry.permits);
4825            if domains.is_empty() {
4826                return Err(failure(
4827                    NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4828                    "Claude sandbox.network overlay could not map catalog permits to allowedDomains",
4829                ));
4830            }
4831            let settings_path = home.join(
4832                crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
4833            );
4834            let json =
4835                crate::network_allowlist_catalog::claude_station_network_settings_json(&domains);
4836            crate::session_environment::secure_create_file(&settings_path, json.as_bytes()).map_err(
4837                |_| {
4838                    failure(
4839                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4840                        "Claude station network settings sidecar could not be written under ProviderHome",
4841                    )
4842                },
4843            )?;
4844            let overlay =
4845                crate::network_allowlist_catalog::claude_settings_network_overlay_args(&settings_path);
4846            return Ok(overlay.into_iter().map(std::ffi::OsString::from).collect());
4847        }
4848
4849        let Some(native) = entry.provider_native.as_ref() else {
4850            return Ok(Vec::new());
4851        };
4852        let overlay = crate::network_allowlist_catalog::resolve_provider_native_launch_overlay(
4853            provider.as_str(),
4854            approval_level,
4855            native,
4856        )
4857        .map_err(|message| {
4858            failure(
4859                NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4860                &message,
4861            )
4862        })?;
4863        if overlay.is_empty() {
4864            return Ok(Vec::new());
4865        }
4866        if !matches!(mode, SessionMode::Pty | SessionMode::Acp) {
4867            return Err(failure(
4868                NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4869                "Codex network_access -c overlay requires PTY or ACP transport",
4870            ));
4871        }
4872        Ok(overlay.into_iter().map(std::ffi::OsString::from).collect())
4873    }
4874
4875    /// Backward-compatible alias used by Codex-focused unit tests.
4876    fn resolve_codex_network_access_overlay_args(
4877        &self,
4878        provider: &AgentId,
4879        mode: SessionMode,
4880        approval_level: ApprovalLevel,
4881        network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4882    ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4883        self.resolve_network_allowlist_overlay_args(
4884            provider,
4885            mode,
4886            approval_level,
4887            network_allowlist,
4888            None,
4889        )
4890    }
4891
4892    fn resolve_environment_profile(
4893        &self,
4894        resolved: &ResolvedSpawnSpec,
4895    ) -> Result<Option<ResolvedEnvironmentProfileReceipt>, NodeFailure> {
4896        // Station network allowlist catalog enforce (plan
4897        // station-network-and-browser-profile-knobs §2.1 / §4): empty-default
4898        // catalog — unknown ids refuse rather than silent ambient. Runs even
4899        // when no environment_profile_id is present so a bare allowlist still
4900        // cannot bypass the catalog. Never cookies / OAuth / proxy credentials.
4901        if let Some(allowlist_id) = resolved.network_allowlist.as_ref() {
4902            let catalog = self
4903                .network_allowlist_catalog
4904                .read()
4905                .unwrap_or_else(|poisoned| poisoned.into_inner());
4906            let Some(entry) = catalog.get(allowlist_id) else {
4907                return Err(failure(
4908                    NodeFailureCode::UnknownNetworkAllowlist,
4909                    "spawn network allowlist is unavailable on this node",
4910                ));
4911            };
4912            // Track B: refuse provider-native mappings this provider cannot
4913            // honor (e.g. codex_network_access on Claude). Never silent ambient.
4914            if let Some(native) = entry.provider_native.as_ref() {
4915                if let Err(message) = crate::network_allowlist_catalog::provider_native_mapping_supported(
4916                    resolved.provider.as_str(),
4917                    resolved.approval_level,
4918                    native,
4919                ) {
4920                    return Err(failure(
4921                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4922                        &message,
4923                    ));
4924                }
4925            }
4926        }
4927        // Station knobs Track A (dig2browser-station-probe-and-network-permit-set):
4928        // when feature `dig2-station-probe` is on and browser_profile_id is set,
4929        // cheap named-pipe path reachability must succeed (Windows
4930        // `\\.\pipe\{suffix}`); platform-unsupported or unreachable → clear
4931        // NodeFailureCode. Runs even without environment_profile_id (same
4932        // placement spirit as allowlist catalog). Feature off keeps stub echo.
4933        // Never cookies / OAuth / proxy credentials. Exclusive lease is
4934        // acquired later in spawn_session_with_deadline (after instance id).
4935        #[cfg(feature = "dig2-station-probe")]
4936        if resolved.browser_profile_id.is_some() {
4937            use crate::dig2_station_probe::{probe_station_reachable, StationProbeError};
4938            match probe_station_reachable() {
4939                Ok(()) => {}
4940                Err(StationProbeError::UnsupportedPlatform)
4941                | Err(StationProbeError::InvalidPipeSuffix) => {
4942                    return Err(failure(
4943                        NodeFailureCode::BrowserStationProbeUnavailable,
4944                        "dig2browser station probe unavailable on this platform",
4945                    ));
4946                }
4947                Err(StationProbeError::Unreachable) => {
4948                    return Err(failure(
4949                        NodeFailureCode::BrowserStationUnreachable,
4950                        "dig2browser station named-pipe is unreachable",
4951                    ));
4952                }
4953            }
4954        }
4955        let Some(profile_id) = resolved.environment_profile_id.as_ref() else {
4956            return Ok(None);
4957        };
4958        let profiles = self
4959            .environment_profiles
4960            .read()
4961            .unwrap_or_else(|poisoned| poisoned.into_inner());
4962        let binding = profiles.get(profile_id).ok_or_else(|| {
4963            failure(
4964                NodeFailureCode::UnknownEnvironmentProfile,
4965                "spawn environment profile is unavailable on this node",
4966            )
4967        })?;
4968        if binding.provider != resolved.provider
4969            || binding.native_profile_id(resolved.mode).is_none()
4970        {
4971            return Err(failure(
4972                NodeFailureCode::EnvironmentProfileBindingMismatch,
4973                "spawn environment profile does not match the provider and mode",
4974            ));
4975        }
4976        // Echo opaque network_allowlist / browser_profile_id onto the receipt
4977        // after catalog + optional dig2 probe above. Empty/whitespace ids
4978        // already refuse at Spawn*Id construction.
4979        Ok(Some(ResolvedEnvironmentProfileReceipt {
4980            profile_id: binding.id.clone(),
4981            profile_revision: binding.revision.clone(),
4982            network_allowlist: resolved.network_allowlist.clone(),
4983            browser_profile_id: resolved.browser_profile_id.clone(),
4984        }))
4985    }
4986
4987    fn resolve_bundle(
4988        &self,
4989        resolved: &ResolvedSpawnSpec,
4990        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4991    ) -> Result<Option<ResolvedBundleReceipt>, NodeFailure> {
4992        let Some(bundle_id) = resolved.bundle_id.as_ref() else {
4993            return Ok(None);
4994        };
4995        let catalog = self.bundle_catalog
4996            .read()
4997            .unwrap_or_else(|poisoned| poisoned.into_inner());
4998        let bundle = catalog.get(bundle_id).ok_or_else(|| {
4999                failure(
5000                    NodeFailureCode::UnknownBundle,
5001                    "spawn bundle is unavailable on this node",
5002                )
5003            })?;
5004        self.bundle_layout(
5005            &resolved.provider,
5006            resolved.mode,
5007            environment_profile,
5008            bundle,
5009        )?;
5010        Ok(Some(bundle.receipt()))
5011    }
5012
5013    fn begin_delivery_stage(
5014        &self,
5015        manifest: DeliveryBundleManifestV2,
5016    ) -> Result<NodeResponse, NodeFailure> {
5017        let mut store = self
5018            .delivery_store
5019            .lock()
5020            .unwrap_or_else(|poisoned| poisoned.into_inner());
5021        let store = store.as_mut().ok_or_else(|| {
5022            failure(
5023                NodeFailureCode::DeliveryStageStorageFailed,
5024                "node delivery store is unavailable",
5025            )
5026        })?;
5027        let begun = store.begin(manifest).map_err(delivery_failure)?;
5028        Ok(NodeResponse::DeliveryStageBegun {
5029            stage_id: begun.stage_id,
5030            manifest_digest: begun.manifest_digest,
5031            missing_blobs: begun.missing_blobs,
5032        })
5033    }
5034
5035    fn put_delivery_blob_chunk(
5036        &self,
5037        stage_id: DeliveryStageId,
5038        blob_digest: DeliveryBlobDigestV1,
5039        offset: u64,
5040        chunk_hex: DeliveryBlobChunkHexV1,
5041    ) -> Result<NodeResponse, NodeFailure> {
5042        let mut store = self
5043            .delivery_store
5044            .lock()
5045            .unwrap_or_else(|poisoned| poisoned.into_inner());
5046        let store = store.as_mut().ok_or_else(|| {
5047            failure(
5048                NodeFailureCode::DeliveryStageStorageFailed,
5049                "node delivery store is unavailable",
5050            )
5051        })?;
5052        let accepted = store
5053            .put_chunk(&stage_id, &blob_digest, offset, &chunk_hex.decode())
5054            .map_err(delivery_failure)?;
5055        Ok(NodeResponse::DeliveryBlobChunkAccepted {
5056            stage_id: accepted.stage_id,
5057            blob_digest: accepted.blob_digest,
5058            next_offset: accepted.next_offset,
5059        })
5060    }
5061
5062    fn commit_delivery_stage(
5063        &self,
5064        stage_id: DeliveryStageId,
5065    ) -> Result<DeliveryCommitReceiptV1, NodeFailure> {
5066        let mut store = self
5067            .delivery_store
5068            .lock()
5069            .unwrap_or_else(|poisoned| poisoned.into_inner());
5070        let store = store.as_mut().ok_or_else(|| {
5071            failure(
5072                NodeFailureCode::DeliveryStageStorageFailed,
5073                "node delivery store is unavailable",
5074            )
5075        })?;
5076        let prepared = store.prepare_commit(&stage_id).map_err(delivery_failure)?;
5077        let mut catalog = self
5078            .bundle_catalog
5079            .write()
5080            .unwrap_or_else(|poisoned| poisoned.into_inner());
5081        let mut next_catalog = catalog.clone();
5082        next_catalog
5083            .insert_idempotent(prepared.bundle().clone())
5084            .map_err(|_| {
5085                failure(
5086                    NodeFailureCode::DeliveryStageConflict,
5087                    "delivery bundle identity conflicts with installed content",
5088                )
5089            })?;
5090        let receipt = store.publish_commit(prepared).map_err(delivery_failure)?;
5091        *catalog = next_catalog;
5092        Ok(receipt)
5093    }
5094
5095    fn abort_delivery_stage(
5096        &self,
5097        stage_id: &DeliveryStageId,
5098    ) -> Result<(), NodeFailure> {
5099        let mut store = self
5100            .delivery_store
5101            .lock()
5102            .unwrap_or_else(|poisoned| poisoned.into_inner());
5103        let store = store.as_mut().ok_or_else(|| {
5104            failure(
5105                NodeFailureCode::DeliveryStageStorageFailed,
5106                "node delivery store is unavailable",
5107            )
5108        })?;
5109        store.abort(stage_id).map_err(delivery_failure)
5110    }
5111
5112    fn resolve_context(
5113        &self,
5114        resolved: &ResolvedSpawnSpec,
5115    ) -> Result<Option<ResolvedContextPackReceipt>, NodeFailure> {
5116        let Some(context_id) = resolved.context_id.as_ref() else {
5117            return Ok(None);
5118        };
5119        self.context_catalog
5120            .read()
5121            .unwrap_or_else(|poisoned| poisoned.into_inner())
5122            .get(context_id)
5123            .map(|context| context.receipt().clone())
5124            .ok_or_else(|| {
5125                failure(
5126                    NodeFailureCode::UnknownContextPack,
5127                    "spawn context pack is unavailable on this node",
5128                )
5129            })
5130            .map(Some)
5131    }
5132
5133    fn bundle_layout(
5134        &self,
5135        provider: &AgentId,
5136        mode: SessionMode,
5137        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5138        bundle: &NodeBundle,
5139    ) -> Result<BundleProviderLayout, NodeFailure> {
5140        let layout = validate_bundle_binding(provider, mode, bundle).map_err(|_| {
5141            failure(
5142                NodeFailureCode::BundleBindingMismatch,
5143                "spawn bundle does not match the provider and mode",
5144            )
5145        })?;
5146        if layout != BundleProviderLayout::Codex {
5147            return Ok(layout);
5148        }
5149        let profile = environment_profile.and_then(|receipt| {
5150            let profiles = self
5151                .environment_profiles
5152                .read()
5153                .unwrap_or_else(|poisoned| poisoned.into_inner());
5154            let binding = profiles.get(&receipt.profile_id)?;
5155            if binding.revision != receipt.profile_revision
5156                || &binding.provider != provider
5157                || binding.native_profile_id(mode).is_none()
5158            {
5159                return None;
5160            }
5161            self.environment_materialization_profiles
5162                .read()
5163                .unwrap_or_else(|poisoned| poisoned.into_inner())
5164                .get(&receipt.profile_id)
5165                .cloned()
5166        });
5167        if !profile.is_some_and(|profile| profile.supports_bundle_layout(layout)) {
5168            return Err(failure(
5169                NodeFailureCode::BundleBindingMismatch,
5170                "Codex bundle requires an exact isolated CODEX_HOME profile",
5171            ));
5172        }
5173        Ok(layout)
5174    }
5175
5176    fn select_environment_profile(
5177        &self,
5178        instance_id: AgentInstanceId,
5179        provider: &AgentId,
5180        mode: SessionMode,
5181        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5182    ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
5183        let Some(environment_profile) = environment_profile else {
5184            return Ok(None);
5185        };
5186        let native_profile_id = {
5187            let profiles = self
5188                .environment_profiles
5189                .read()
5190                .unwrap_or_else(|poisoned| poisoned.into_inner());
5191            let binding = profiles
5192                .get(&environment_profile.profile_id)
5193                .ok_or_else(|| {
5194                    failure(
5195                        NodeFailureCode::UnknownEnvironmentProfile,
5196                        "session environment profile is unavailable on this node",
5197                    )
5198                })?;
5199            if &binding.revision != &environment_profile.profile_revision
5200                || &binding.provider != provider
5201            {
5202                return Err(failure(
5203                    NodeFailureCode::EnvironmentProfileBindingMismatch,
5204                    "session environment profile revision or provider changed",
5205                ));
5206            }
5207            binding.native_profile_id(mode).cloned().ok_or_else(|| {
5208                failure(
5209                    NodeFailureCode::EnvironmentProfileBindingMismatch,
5210                    "session environment profile does not support this mode",
5211                )
5212            })?
5213        };
5214        let control = self.native_launch_profile_control.clone().ok_or_else(|| {
5215            failure(
5216                NodeFailureCode::BackendOperationFailed,
5217                "native environment profile controller is unavailable",
5218            )
5219        })?;
5220        control
5221            .select_native_launch_profile(instance_id, native_profile_id)
5222            .map_err(|_| {
5223                failure(
5224                    NodeFailureCode::BackendOperationFailed,
5225                    "native environment profile selection failed",
5226                )
5227            })?;
5228        Ok(Some(NativeEnvironmentSelectionGuard {
5229            control: Some(control),
5230            instance_id,
5231        }))
5232    }
5233
5234    fn install_prepared_launch_overlay(
5235        &self,
5236        instance_id: AgentInstanceId,
5237        overlay: PreparedNativeLaunchOverlay,
5238    ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
5239        let control = self.native_launch_profile_control.clone().ok_or_else(|| {
5240            failure(
5241                NodeFailureCode::BackendOperationFailed,
5242                "native launch profile controller is unavailable",
5243            )
5244        })?;
5245        match overlay {
5246            PreparedNativeLaunchOverlay::Environment(overlay) => {
5247                control
5248                    .install_native_launch_environment_overlay(instance_id, overlay)
5249                    .map_err(|_| failure(
5250                        NodeFailureCode::BackendOperationFailed,
5251                        "session environment overlay installation failed",
5252                    ))?;
5253                Ok(None)
5254            }
5255            PreparedNativeLaunchOverlay::Instance(overlay) => {
5256                control
5257                    .install_native_instance_launch_overlay(instance_id, overlay)
5258                    .map_err(|_| failure(
5259                        NodeFailureCode::BundleBindingMismatch,
5260                        "session bundle launch overlay installation failed",
5261                    ))?;
5262                Ok(Some(NativeInstanceOverlayGuard {
5263                    control: Some(control),
5264                    instance_id,
5265                }))
5266            }
5267        }
5268    }
5269
5270    fn environment_materialization_profile(
5271        &self,
5272        environment_profile: &ResolvedEnvironmentProfileReceipt,
5273    ) -> Option<NodeSessionMaterializationProfile> {
5274        self.environment_materialization_profiles
5275            .read()
5276            .unwrap_or_else(|poisoned| poisoned.into_inner())
5277            .get(&environment_profile.profile_id)
5278            .cloned()
5279    }
5280
5281    fn materialization_profile(
5282        &self,
5283        provider: &AgentId,
5284        mode: SessionMode,
5285        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5286        bundle: Option<&ResolvedBundleReceipt>,
5287        context: Option<&ResolvedContextPackReceipt>,
5288    ) -> Result<Option<(
5289        NodeSessionMaterializationProfile,
5290        bool,
5291        Option<BundleProviderLayout>,
5292    )>, NodeFailure> {
5293        let environment = environment_profile
5294            .and_then(|receipt| self.environment_materialization_profile(receipt));
5295        let (bundle, bundle_layout) = match bundle {
5296            Some(receipt) => {
5297                let catalog = self.bundle_catalog.read()
5298                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5299                let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
5300                    NodeFailureCode::UnknownBundle,
5301                    "session bundle is unavailable on this node",
5302                ))?;
5303                if installed.receipt() != *receipt {
5304                    return Err(failure(
5305                        NodeFailureCode::BundleBindingMismatch,
5306                        "session bundle revision or digest changed",
5307                    ));
5308                }
5309                let layout = self.bundle_layout(
5310                    provider,
5311                    mode,
5312                    environment_profile,
5313                    installed,
5314                )?;
5315                (Some(installed.clone()), Some(layout))
5316            }
5317            None => (None, None),
5318        };
5319        let has_environment_overlay = environment.is_some() || context.is_some();
5320        let profile = match (environment, bundle.as_ref(), bundle_layout) {
5321            (None, None, None) => None,
5322            (Some(profile), None, None) => Some(Ok(profile)),
5323            (None, Some(bundle), Some(layout)) => {
5324                Some(NodeSessionMaterializationProfile::from_bundle(bundle, layout))
5325            }
5326            (Some(profile), Some(bundle), Some(layout)) => {
5327                Some(profile.with_bundle(bundle, layout))
5328            }
5329            _ => {
5330                return Err(failure(
5331                    NodeFailureCode::BundleBindingMismatch,
5332                    "session bundle provider layout is unavailable",
5333                ));
5334            }
5335        };
5336        let profile = profile
5337            .transpose()
5338            .map_err(|_| failure(
5339                NodeFailureCode::BundleMaterializationFailed,
5340                "session bundle materialization profile is invalid",
5341            ))?;
5342        let profile = match (profile, context) {
5343            (Some(profile), Some(receipt)) => {
5344                let catalog = self.context_catalog
5345                    .read()
5346                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5347                let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
5348                    NodeFailureCode::UnknownContextPack,
5349                    "session context pack is unavailable on this node",
5350                ))?;
5351                if pack.receipt() != receipt {
5352                    return Err(failure(
5353                        NodeFailureCode::ContextPackMaterializationFailed,
5354                        "session context pack identity changed",
5355                    ));
5356                }
5357                profile.with_context(pack)
5358            }
5359            (None, Some(receipt)) => {
5360                let catalog = self.context_catalog
5361                    .read()
5362                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5363                let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
5364                    NodeFailureCode::UnknownContextPack,
5365                    "session context pack is unavailable on this node",
5366                ))?;
5367                if pack.receipt() != receipt {
5368                    return Err(failure(
5369                        NodeFailureCode::ContextPackMaterializationFailed,
5370                        "session context pack identity changed",
5371                    ));
5372                }
5373                NodeSessionMaterializationProfile::from_context(pack)
5374            }
5375            (Some(profile), None) => Ok(profile),
5376            (None, None) => return Ok(None),
5377        }
5378        .map_err(|_| failure(
5379            NodeFailureCode::ContextPackMaterializationFailed,
5380            "session context pack materialization profile is invalid",
5381        ))?;
5382        Ok(Some((profile, has_environment_overlay, bundle_layout)))
5383    }
5384
5385    fn prepare_session_materialization<'a>(
5386        &'a self,
5387        address: &SessionAddress,
5388        provider: &AgentId,
5389        mode: SessionMode,
5390        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5391        bundle: Option<&ResolvedBundleReceipt>,
5392        context: Option<&ResolvedContextPackReceipt>,
5393        managed_lease_id: Option<ManagedWorktreeLeaseId>,
5394    ) -> Result<Option<(Option<PreparedNativeLaunchOverlay>, SessionMaterializationGuard<'a>)>, NodeFailure> {
5395        let Some((profile, has_environment_overlay, bundle_layout)) =
5396            self.materialization_profile(provider, mode, environment_profile, bundle, context)?
5397        else {
5398            return Ok(None);
5399        };
5400        let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5401            failure(
5402                NodeFailureCode::BackendOperationFailed,
5403                "session environment materializer is unavailable",
5404            )
5405        })?;
5406        let materialization_failure_code = if context.is_some() {
5407            NodeFailureCode::ContextPackMaterializationFailed
5408        } else if bundle.is_some() {
5409            NodeFailureCode::BundleMaterializationFailed
5410        } else {
5411            NodeFailureCode::BackendOperationFailed
5412        };
5413        let id = MaterializationId::new(format!(
5414            "mat-{}-{}-{}",
5415            self.incarnation_id,
5416            address.session.instance_id.0,
5417            address.session.generation.0,
5418        ))
5419        .map_err(|_| {
5420            failure(
5421                NodeFailureCode::BackendOperationFailed,
5422                "session environment materialization identity failed",
5423            )
5424        })?;
5425        let owner = MaterializationOwner::Session {
5426            incarnation_id: self.incarnation_id,
5427            instance_id: address.session.instance_id,
5428            generation: address.session.generation,
5429        };
5430        let mut ownership = materializer
5431            .begin(
5432                id.clone(),
5433                environment_profile.cloned(),
5434                bundle.cloned(),
5435                context.cloned(),
5436                owner,
5437                managed_lease_id,
5438                &profile,
5439                unix_time_ms(),
5440            )
5441            .map_err(|_| {
5442                failure(
5443                    materialization_failure_code,
5444                    "session materialization preparation failed",
5445                )
5446            })?;
5447        {
5448            let _transaction = self
5449                .state_transaction
5450                .lock()
5451                .unwrap_or_else(|poisoned| poisoned.into_inner());
5452            let duplicate = self
5453                .materializations
5454                .lock()
5455                .unwrap_or_else(|poisoned| poisoned.into_inner())
5456                .insert(id.clone(), ownership.clone());
5457            if duplicate.is_some() {
5458                return Err(failure(
5459                    NodeFailureCode::BackendOperationFailed,
5460                    "session environment materialization identity conflict",
5461                ));
5462            }
5463            if let Err(error) = self.persist_state_locked() {
5464                self.materializations
5465                    .lock()
5466                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5467                    .remove(&id);
5468                return Err(persistence_failure(error));
5469            }
5470        }
5471        let environment = match materializer.materialize(&mut ownership, &profile, unix_time_ms()) {
5472            Ok(environment) => environment,
5473            Err(_) => {
5474                let transaction = self
5475                    .state_transaction
5476                    .lock()
5477                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5478                self.materializations
5479                    .lock()
5480                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5481                    .insert(id.clone(), ownership.clone());
5482                if self.persist_state_locked().is_err() {
5483                    self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5484                }
5485                drop(transaction);
5486                if ownership.state() == MaterializationState::CleanupRequired {
5487                    let _ = self.cleanup_materialization(&id);
5488                }
5489                return Err(failure(
5490                    materialization_failure_code,
5491                    "session materialization failed",
5492                ));
5493            }
5494        };
5495        {
5496            let _transaction = self
5497                .state_transaction
5498                .lock()
5499                .unwrap_or_else(|poisoned| poisoned.into_inner());
5500            self.materializations
5501                .lock()
5502                .unwrap_or_else(|poisoned| poisoned.into_inner())
5503                .insert(id.clone(), ownership.clone());
5504            if let Err(error) = self.persist_state_locked() {
5505                drop(_transaction);
5506                let _ = self.cleanup_materialization(&id);
5507                return Err(persistence_failure(error));
5508            }
5509        }
5510        let guard = SessionMaterializationGuard {
5511            shared: self,
5512            id: Some(id),
5513            provider_home: Some(ownership.provider_home().to_path_buf()),
5514        };
5515        let bundle_arguments = match bundle {
5516            Some(receipt) => {
5517                let catalog = self.bundle_catalog.read()
5518                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5519                let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
5520                    NodeFailureCode::UnknownBundle,
5521                    "session bundle is unavailable on this node",
5522                ))?;
5523                if installed.receipt() != *receipt {
5524                    return Err(failure(
5525                        NodeFailureCode::BundleBindingMismatch,
5526                        "session bundle revision or digest changed",
5527                    ));
5528                }
5529                let layout = bundle_layout.ok_or_else(|| failure(
5530                    NodeFailureCode::BundleBindingMismatch,
5531                    "materialized bundle provider layout is unavailable",
5532                ))?;
5533                materializer.revalidate_bundle(&ownership, installed, layout).map_err(|_| {
5534                    failure(
5535                        NodeFailureCode::BundleMaterializationFailed,
5536                        "session bundle byte revalidation failed",
5537                    )
5538                })?;
5539                Some(bundle_launch_arguments(
5540                    layout,
5541                    ownership.bundle_root(),
5542                    ownership.provider_home(),
5543                ).map_err(|_| failure(
5544                    NodeFailureCode::BundleBindingMismatch,
5545                    "session bundle launch binding is invalid",
5546                ))?)
5547            }
5548            None => None,
5549        };
5550        if let Some(receipt) = context {
5551            materializer
5552                .revalidate_context(&ownership, receipt)
5553                .map_err(|_| failure(
5554                    NodeFailureCode::ContextPackMaterializationFailed,
5555                    "session context pack byte revalidation failed",
5556                ))?;
5557        }
5558        let transport = match mode {
5559            SessionMode::Pty => TransportKind::Pty,
5560            SessionMode::Inline => TransportKind::Pipe,
5561            SessionMode::Acp => TransportKind::Acp,
5562        };
5563        let overlay = if let Some(extra_args) = bundle_arguments {
5564            Some(PreparedNativeLaunchOverlay::Instance(
5565                NativeInstanceLaunchOverlay::new(
5566                    provider.clone(),
5567                    transport,
5568                    if has_environment_overlay {
5569                        environment
5570                    } else {
5571                        Vec::new()
5572                    },
5573                    extra_args,
5574                )
5575                .map_err(|_| {
5576                    failure(
5577                        NodeFailureCode::BundleBindingMismatch,
5578                        "session bundle launch overlay is invalid",
5579                    )
5580                })?,
5581            ))
5582        } else if has_environment_overlay {
5583            let context_only = environment_profile.is_none()
5584                && bundle.is_none()
5585                && context.is_some();
5586            let overlay = if context_only {
5587                NativeLaunchEnvironmentOverlay::new_context_root(
5588                    provider.clone(),
5589                    transport,
5590                    environment,
5591                )
5592                .map_err(|_| {
5593                    failure(
5594                        NodeFailureCode::ContextPackMaterializationFailed,
5595                        "context-only launch overlay is invalid",
5596                    )
5597                })?
5598            } else {
5599                NativeLaunchEnvironmentOverlay::new(
5600                    provider.clone(),
5601                    transport,
5602                    environment,
5603                )
5604                .map_err(|_| {
5605                    failure(
5606                        NodeFailureCode::BackendOperationFailed,
5607                        "session environment overlay is invalid",
5608                    )
5609                })?
5610            };
5611            Some(PreparedNativeLaunchOverlay::Environment(overlay))
5612        } else {
5613            None
5614        };
5615        Ok(Some((overlay, guard)))
5616    }
5617
5618    fn cleanup_materialization(&self, id: &MaterializationId) -> Result<(), NodeFailure> {
5619        let Some(mut ownership) = self
5620            .materializations
5621            .lock()
5622            .unwrap_or_else(|poisoned| poisoned.into_inner())
5623            .get(id)
5624            .cloned()
5625        else {
5626            return Ok(());
5627        };
5628        if ownership.state() != MaterializationState::CleanupRequired {
5629            ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5630                failure(
5631                    NodeFailureCode::BackendOperationFailed,
5632                    "session environment cleanup state is invalid",
5633                )
5634            })?;
5635        }
5636        {
5637            let _transaction = self
5638                .state_transaction
5639                .lock()
5640                .unwrap_or_else(|poisoned| poisoned.into_inner());
5641            let previous = self
5642                .materializations
5643                .lock()
5644                .unwrap_or_else(|poisoned| poisoned.into_inner())
5645                .insert(id.clone(), ownership.clone());
5646            if let Err(error) = self.persist_state_locked() {
5647                let mut materializations = self
5648                    .materializations
5649                    .lock()
5650                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5651                if let Some(previous) = previous {
5652                    materializations.insert(id.clone(), previous);
5653                }
5654                return Err(persistence_failure(error));
5655            }
5656        }
5657        let cleanup = self
5658            .session_environment_materializer
5659            .as_ref()
5660            .ok_or(())
5661            .and_then(|materializer| materializer.cleanup(&ownership).map_err(|_| ()));
5662        if cleanup.is_err() {
5663            let _ = ownership.mark_recovery_required(unix_time_ms());
5664            self.materializations
5665                .lock()
5666                .unwrap_or_else(|poisoned| poisoned.into_inner())
5667                .insert(id.clone(), ownership);
5668            if self.persist_state().is_err() {
5669                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5670            }
5671            return Err(failure(
5672                NodeFailureCode::BackendOperationFailed,
5673                "session environment cleanup requires recovery",
5674            ));
5675        }
5676        let _transaction = self
5677            .state_transaction
5678            .lock()
5679            .unwrap_or_else(|poisoned| poisoned.into_inner());
5680        self.materializations
5681            .lock()
5682            .unwrap_or_else(|poisoned| poisoned.into_inner())
5683            .remove(id);
5684        if let Err(error) = self.persist_state_locked() {
5685            self.materializations
5686                .lock()
5687                .unwrap_or_else(|poisoned| poisoned.into_inner())
5688                .insert(id.clone(), ownership);
5689            return Err(persistence_failure(error));
5690        }
5691        Ok(())
5692    }
5693
5694    fn resolve_record_materialization(
5695        &self,
5696        record_id: &SessionRecordId,
5697        provider: &AgentId,
5698        mode: SessionMode,
5699        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5700        bundle: Option<&ResolvedBundleReceipt>,
5701        context: Option<&ResolvedContextPackReceipt>,
5702    ) -> Result<Option<(MaterializationId, Option<PreparedNativeLaunchOverlay>)>, NodeFailure> {
5703        let ownership = {
5704            let materializations = self
5705                .materializations
5706                .lock()
5707                .unwrap_or_else(|poisoned| poisoned.into_inner());
5708            let mut matches = materializations.values().filter(|record| {
5709                record.owner()
5710                    == &MaterializationOwner::Record {
5711                        record_id: record_id.clone(),
5712                    }
5713            });
5714            let ownership = matches.next().cloned();
5715            if matches.next().is_some() {
5716                return Err(failure(
5717                    NodeFailureCode::BackendOperationFailed,
5718                    "managed session has conflicting environment ownership",
5719                ));
5720            }
5721            ownership
5722        };
5723        if let (Some(ownership), Some(receipt)) = (ownership.as_ref(), context) {
5724            let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5725                failure(
5726                    NodeFailureCode::ContextPackMaterializationFailed,
5727                    "session context materializer is unavailable",
5728                )
5729            })?;
5730            let pack = materializer
5731                .revalidate_context(ownership, receipt)
5732                .map_err(|_| {
5733                    self.mark_materialization_recovery_required(ownership.id());
5734                    failure(
5735                        NodeFailureCode::ContextPackMaterializationFailed,
5736                        "managed session context pack revalidation failed",
5737                    )
5738                })?;
5739            self.context_catalog
5740                .write()
5741                .unwrap_or_else(|poisoned| poisoned.into_inner())
5742                .insert(pack)
5743                .map_err(|_| failure(
5744                    NodeFailureCode::ContextPackMaterializationFailed,
5745                    "managed session context pack catalog restore failed",
5746                ))?;
5747        }
5748        let materialization = self.materialization_profile(
5749            provider,
5750            mode,
5751            environment_profile,
5752            bundle,
5753            context,
5754        )?;
5755        match (ownership, materialization) {
5756            (None, None) => Ok(None),
5757            (None, Some(_)) | (Some(_), None) => Err(failure(
5758                NodeFailureCode::BackendOperationFailed,
5759                "managed session materialization is unavailable",
5760            )),
5761            (Some(ownership), Some((profile, has_environment_materialization, bundle_layout))) => {
5762                if ownership.environment_profile() != environment_profile
5763                    || ownership.bundle() != bundle
5764                    || ownership.context() != context
5765                    || ownership.state() != MaterializationState::Ready
5766                {
5767                    self.mark_materialization_recovery_required(ownership.id());
5768                    return Err(failure(
5769                        NodeFailureCode::BackendOperationFailed,
5770                        "managed session environment ownership requires recovery",
5771                    ));
5772                }
5773                let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5774                    failure(
5775                        NodeFailureCode::BackendOperationFailed,
5776                        "session environment materializer is unavailable",
5777                    )
5778                })?;
5779                if materializer.revalidate(&ownership).is_err() {
5780                    self.mark_materialization_recovery_required(ownership.id());
5781                    return Err(failure(
5782                        NodeFailureCode::BackendOperationFailed,
5783                        "managed session environment revalidation failed",
5784                    ));
5785                }
5786                let bundle_arguments = if let Some(bundle_receipt) = bundle {
5787                    let catalog = self.bundle_catalog.read()
5788                        .unwrap_or_else(|poisoned| poisoned.into_inner());
5789                    let Some(installed) = catalog.get(&bundle_receipt.id) else {
5790                        return Err(failure(
5791                            NodeFailureCode::UnknownBundle,
5792                            "managed session bundle is unavailable",
5793                        ));
5794                    };
5795                    let layout = bundle_layout.ok_or_else(|| failure(
5796                        NodeFailureCode::BundleBindingMismatch,
5797                        "record bundle provider layout is unavailable",
5798                    ))?;
5799                    if materializer.revalidate_bundle(&ownership, installed, layout).is_err() {
5800                        drop(catalog);
5801                        self.mark_materialization_recovery_required(ownership.id());
5802                        return Err(failure(
5803                            NodeFailureCode::BundleMaterializationFailed,
5804                            "managed session bundle revalidation failed",
5805                        ));
5806                    }
5807                    Some(bundle_launch_arguments(
5808                        layout,
5809                        ownership.bundle_root(),
5810                        ownership.provider_home(),
5811                    ).map_err(|_| failure(
5812                        NodeFailureCode::BundleBindingMismatch,
5813                        "managed session bundle launch binding is invalid",
5814                    ))?)
5815                } else {
5816                    None
5817                };
5818                if !has_environment_materialization {
5819                    let overlay = bundle_arguments.map(|extra_args| {
5820                        NativeInstanceLaunchOverlay::new(
5821                            provider.clone(),
5822                            TransportKind::Pty,
5823                            Vec::new(),
5824                            extra_args,
5825                        )
5826                        .map(PreparedNativeLaunchOverlay::Instance)
5827                        .map_err(|_| failure(
5828                            NodeFailureCode::BundleBindingMismatch,
5829                            "managed session bundle launch overlay is invalid",
5830                        ))
5831                    }).transpose()?;
5832                    return Ok(Some((ownership.id().clone(), overlay)));
5833                }
5834                let environment = match materializer.resolve_environment(&ownership, &profile) {
5835                    Ok(environment) => environment,
5836                    Err(
5837                        SessionEnvironmentMaterializeError::SecretUnavailable
5838                        | SessionEnvironmentMaterializeError::SecretDenied
5839                        | SessionEnvironmentMaterializeError::InvalidSecretValue,
5840                    ) => {
5841                        return Err(failure(
5842                            NodeFailureCode::BackendOperationFailed,
5843                            "managed session environment resolution failed",
5844                        ));
5845                    }
5846                    Err(
5847                        SessionEnvironmentMaterializeError::InvalidRoot
5848                        | SessionEnvironmentMaterializeError::OwnershipMismatch
5849                        | SessionEnvironmentMaterializeError::Filesystem(_)
5850                        | SessionEnvironmentMaterializeError::Record(_),
5851                    ) => {
5852                        self.mark_materialization_recovery_required(ownership.id());
5853                        return Err(failure(
5854                            NodeFailureCode::BackendOperationFailed,
5855                            "managed session environment resolution failed",
5856                        ));
5857                    }
5858                };
5859                let transport = match mode {
5860                    SessionMode::Pty => TransportKind::Pty,
5861                    SessionMode::Inline => TransportKind::Pipe,
5862                    SessionMode::Acp => TransportKind::Acp,
5863                };
5864                let overlay = match bundle_arguments {
5865                    Some(extra_args) => PreparedNativeLaunchOverlay::Instance(
5866                        NativeInstanceLaunchOverlay::new(
5867                            provider.clone(),
5868                            transport,
5869                            environment,
5870                            extra_args,
5871                        )
5872                        .map_err(|_| failure(
5873                            NodeFailureCode::BundleBindingMismatch,
5874                            "managed session bundle launch overlay is invalid",
5875                        ))?,
5876                    ),
5877                    None => PreparedNativeLaunchOverlay::Environment(
5878                        NativeLaunchEnvironmentOverlay::new(
5879                            provider.clone(),
5880                            transport,
5881                            environment,
5882                        )
5883                        .map_err(|_| {
5884                            failure(
5885                                NodeFailureCode::BackendOperationFailed,
5886                                "managed session environment overlay is invalid",
5887                            )
5888                        })?,
5889                    ),
5890                };
5891                Ok(Some((ownership.id().clone(), Some(overlay))))
5892            }
5893        }
5894    }
5895
5896    fn destroy_record_materialization(
5897        &self,
5898        record_id: &SessionRecordId,
5899    ) -> Result<bool, NodeFailure> {
5900        let ids = self
5901            .materializations
5902            .lock()
5903            .unwrap_or_else(|poisoned| poisoned.into_inner())
5904            .values()
5905            .filter_map(|record| {
5906                (record.owner()
5907                    == &MaterializationOwner::Record {
5908                        record_id: record_id.clone(),
5909                    })
5910                .then(|| record.id().clone())
5911            })
5912            .collect::<Vec<_>>();
5913        if ids.len() > 1 {
5914            return Err(failure(
5915                NodeFailureCode::BackendOperationFailed,
5916                "managed session has conflicting environment ownership",
5917            ));
5918        }
5919        let Some(id) = ids.first().cloned() else {
5920            return Ok(false);
5921        };
5922        let (terminal_record, mut cleanup_ownership) = {
5923            let _transaction = self
5924                .state_transaction
5925                .lock()
5926                .unwrap_or_else(|poisoned| poisoned.into_inner());
5927            let original_record = self.record(record_id)?;
5928            let mut terminal_record = original_record.clone();
5929            terminal_record.provider_session = None;
5930            terminal_record.active_session = None;
5931            terminal_record.state = ManagedSessionState::Unavailable;
5932            terminal_record.last_error = Some("environment-profile-unavailable".to_owned());
5933            terminal_record.updated_at_unix_ms = unix_time_ms();
5934            let original_ownership = self
5935                .materializations
5936                .lock()
5937                .unwrap_or_else(|poisoned| poisoned.into_inner())
5938                .get(&id)
5939                .cloned()
5940                .ok_or_else(|| {
5941                    failure(
5942                        NodeFailureCode::BackendOperationFailed,
5943                        "managed session environment ownership disappeared during cleanup",
5944                    )
5945                })?;
5946            let mut cleanup_ownership = original_ownership.clone();
5947            if cleanup_ownership.state() != MaterializationState::CleanupRequired {
5948                cleanup_ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5949                    failure(
5950                        NodeFailureCode::BackendOperationFailed,
5951                        "managed session environment cleanup state is invalid",
5952                    )
5953                })?;
5954            }
5955            self.session_records
5956                .lock()
5957                .unwrap_or_else(|poisoned| poisoned.into_inner())
5958                .records
5959                .insert(record_id.clone(), terminal_record.clone());
5960            self.materializations
5961                .lock()
5962                .unwrap_or_else(|poisoned| poisoned.into_inner())
5963                .insert(id.clone(), cleanup_ownership.clone());
5964            if let Err(error) = self.persist_state_locked() {
5965                self.session_records
5966                    .lock()
5967                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5968                    .records
5969                    .insert(record_id.clone(), original_record);
5970                self.materializations
5971                    .lock()
5972                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5973                    .insert(id.clone(), original_ownership);
5974                return Err(persistence_failure(error));
5975            }
5976            (terminal_record, cleanup_ownership)
5977        };
5978        let cleanup = self
5979            .session_environment_materializer
5980            .as_ref()
5981            .ok_or(())
5982            .and_then(|materializer| materializer.cleanup(&cleanup_ownership).map_err(|_| ()));
5983        if cleanup.is_err() {
5984            let _ = cleanup_ownership.mark_recovery_required(unix_time_ms());
5985            self.materializations
5986                .lock()
5987                .unwrap_or_else(|poisoned| poisoned.into_inner())
5988                .insert(id.clone(), cleanup_ownership);
5989            if self.persist_state().is_err() {
5990                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5991            }
5992            return Err(failure(
5993                NodeFailureCode::BackendOperationFailed,
5994                "managed session environment cleanup requires recovery",
5995            ));
5996        }
5997        let _transaction = self
5998            .state_transaction
5999            .lock()
6000            .unwrap_or_else(|poisoned| poisoned.into_inner());
6001        self.remove_record_memory(record_id);
6002        self.materializations
6003            .lock()
6004            .unwrap_or_else(|poisoned| poisoned.into_inner())
6005            .remove(&id);
6006        if let Err(error) = self.persist_state_locked() {
6007            self.session_records
6008                .lock()
6009                .unwrap_or_else(|poisoned| poisoned.into_inner())
6010                .records
6011                .insert(record_id.clone(), terminal_record);
6012            self.materializations
6013                .lock()
6014                .unwrap_or_else(|poisoned| poisoned.into_inner())
6015                .insert(id, cleanup_ownership);
6016            return Err(persistence_failure(error));
6017        }
6018        Ok(true)
6019    }
6020
6021    fn mark_materialization_recovery_required(&self, id: &MaterializationId) {
6022        let changed = {
6023            let mut materializations = self
6024                .materializations
6025                .lock()
6026                .unwrap_or_else(|poisoned| poisoned.into_inner());
6027            let Some(ownership) = materializations.get_mut(id) else {
6028                return;
6029            };
6030            ownership.mark_recovery_required(unix_time_ms()).is_ok()
6031        };
6032        if changed && self.persist_state().is_err() {
6033            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6034        }
6035    }
6036
6037    fn reconcile_materializations(&self) {
6038        let records = self
6039            .materializations
6040            .lock()
6041            .unwrap_or_else(|poisoned| poisoned.into_inner())
6042            .values()
6043            .cloned()
6044            .collect::<Vec<_>>();
6045        for mut ownership in records {
6046            let result = match (ownership.state(), ownership.owner()) {
6047                (MaterializationState::Preparing, _)
6048                | (MaterializationState::CleanupRequired, _) => {
6049                    self.cleanup_materialization(ownership.id())
6050                }
6051                (MaterializationState::Ready, MaterializationOwner::Session { .. }) => {
6052                    if ownership.mark_recovery_required(unix_time_ms()).is_ok() {
6053                        self.materializations
6054                            .lock()
6055                            .unwrap_or_else(|poisoned| poisoned.into_inner())
6056                            .insert(ownership.id().clone(), ownership.clone());
6057                        self.persist_state().map_err(persistence_failure)
6058                    } else {
6059                        Err(failure(
6060                            NodeFailureCode::BackendOperationFailed,
6061                            "session environment reconciliation failed",
6062                        ))
6063                    }
6064                }
6065                (MaterializationState::Ready, MaterializationOwner::Record { record_id }) => self
6066                    .session_environment_materializer
6067                    .as_ref()
6068                    .ok_or_else(|| {
6069                        failure(
6070                            NodeFailureCode::BackendOperationFailed,
6071                            "session environment materializer is unavailable",
6072                        )
6073                    })
6074                    .and_then(|materializer| {
6075                        materializer.revalidate(&ownership).map_err(|_| failure(
6076                            NodeFailureCode::BackendOperationFailed,
6077                            "session materialization revalidation failed",
6078                        ))?;
6079                        let record = self.record(record_id)?;
6080                        if record.environment_profile.as_ref() != ownership.environment_profile()
6081                            || record.bundle.as_ref() != ownership.bundle()
6082                            || record.context.as_ref() != ownership.context()
6083                        {
6084                            return Err(failure(
6085                                NodeFailureCode::BackendOperationFailed,
6086                                "session materialization ownership changed after restart",
6087                            ));
6088                        }
6089                        if let Some(receipt) = ownership.bundle() {
6090                            let catalog = self.bundle_catalog.read()
6091                                .unwrap_or_else(|poisoned| poisoned.into_inner());
6092                            let bundle = catalog.get(&receipt.id).ok_or_else(|| failure(
6093                                NodeFailureCode::UnknownBundle,
6094                                "session bundle is unavailable after restart",
6095                            ))?;
6096                            if bundle.receipt() != *receipt {
6097                                return Err(failure(
6098                                    NodeFailureCode::BundleBindingMismatch,
6099                                    "session bundle changed after restart",
6100                                ));
6101                            }
6102                            let layout = self.bundle_layout(
6103                                &record.provider,
6104                                record.mode,
6105                                record.environment_profile.as_ref(),
6106                                bundle,
6107                            )?;
6108                            materializer
6109                                .revalidate_bundle(&ownership, bundle, layout)
6110                                .map_err(|_| failure(
6111                                    NodeFailureCode::BundleMaterializationFailed,
6112                                    "session bundle revalidation failed",
6113                                ))?;
6114                        }
6115                        if let Some(receipt) = ownership.context() {
6116                            let pack = materializer
6117                                .revalidate_context(&ownership, receipt)
6118                                .map_err(|_| {
6119                                    failure(
6120                                        NodeFailureCode::ContextPackMaterializationFailed,
6121                                        "session context revalidation failed",
6122                                    )
6123                                })?;
6124                            self.context_catalog
6125                                .write()
6126                                .unwrap_or_else(|poisoned| poisoned.into_inner())
6127                                .insert(pack)
6128                                .map_err(|_| {
6129                                    failure(
6130                                        NodeFailureCode::ContextPackMaterializationFailed,
6131                                        "session context catalog restore failed",
6132                                    )
6133                                })?;
6134                        }
6135                        Ok(())
6136                    }),
6137                (MaterializationState::RecoveryRequired, _) => Ok(()),
6138            };
6139            if result.is_err() && ownership.state() != MaterializationState::RecoveryRequired {
6140                let _ = ownership.mark_recovery_required(unix_time_ms());
6141                self.materializations
6142                    .lock()
6143                    .unwrap_or_else(|poisoned| poisoned.into_inner())
6144                    .insert(ownership.id().clone(), ownership);
6145                if self.persist_state().is_err() {
6146                    self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6147                }
6148            }
6149        }
6150        let materialized_profiles = self
6151            .environment_materialization_profiles
6152            .read()
6153            .unwrap_or_else(|poisoned| poisoned.into_inner())
6154            .keys()
6155            .cloned()
6156            .collect::<Vec<_>>();
6157        let ownership = self
6158            .materializations
6159            .lock()
6160            .unwrap_or_else(|poisoned| poisoned.into_inner())
6161            .values()
6162            .cloned()
6163            .collect::<Vec<_>>();
6164        let changed = {
6165            let mut session_records = self
6166                .session_records
6167                .lock()
6168                .unwrap_or_else(|poisoned| poisoned.into_inner());
6169            let mut changed = false;
6170            for record in session_records.records.values_mut() {
6171                let environment_profile = record.environment_profile.as_ref();
6172                if environment_profile.is_none()
6173                    && record.bundle.is_none()
6174                    && record.context.is_none()
6175                {
6176                    continue;
6177                }
6178                if record.bundle.is_none()
6179                    && environment_profile.is_some_and(|profile| {
6180                        !materialized_profiles.contains(&profile.profile_id)
6181                    })
6182                {
6183                    continue;
6184                }
6185                let has_exact_ownership = ownership.iter().any(|candidate| {
6186                    candidate.state() == MaterializationState::Ready
6187                        && candidate.environment_profile() == environment_profile
6188                        && candidate.bundle() == record.bundle.as_ref()
6189                        && candidate.context() == record.context.as_ref()
6190                        && candidate.owner()
6191                            == &MaterializationOwner::Record {
6192                                record_id: record.record_id.clone(),
6193                            }
6194                });
6195                if !has_exact_ownership {
6196                    record.provider_session = None;
6197                    record.active_session = None;
6198                    record.state = ManagedSessionState::Unavailable;
6199                    record.last_error = Some(if record.bundle.is_some() {
6200                        "bundle-unavailable".to_owned()
6201                    } else if record.context.is_some() {
6202                        "context-unavailable".to_owned()
6203                    } else {
6204                        "environment-profile-unavailable".to_owned()
6205                    });
6206                    record.updated_at_unix_ms = unix_time_ms();
6207                    changed = true;
6208                }
6209            }
6210            changed
6211        };
6212        if changed && self.persist_state().is_err() {
6213            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6214        }
6215    }
6216
6217    fn replay_spawn_spec(
6218        &self,
6219        spec: &SpawnSpec,
6220        now: Instant,
6221    ) -> Result<Option<Result<ResolvedSpawnReceipt, NodeFailure>>, NodeFailure> {
6222        let mut cache = self
6223            .spawn_idempotency
6224            .lock()
6225            .unwrap_or_else(|poisoned| poisoned.into_inner());
6226        cache.entries.retain(|_, entry| entry.expires_at > now);
6227        if let Some(entry) = cache.entries.get(&spec.idempotency_key) {
6228            let SpawnIdempotencyValue::Standard { spec: existing, result } = &entry.value else {
6229                return Err(failure(
6230                    NodeFailureCode::SpawnIdempotencyConflict,
6231                    "spawn idempotency key was reused across spawn kinds",
6232                ));
6233            };
6234            if existing != spec {
6235                return Err(failure(
6236                    NodeFailureCode::SpawnIdempotencyConflict,
6237                    "spawn idempotency key was reused with a different specification",
6238                ));
6239            }
6240            return Ok(Some(result.clone()));
6241        }
6242        if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
6243            return Err(failure(
6244                NodeFailureCode::SpawnIdempotencyCapacity,
6245                "spawn idempotency cache reached its bounded capacity",
6246            ));
6247        }
6248        Ok(None)
6249    }
6250
6251    fn remember_spawn_spec(
6252        &self,
6253        spec: SpawnSpec,
6254        result: Result<ResolvedSpawnReceipt, NodeFailure>,
6255        accepted_at: Instant,
6256    ) {
6257        self
6258            .spawn_idempotency
6259            .lock()
6260            .unwrap_or_else(|poisoned| poisoned.into_inner())
6261            .entries
6262            .insert(
6263                spec.idempotency_key.clone(),
6264                SpawnIdempotencyEntry {
6265                    value: SpawnIdempotencyValue::Standard { spec, result },
6266                    expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
6267                },
6268            );
6269    }
6270
6271    fn claim_harness_mcp_spawn(
6272        &self,
6273        _reservation_id: &HarnessMcpReservationId,
6274        _activation_digest: &HarnessMcpActivationDigest,
6275        spec: &SpawnSpec,
6276        now: Instant,
6277    ) -> Result<(), NodeFailure> {
6278        let mut cache = self.spawn_idempotency.lock()
6279            .unwrap_or_else(|poisoned| poisoned.into_inner());
6280        cache.entries.retain(|_, entry| entry.expires_at > now);
6281        if cache.entries.contains_key(&spec.idempotency_key) {
6282            return Err(failure(
6283                NodeFailureCode::SpawnIdempotencyConflict,
6284                "spawn idempotency key was reused across spawn kinds",
6285            ));
6286        }
6287        if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
6288            return Err(failure(
6289                NodeFailureCode::SpawnIdempotencyCapacity,
6290                "spawn idempotency cache reached its bounded capacity",
6291            ));
6292        }
6293        cache.entries.insert(spec.idempotency_key.clone(), SpawnIdempotencyEntry {
6294            value: SpawnIdempotencyValue::HarnessMcp,
6295            expires_at: now + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
6296        });
6297        Ok(())
6298    }
6299
6300    fn replay_managed_spawn(
6301        &self,
6302        request: &ManagedWorktreeSpawnRequest,
6303        now: Instant,
6304    ) -> Result<Option<Result<ManagedWorktreeSpawnReceipt, NodeFailure>>, NodeFailure> {
6305        let mut cache = self.spawn_idempotency.lock()
6306            .unwrap_or_else(|poisoned| poisoned.into_inner());
6307        cache.entries.retain(|_, entry| entry.expires_at > now);
6308        if let Some(entry) = cache.entries.get(&request.spawn_spec.idempotency_key) {
6309            let SpawnIdempotencyValue::Managed { request: existing, result } = &entry.value else {
6310                return Err(failure(
6311                    NodeFailureCode::SpawnIdempotencyConflict,
6312                    "spawn idempotency key was reused across spawn kinds",
6313                ));
6314            };
6315            if existing != request {
6316                return Err(failure(
6317                    NodeFailureCode::SpawnIdempotencyConflict,
6318                    "spawn idempotency key was reused with a different managed specification",
6319                ));
6320            }
6321            return Ok(Some(result.clone()));
6322        }
6323        if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
6324            return Err(failure(
6325                NodeFailureCode::SpawnIdempotencyCapacity,
6326                "spawn idempotency cache reached its bounded capacity",
6327            ));
6328        }
6329        Ok(None)
6330    }
6331
6332    fn remember_managed_spawn(
6333        &self,
6334        request: ManagedWorktreeSpawnRequest,
6335        result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
6336        accepted_at: Instant,
6337    ) {
6338        self.spawn_idempotency.lock()
6339            .unwrap_or_else(|poisoned| poisoned.into_inner())
6340            .entries
6341            .insert(
6342                request.spawn_spec.idempotency_key.clone(),
6343                SpawnIdempotencyEntry {
6344                    value: SpawnIdempotencyValue::Managed { request, result },
6345                    expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
6346                },
6347            );
6348    }
6349
6350    fn remember_managed_spawn_attempt(
6351        &self,
6352        durable_v2_key: Option<&SpawnIdempotencyKey>,
6353        request: ManagedWorktreeSpawnRequest,
6354        result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
6355        accepted_at: Instant,
6356    ) {
6357        if durable_v2_key.is_none() {
6358            self.remember_managed_spawn(request, result, accepted_at);
6359        }
6360    }
6361
6362    fn acquire_durable_managed_spawn_v2(
6363        &self,
6364        request: &ManagedWorktreeSpawnRequestV2,
6365        request_digest: String,
6366    ) -> Result<DurableManagedSpawnReplayDecision, NodeFailure> {
6367        if self.state_path.is_none() {
6368            return Err(failure(
6369                NodeFailureCode::BackendOperationFailed,
6370                "managed worktree V2 requires durable node state",
6371            ));
6372        }
6373        let _transaction = self.state_transaction
6374            .lock()
6375            .unwrap_or_else(|poisoned| poisoned.into_inner());
6376        let key = request.spawn_spec.idempotency_key.clone();
6377        let mut previous = None;
6378        {
6379            let mut replays = self.managed_spawn_replays
6380                .lock()
6381                .unwrap_or_else(|poisoned| poisoned.into_inner());
6382            if let Some(record) = replays.get_mut(&key) {
6383                if record.request_digest != request_digest {
6384                    return Err(failure(
6385                        NodeFailureCode::SpawnIdempotencyConflict,
6386                        "managed worktree V2 idempotency key was reused with a different request",
6387                    ));
6388                }
6389                match &record.state {
6390                    ManagedWorktreeSpawnReplayStateV10::Pending {
6391                        owner_incarnation_id,
6392                        lease_id: None,
6393                    } if owner_incarnation_id != &self.incarnation_id => {
6394                        previous = Some(record.clone());
6395                        record.state = ManagedWorktreeSpawnReplayStateV10::Pending {
6396                            owner_incarnation_id: self.incarnation_id,
6397                            lease_id: None,
6398                        };
6399                        record.updated_at_unix_ms = unix_time_ms();
6400                    }
6401                    ManagedWorktreeSpawnReplayStateV10::Pending {
6402                        lease_id: None,
6403                        ..
6404                    } => {
6405                        return Err(failure(
6406                            NodeFailureCode::BackendBusy,
6407                            "managed worktree V2 reservation is already active",
6408                        ));
6409                    }
6410                    ManagedWorktreeSpawnReplayStateV10::Pending {
6411                        lease_id: Some(_),
6412                        ..
6413                    } => {
6414                        return Ok(DurableManagedSpawnReplayDecision::PendingLinked);
6415                    }
6416                    ManagedWorktreeSpawnReplayStateV10::Committed { receipt } => {
6417                        return Ok(DurableManagedSpawnReplayDecision::Committed(
6418                            receipt.clone(),
6419                        ));
6420                    }
6421                }
6422            } else if replays.len() >= MAX_MANAGED_WORKTREE_SPAWN_REPLAYS {
6423                return Err(failure(
6424                    NodeFailureCode::SpawnIdempotencyCapacity,
6425                    "durable managed worktree replay ledger reached its bounded capacity",
6426                ));
6427            } else {
6428                let now = unix_time_ms();
6429                replays.insert(key.clone(), ManagedWorktreeSpawnReplayRecordV10 {
6430                    idempotency_key: key.clone(),
6431                    request_digest,
6432                    source_workspace_id: request.spawn_spec.target.workspace_id.clone(),
6433                    profile_id: request.worktree_profile_id.clone(),
6434                    expected_profile_revision: request.expected_profile_revision.clone(),
6435                    state: ManagedWorktreeSpawnReplayStateV10::Pending {
6436                        owner_incarnation_id: self.incarnation_id,
6437                        lease_id: None,
6438                    },
6439                    created_at_unix_ms: now,
6440                    updated_at_unix_ms: now,
6441                });
6442            }
6443        }
6444        if let Err(error) = self.persist_state_locked() {
6445            let mut replays = self.managed_spawn_replays
6446                .lock()
6447                .unwrap_or_else(|poisoned| poisoned.into_inner());
6448            match previous {
6449                Some(previous) => {
6450                    replays.insert(key, previous);
6451                }
6452                None => {
6453                    replays.remove(&key);
6454                }
6455            }
6456            return Err(persistence_failure(error));
6457        }
6458        Ok(DurableManagedSpawnReplayDecision::Reserved)
6459    }
6460
6461    fn link_durable_managed_spawn_v2_lease_locked(
6462        &self,
6463        key: &SpawnIdempotencyKey,
6464        lease_id: &ManagedWorktreeLeaseId,
6465    ) -> Result<ManagedWorktreeSpawnReplayRecordV10, NodeFailure> {
6466        let mut replays = self.managed_spawn_replays
6467            .lock()
6468            .unwrap_or_else(|poisoned| poisoned.into_inner());
6469        let record = replays.get_mut(key).ok_or_else(|| {
6470            failure(
6471                NodeFailureCode::ManagedWorktreeRecoveryRequired,
6472                "managed worktree V2 reservation disappeared before allocation",
6473            )
6474        })?;
6475        let previous = record.clone();
6476        match &mut record.state {
6477            ManagedWorktreeSpawnReplayStateV10::Pending {
6478                lease_id: current,
6479                ..
6480            }
6481                if current.is_none() =>
6482            {
6483                *current = Some(lease_id.clone());
6484                record.updated_at_unix_ms = unix_time_ms();
6485                Ok(previous)
6486            }
6487            _ => Err(failure(
6488                NodeFailureCode::ManagedWorktreeRecoveryRequired,
6489                "managed worktree V2 reservation is not allocation-ready",
6490            )),
6491        }
6492    }
6493
6494    fn restore_durable_managed_spawn_v2_locked(
6495        &self,
6496        key: &SpawnIdempotencyKey,
6497        previous: ManagedWorktreeSpawnReplayRecordV10,
6498    ) {
6499        self.managed_spawn_replays
6500            .lock()
6501            .unwrap_or_else(|poisoned| poisoned.into_inner())
6502            .insert(key.clone(), previous);
6503    }
6504
6505    fn commit_durable_managed_spawn_v2(
6506        &self,
6507        request: &ManagedWorktreeSpawnRequestV2,
6508        receipt: ManagedWorktreeSpawnReceipt,
6509    ) -> Result<(), NodeFailure> {
6510        let _transaction = self.state_transaction
6511            .lock()
6512            .unwrap_or_else(|poisoned| poisoned.into_inner());
6513        let key = &request.spawn_spec.idempotency_key;
6514        let previous = {
6515            let mut replays = self.managed_spawn_replays
6516                .lock()
6517                .unwrap_or_else(|poisoned| poisoned.into_inner());
6518            let record = replays.get_mut(key).ok_or_else(|| {
6519                failure(
6520                    NodeFailureCode::ManagedWorktreeRecoveryRequired,
6521                    "managed worktree V2 reservation disappeared before commit",
6522                )
6523            })?;
6524            let previous = record.clone();
6525            match &record.state {
6526                ManagedWorktreeSpawnReplayStateV10::Pending {
6527                    lease_id: Some(lease_id),
6528                    ..
6529                } if lease_id == &receipt.lease.lease_id => {}
6530                _ => {
6531                    return Err(failure(
6532                        NodeFailureCode::ManagedWorktreeRecoveryRequired,
6533                        "managed worktree V2 reservation is not linked to the spawned lease",
6534                    ));
6535                }
6536            }
6537            record.state = ManagedWorktreeSpawnReplayStateV10::Committed { receipt };
6538            record.updated_at_unix_ms = unix_time_ms();
6539            previous
6540        };
6541        if let Err(error) = self.persist_state_locked() {
6542            self.restore_durable_managed_spawn_v2_locked(key, previous);
6543            return Err(persistence_failure(error));
6544        }
6545        Ok(())
6546    }
6547
6548    fn remove_unallocated_durable_managed_spawn_v2(
6549        &self,
6550        request: &ManagedWorktreeSpawnRequestV2,
6551    ) -> Result<(), NodeFailure> {
6552        let _transaction = self.state_transaction
6553            .lock()
6554            .unwrap_or_else(|poisoned| poisoned.into_inner());
6555        let key = &request.spawn_spec.idempotency_key;
6556        let removed = {
6557            let mut replays = self.managed_spawn_replays
6558                .lock()
6559                .unwrap_or_else(|poisoned| poisoned.into_inner());
6560            match replays.get(key).map(|record| &record.state) {
6561                Some(ManagedWorktreeSpawnReplayStateV10::Pending {
6562                    lease_id: None,
6563                    ..
6564                }) => {
6565                    replays.remove(key)
6566                }
6567                _ => None,
6568            }
6569        };
6570        let Some(removed) = removed else {
6571            return Ok(());
6572        };
6573        if let Err(error) = self.persist_state_locked() {
6574            self.managed_spawn_replays
6575                .lock()
6576                .unwrap_or_else(|poisoned| poisoned.into_inner())
6577                .insert(key.clone(), removed);
6578            return Err(persistence_failure(error));
6579        }
6580        Ok(())
6581    }
6582
6583    fn resolve_spawn_spec(&self, spec: &SpawnSpec) -> Result<ResolvedSpawnSpec, NodeFailure> {
6584        if spec.target.node_id != self.node_id {
6585            return Err(failure(
6586                NodeFailureCode::SpawnTargetMismatch,
6587                "spawn target does not match this node",
6588            ));
6589        }
6590        let defaults = self.spawn_profiles.get(&spec.profile_id).ok_or_else(|| {
6591            failure(
6592                NodeFailureCode::UnknownSpawnProfile,
6593                "spawn profile is unavailable on this node",
6594            )
6595        })?;
6596        let resolved = spec.resolve(defaults).map_err(|error| match error {
6597            SpawnSpecResolveError::ProfileRevisionMismatch { .. } => failure(
6598                NodeFailureCode::SpawnProfileRevisionMismatch,
6599                "spawn profile revision does not match the loaded profile",
6600            ),
6601            _ => failure(
6602                NodeFailureCode::InvalidRequest,
6603                "spawn specification could not be resolved",
6604            ),
6605        })?;
6606        if resolved.target.worktree_id.is_some() {
6607            self.require_worktree_service(&resolved.target.workspace_id)?;
6608        }
6609        let environment_profile = self.resolve_environment_profile(&resolved)?;
6610        self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6611        self.resolve_context(&resolved)?;
6612        Ok(resolved)
6613    }
6614
6615    async fn resolve_spawn_workspace(
6616        &self,
6617        resolved: &ResolvedSpawnSpec,
6618        deadline: Instant,
6619    ) -> Result<WorkspaceId, NodeFailure> {
6620        let Some(worktree_id) = resolved.target.worktree_id.as_ref() else {
6621            return Ok(resolved.target.workspace_id.clone());
6622        };
6623        if self.managed_worktrees.lock()
6624            .unwrap_or_else(|poisoned| poisoned.into_inner())
6625            .lease_for_workspace(worktree_id)
6626            .is_some()
6627        {
6628            return Err(failure(
6629                NodeFailureCode::ManagedWorktreeOwnershipConflict,
6630                "managed worktree targets may only be spawned through their lease request",
6631            ));
6632        }
6633        self.require_worktree_service(&resolved.target.workspace_id)?;
6634        let source_root = self.workspace_root(&resolved.target.workspace_id)?;
6635        let selected_root = self.workspace_root(worktree_id)?;
6636        let remaining = spawn_deadline_remaining(deadline)?;
6637        let worktrees = timeout(remaining, list_git_worktrees(&source_root))
6638            .await
6639            .map_err(|_| {
6640                failure(
6641                    NodeFailureCode::SpawnDeadlineExceeded,
6642                    "Git worktree selection exceeded the spawn deadline",
6643                )
6644            })?
6645            .map_err(git_worktree_failure)?;
6646        validate_selected_worktree(&source_root, &selected_root, &worktrees)?;
6647        Ok(worktree_id.clone())
6648    }
6649
6650    async fn spawn_from_spec(
6651        &self,
6652        spec: SpawnSpec,
6653    ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6654        self.spawn_from_spec_with_proxy(spec, None).await
6655    }
6656
6657    async fn spawn_from_spec_with_proxy(
6658        &self,
6659        spec: SpawnSpec,
6660        harness_mcp: Option<&PreparedHarnessMcpSpawn>,
6661    ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6662        let accepted_at = Instant::now();
6663        if harness_mcp.is_none() {
6664            if let Some(replayed) = self.replay_spawn_spec(&spec, accepted_at)? {
6665                return replayed;
6666            }
6667        }
6668        let resolved = self.resolve_spawn_spec(&spec)?;
6669        let environment_profile = self.resolve_environment_profile(&resolved)?;
6670        let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6671        let context = self.resolve_context(&resolved)?;
6672        let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6673        let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6674        let spawn_workspace_id = self.resolve_spawn_workspace(&resolved, deadline).await;
6675        let spawn_workspace_id = match spawn_workspace_id {
6676            Ok(workspace_id) => workspace_id,
6677            Err(error) => {
6678                let result = Err(error);
6679                if harness_mcp.is_none() {
6680                    self.remember_spawn_spec(spec, result.clone(), accepted_at);
6681                }
6682                return result;
6683            }
6684        };
6685        if self.managed_worktrees.lock()
6686            .unwrap_or_else(|poisoned| poisoned.into_inner())
6687            .lease_for_workspace(&spawn_workspace_id)
6688            .is_some()
6689        {
6690            let result = Err(failure(
6691                NodeFailureCode::ManagedWorktreeOwnershipConflict,
6692                "managed worktree targets may only be spawned through their lease request",
6693            ));
6694            if harness_mcp.is_none() {
6695                self.remember_spawn_spec(spec, result.clone(), accepted_at);
6696            }
6697            return result;
6698        }
6699        let result = self
6700            .spawn_session_with_deadline(
6701                spawn_workspace_id,
6702                resolved.provider.clone(),
6703                resolved.mode,
6704                resolved.terminal_size,
6705                resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
6706                environment_profile.clone(),
6707                bundle.clone(),
6708                context.clone(),
6709                None,
6710                None,
6711                SpawnRecordPolicy::Always,
6712                Some(deadline),
6713                &required_capabilities,
6714                harness_mcp,
6715                resolved.approval_level,
6716                resolved.network_allowlist.clone(),
6717                resolved.browser_profile_id.clone(),
6718            )
6719            .await
6720            .map(|(session, _runtime_policy)| {
6721                resolved.receipt_with_materialization(
6722                    self.incarnation_id,
6723                    session,
6724                    environment_profile,
6725                    bundle,
6726                    context,
6727                )
6728            });
6729        if harness_mcp.is_none() {
6730            self.remember_spawn_spec(spec, result.clone(), accepted_at);
6731        }
6732        result
6733    }
6734
6735    async fn spawn_from_spec_with_harness_mcp(
6736        &self,
6737        reservation_id: HarnessMcpReservationId,
6738        activation_digest: HarnessMcpActivationDigest,
6739        spec: SpawnSpec,
6740        deadline_unix_ms: u64,
6741    ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6742        let registry = self.harness_mcp_registry.as_ref().ok_or_else(|| {
6743            failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
6744        })?;
6745        if let Some(receipt) = registry
6746            .replay_spawn(&reservation_id, &activation_digest, &spec)
6747            .map_err(harness_mcp_failure)?
6748        {
6749            return Ok(receipt);
6750        }
6751        let resolved = self.resolve_spawn_spec(&spec)?;
6752        let prepared = registry
6753            .prepare_spawn(
6754                &reservation_id,
6755                &activation_digest,
6756                &spec,
6757                deadline_unix_ms,
6758                resolved.provider.clone(),
6759            )
6760            .map_err(harness_mcp_failure)?;
6761        self.claim_harness_mcp_spawn(
6762            &reservation_id,
6763            &activation_digest,
6764            &spec,
6765            Instant::now(),
6766        )?;
6767        let spawn = self.spawn_from_spec_with_proxy(spec, Some(&prepared)).await;
6768        let mut receipt = match spawn {
6769            Ok(receipt) => receipt,
6770            Err(error) => {
6771                let _ = registry.abort(&reservation_id, &activation_digest);
6772                return Err(error);
6773            }
6774        };
6775        let record_id = self
6776            .session_bindings
6777            .lock()
6778            .unwrap_or_else(|poisoned| poisoned.into_inner())
6779            .get(&receipt.session.session.instance_id)
6780            .and_then(|binding| binding.record_id.clone());
6781        let record_id = match record_id {
6782            Some(record_id) => record_id,
6783            None => {
6784                let rollback = self.remove_session(&receipt.session).await;
6785                let _ = registry.abort(&reservation_id, &activation_digest);
6786                if let Err(rollback_error) = rollback {
6787                    return Err(rollback_error);
6788                }
6789                return Err(failure(
6790                    NodeFailureCode::BindingMismatch,
6791                    "harness MCP spawn did not retain its exact managed record",
6792                ));
6793            }
6794        };
6795        receipt.harness_mcp_proxy = Some(ResolvedHarnessMcpProxyReceiptV1 {
6796            reservation_id: reservation_id.clone(),
6797            activation_digest: activation_digest.clone(),
6798        });
6799        if let Err(error) = registry.mark_spawned(
6800            &prepared,
6801            receipt.session.clone(),
6802            record_id,
6803            receipt.clone(),
6804        ) {
6805            let rollback = self.remove_session(&receipt.session).await;
6806            let _ = registry.abort(&reservation_id, &activation_digest);
6807            if let Err(rollback_error) = rollback {
6808                return Err(rollback_error);
6809            }
6810            return Err(harness_mcp_failure(error));
6811        }
6812        Ok(receipt)
6813    }
6814
6815    async fn spawn_managed_worktree(
6816        &self,
6817        request: ManagedWorktreeSpawnRequest,
6818    ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6819        self.spawn_managed_worktree_inner(request, None).await
6820    }
6821
6822    async fn spawn_managed_worktree_inner(
6823        &self,
6824        request: ManagedWorktreeSpawnRequest,
6825        durable_v2_key: Option<&SpawnIdempotencyKey>,
6826    ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6827        let accepted_at = Instant::now();
6828        if durable_v2_key.is_none() {
6829            if let Some(replayed) = self.replay_managed_spawn(&request, accepted_at)? {
6830                return replayed;
6831            }
6832        }
6833        let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
6834        if request.spawn_spec.target.worktree_id.is_some() {
6835            let result = Err(failure(
6836                NodeFailureCode::InvalidRequest,
6837                "managed worktree spawn must not select a caller-provided worktree",
6838            ));
6839            self.remember_managed_spawn_attempt(
6840                durable_v2_key,
6841                request,
6842                result.clone(),
6843                accepted_at,
6844            );
6845            return result;
6846        }
6847        let environment_profile = self.resolve_environment_profile(&resolved)?;
6848        let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6849        let context = self.resolve_context(&resolved)?;
6850        let source_workspace_id = resolved.target.workspace_id.clone();
6851        let profile = self.managed_profile(&source_workspace_id, &request.worktree_profile_id)?;
6852        let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6853        let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6854        let runtime_requirement = match (resolved.mode, resolved.prompt.is_some()) {
6855            (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
6856            (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
6857            (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
6858            (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
6859        };
6860        let runtime_policy = timeout(
6861            spawn_deadline_remaining(deadline)?,
6862            self.admit_provider_runtime(&resolved.provider, runtime_requirement),
6863        ).await.map_err(|_| failure(
6864            NodeFailureCode::SpawnDeadlineExceeded,
6865            "provider admission exceeded the managed spawn deadline",
6866        ))??;
6867        if required_capabilities.iter().any(|capability| !runtime_policy.admits(*capability)) {
6868            return Err(failure(
6869                NodeFailureCode::UnsupportedSpawnCapability,
6870                "provider runtime does not admit a required spawn capability",
6871            ));
6872        }
6873        self.ensure_binding_capacity()?;
6874        let source_root = self.workspace_root(&source_workspace_id)?;
6875        profile.validate_for_workspace(&source_root)
6876            .map_err(|message| failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message))?;
6877        let base_timeout_ms = u64::try_from(
6878            spawn_deadline_remaining(deadline)?.as_millis(),
6879        ).unwrap_or(u64::MAX).max(1);
6880        let base_commit = resolve_base_commit_with_timeout(
6881            &source_root,
6882            profile.base(),
6883            base_timeout_ms,
6884        ).await.map_err(|error| {
6885            if Instant::now() >= deadline || error.message.contains("timed out") {
6886                failure(
6887                    NodeFailureCode::SpawnDeadlineExceeded,
6888                    "managed worktree base resolution exceeded the spawn deadline",
6889                )
6890            } else {
6891                managed_git_worktree_failure(error)
6892            }
6893        })?;
6894
6895        let lease = {
6896            let _transaction = self.state_transaction.lock()
6897                .unwrap_or_else(|poisoned| poisoned.into_inner());
6898            let previous = self.managed_worktrees.lock()
6899                .unwrap_or_else(|poisoned| poisoned.into_inner()).clone();
6900            let lease = self.managed_worktrees.lock()
6901                .unwrap_or_else(|poisoned| poisoned.into_inner())
6902                .allocate(source_workspace_id.clone(), &profile, base_commit.clone(), unix_time_ms())
6903                .map_err(|message| failure(NodeFailureCode::BackendBusy, &message))?;
6904            let previous_replay = match durable_v2_key
6905                .map(|key| self.link_durable_managed_spawn_v2_lease_locked(key, &lease.lease_id))
6906                .transpose()
6907            {
6908                Ok(previous) => previous,
6909                Err(error) => {
6910                    *self.managed_worktrees.lock()
6911                        .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6912                    return Err(error);
6913                }
6914            };
6915            if let Err(error) = self.persist_state_locked() {
6916                *self.managed_worktrees.lock()
6917                    .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6918                if let (Some(key), Some(previous_replay)) =
6919                    (durable_v2_key, previous_replay)
6920                {
6921                    self.restore_durable_managed_spawn_v2_locked(key, previous_replay);
6922                }
6923                return Err(persistence_failure(error));
6924            }
6925            lease
6926        };
6927        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() });
6928        if let Err(message) = profile.validate_target_authority(&lease.lease_id, &lease.target_root) {
6929            self.managed_worktrees.lock()
6930                .unwrap_or_else(|poisoned| poisoned.into_inner())
6931                .remove_unmutated(&lease.lease_id);
6932            if self.persist_state().is_err() {
6933                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6934            }
6935            let result = Err(failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message));
6936            self.remember_managed_spawn_attempt(
6937                durable_v2_key,
6938                request,
6939                result.clone(),
6940                accepted_at,
6941            );
6942            return result;
6943        }
6944        let mutation_timeout_ms = match spawn_deadline_remaining(deadline) {
6945            Ok(remaining) => u64::try_from(remaining.as_millis()).unwrap_or(u64::MAX).max(1),
6946            Err(error) => {
6947                self.reconcile_failed_allocation(&lease, &source_root).await;
6948                let result = Err(error);
6949                self.remember_managed_spawn_attempt(
6950                    durable_v2_key,
6951                    request,
6952                    result.clone(),
6953                    accepted_at,
6954                );
6955                return result;
6956            }
6957        };
6958        let created = match create_git_worktree_with_timeout(
6959                &source_root,
6960                &lease.target_root,
6961                &lease.branch,
6962                Some(&base_commit),
6963                mutation_timeout_ms,
6964            ).await {
6965            Ok(created) => created,
6966            Err(error) => {
6967                self.reconcile_failed_allocation(&lease, &source_root).await;
6968                let result = Err(if Instant::now() >= deadline
6969                    || error.message.contains("timed out")
6970                    || error.message.contains("deadline")
6971                {
6972                    failure(
6973                        NodeFailureCode::SpawnDeadlineExceeded,
6974                        "managed worktree creation exceeded the spawn deadline",
6975                    )
6976                } else {
6977                    managed_git_worktree_failure(error)
6978                });
6979                self.remember_managed_spawn_attempt(
6980                    durable_v2_key,
6981                    request,
6982                    result.clone(),
6983                    accepted_at,
6984                );
6985                return result;
6986            }
6987        };
6988        if !exact_created_worktree(&lease, &created.path, created.branch.as_deref(), &created.head) {
6989            self.mark_managed_recovery_required(&lease.lease_id, ManagedWorktreeCleanupFailure::OwnershipConflict);
6990            let result = Err(failure(
6991                NodeFailureCode::ManagedWorktreeRecoveryRequired,
6992                "created worktree identity did not match its durable lease",
6993            ));
6994            self.remember_managed_spawn_attempt(
6995                durable_v2_key,
6996                request,
6997                result.clone(),
6998                accepted_at,
6999            );
7000            return result;
7001        }
7002        {
7003            let mut registry = self.managed_worktrees.lock()
7004                .unwrap_or_else(|poisoned| poisoned.into_inner());
7005            let current = registry.get_mut(&lease.lease_id)
7006                .expect("allocated managed worktree lease remains present");
7007            current.expected_head = Some(created.head.clone());
7008            current.state = ManagedWorktreeLeaseState::Ready;
7009            current.updated_at_unix_ms = unix_time_ms();
7010        }
7011        if let Err(error) = self.persist_state() {
7012            self.mark_managed_recovery_required(
7013                &lease.lease_id,
7014                ManagedWorktreeCleanupFailure::Backend,
7015            );
7016            let result = Err(persistence_failure(error));
7017            self.remember_managed_spawn_attempt(
7018                durable_v2_key,
7019                request,
7020                result.clone(),
7021                accepted_at,
7022            );
7023            return result;
7024        }
7025        let ready = self.managed_worktrees.lock()
7026            .unwrap_or_else(|poisoned| poisoned.into_inner())
7027            .get(&lease.lease_id)
7028            .expect("ready managed worktree lease remains present")
7029            .snapshot();
7030        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: ready });
7031        if let Err(error) = self.register_workspace(lease.workspace_id.clone(), created.path.clone()).await {
7032            let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
7033            let result = Err(error);
7034            self.remember_managed_spawn_attempt(
7035                durable_v2_key,
7036                request,
7037                result.clone(),
7038                accepted_at,
7039            );
7040            return result;
7041        }
7042        let spawn = self.spawn_session_with_deadline(
7043            lease.workspace_id.clone(),
7044            resolved.provider.clone(),
7045            resolved.mode,
7046            resolved.terminal_size,
7047            resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
7048            environment_profile.clone(),
7049            bundle.clone(),
7050            context.clone(),
7051            Some(lease.lease_id.clone()),
7052            Some(runtime_policy),
7053            SpawnRecordPolicy::Always,
7054            Some(deadline),
7055            &required_capabilities,
7056            None,
7057            resolved.approval_level,
7058            resolved.network_allowlist.clone(),
7059            resolved.browser_profile_id.clone(),
7060        ).await;
7061        let (session, effective_runtime_policy) = match spawn {
7062            Ok(spawned) => spawned,
7063            Err(error) => {
7064                if self
7065                    .cleanup_managed_worktree(&lease.lease_id, true)
7066                    .await
7067                    .is_err()
7068                {
7069                    self.mark_managed_recovery_required(
7070                        &lease.lease_id,
7071                        ManagedWorktreeCleanupFailure::Backend,
7072                    );
7073                }
7074                let result = Err(error);
7075                self.remember_managed_spawn_attempt(
7076                    durable_v2_key,
7077                    request,
7078                    result.clone(),
7079                    accepted_at,
7080                );
7081                return result;
7082            }
7083        };
7084        let (record_id, raw_inventory_record_id) = {
7085            let mut bindings = self.session_bindings.lock()
7086                .unwrap_or_else(|poisoned| poisoned.into_inner());
7087            let binding = bindings.get_mut(&session.session.instance_id)
7088                .expect("successful spawn retains its session binding");
7089            binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
7090            managed_spawn_record_ownership(binding, effective_runtime_policy)
7091        };
7092        let bound = self.managed_worktrees.lock()
7093            .unwrap_or_else(|poisoned| poisoned.into_inner())
7094            .bind_session(
7095                &lease.lease_id,
7096                ManagedWorktreeSessionHolder {
7097                    incarnation_id: self.incarnation_id,
7098                    instance_id: session.session.instance_id,
7099                    generation: session.session.generation,
7100                },
7101                record_id,
7102                unix_time_ms(),
7103            )
7104            .map_err(|message| failure(NodeFailureCode::ManagedWorktreeRecoveryRequired, &message));
7105        let snapshot = match bound {
7106            Ok(snapshot) => snapshot,
7107            Err(error) => {
7108                if raw_inventory_record_id
7109                    .as_ref()
7110                    .is_some_and(|record_id| self.discard_record(record_id).is_err())
7111                {
7112                    self.mark_managed_recovery_required(
7113                        &lease.lease_id,
7114                        ManagedWorktreeCleanupFailure::Backend,
7115                    );
7116                }
7117                let _ = self.remove_session(&session).await;
7118                self.mark_managed_recovery_required(
7119                    &lease.lease_id,
7120                    ManagedWorktreeCleanupFailure::Backend,
7121                );
7122                let result = Err(error);
7123                self.remember_managed_spawn_attempt(
7124                    durable_v2_key,
7125                    request,
7126                    result.clone(),
7127                    accepted_at,
7128                );
7129                return result;
7130            }
7131        };
7132        if let Err(error) = self.persist_state() {
7133            if raw_inventory_record_id
7134                .as_ref()
7135                .is_some_and(|record_id| self.discard_record(record_id).is_err())
7136            {
7137                self.mark_managed_recovery_required(
7138                    &lease.lease_id,
7139                    ManagedWorktreeCleanupFailure::Backend,
7140                );
7141            }
7142            let _ = self.remove_session(&session).await;
7143            if self
7144                .cleanup_managed_worktree(&lease.lease_id, true)
7145                .await
7146                .is_err()
7147            {
7148                self.mark_managed_recovery_required(
7149                    &lease.lease_id,
7150                    ManagedWorktreeCleanupFailure::Backend,
7151                );
7152            }
7153            let result = Err(persistence_failure(error));
7154            self.remember_managed_spawn_attempt(
7155                durable_v2_key,
7156                request,
7157                result.clone(),
7158                accepted_at,
7159            );
7160            return result;
7161        }
7162        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
7163        let receipt = managed_spawn_receipt(
7164            &resolved,
7165            self.incarnation_id,
7166            session,
7167            snapshot,
7168            environment_profile,
7169            bundle,
7170            context,
7171        );
7172        let result = Ok(receipt);
7173        self.remember_managed_spawn_attempt(
7174            durable_v2_key,
7175            request,
7176            result.clone(),
7177            accepted_at,
7178        );
7179        result
7180    }
7181
7182    async fn spawn_managed_worktree_v2(
7183        &self,
7184        request: ManagedWorktreeSpawnRequestV2,
7185    ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
7186        if request.spawn_spec.target.node_id != self.node_id {
7187            return Err(failure(
7188                NodeFailureCode::SpawnTargetMismatch,
7189                "spawn target does not match this node",
7190            ));
7191        }
7192        if request.spawn_spec.target.worktree_id.is_some() {
7193            return Err(failure(
7194                NodeFailureCode::InvalidRequest,
7195                "managed worktree spawn must not select a caller-provided worktree",
7196            ));
7197        }
7198        let request_digest = managed_spawn_request_digest_v2(&request)?;
7199        let replay = self.acquire_durable_managed_spawn_v2(&request, request_digest)?;
7200        match replay {
7201            DurableManagedSpawnReplayDecision::Committed(receipt) => return Ok(receipt),
7202            DurableManagedSpawnReplayDecision::PendingLinked => {
7203                return Err(failure(
7204                    NodeFailureCode::ManagedWorktreeRecoveryRequired,
7205                    "managed worktree V2 reservation has an incomplete durable allocation",
7206                ));
7207            }
7208            DurableManagedSpawnReplayDecision::Reserved => {}
7209        }
7210        let validated = (|| {
7211            let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
7212            let profile = self.managed_profile(
7213                &resolved.target.workspace_id,
7214                &request.worktree_profile_id,
7215            )?;
7216            if profile.revision() != &request.expected_profile_revision {
7217                return Err(failure(
7218                    NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
7219                    "managed worktree profile revision does not match the loaded profile",
7220                ));
7221            }
7222            Ok(())
7223        })();
7224        match validated {
7225            Ok(()) => {}
7226            Err(error) => {
7227                self.remove_unallocated_durable_managed_spawn_v2(&request)?;
7228                return Err(error);
7229            }
7230        }
7231        let legacy = ManagedWorktreeSpawnRequest {
7232            spawn_spec: request.spawn_spec.clone(),
7233            worktree_profile_id: request.worktree_profile_id.clone(),
7234        };
7235        match self
7236            .spawn_managed_worktree_inner(
7237                legacy,
7238                Some(&request.spawn_spec.idempotency_key),
7239            )
7240            .await
7241        {
7242            Ok(receipt) => {
7243                self.commit_durable_managed_spawn_v2(&request, receipt.clone())?;
7244                Ok(receipt)
7245            }
7246            Err(error) => {
7247                self.remove_unallocated_durable_managed_spawn_v2(&request)?;
7248                Err(error)
7249            }
7250        }
7251    }
7252
7253    fn managed_profile(
7254        &self,
7255        workspace_id: &WorkspaceId,
7256        profile_id: &WorktreeProfileId,
7257    ) -> Result<ManagedWorktreeProfile, NodeFailure> {
7258        if self.worktree_service_modes.get(workspace_id).copied()
7259            != Some(WorktreeServiceMode::Managed)
7260        {
7261            return Err(failure(
7262                NodeFailureCode::UnsupportedSpawnCapability,
7263                "workspace does not enable managed worktrees",
7264            ));
7265        }
7266        self.managed_worktree_profiles.get(workspace_id)
7267            .and_then(|profiles| profiles.get(profile_id))
7268            .cloned()
7269            .ok_or_else(|| failure(
7270                NodeFailureCode::InvalidRequest,
7271                "managed worktree profile is unavailable for this workspace",
7272            ))
7273    }
7274
7275    async fn reconcile_failed_allocation(
7276        &self,
7277        lease: &ManagedWorktreeLeaseRecord,
7278        source_root: &str,
7279    ) {
7280        match list_git_worktrees(source_root).await {
7281            Ok(worktrees) => {
7282                match worktrees.iter().find(|item| worktree_paths_equal(&item.path, &lease.target_root)) {
7283                    None => {
7284                        match std::fs::symlink_metadata(&lease.target_root) {
7285                            Err(error) if error.kind() == io::ErrorKind::NotFound => {
7286                                self.managed_worktrees.lock()
7287                                    .unwrap_or_else(|poisoned| poisoned.into_inner())
7288                                    .remove_unmutated(&lease.lease_id);
7289                                let _ = self.persist_state();
7290                            }
7291                            _ => self.mark_managed_recovery_required(
7292                                &lease.lease_id,
7293                                ManagedWorktreeCleanupFailure::OwnershipConflict,
7294                            ),
7295                        }
7296                    }
7297                    Some(item) if exact_owned_worktree(
7298                        lease,
7299                        &item.path,
7300                        item.branch.as_deref(),
7301                        &item.head,
7302                    ) => {
7303                        let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
7304                    }
7305                    Some(_) => self.mark_managed_recovery_required(
7306                        &lease.lease_id,
7307                        ManagedWorktreeCleanupFailure::OwnershipConflict,
7308                    ),
7309                }
7310            }
7311            Err(_) => self.mark_managed_recovery_required(
7312                &lease.lease_id,
7313                ManagedWorktreeCleanupFailure::Backend,
7314            ),
7315        }
7316    }
7317
7318    async fn reconcile_managed_worktrees(&self) {
7319        let leases = self.managed_worktrees.lock()
7320            .unwrap_or_else(|poisoned| poisoned.into_inner())
7321            .active_records()
7322            .cloned()
7323            .collect::<Vec<_>>();
7324        for lease in leases {
7325            let profile = match self.managed_profile(&lease.source_workspace_id, &lease.profile_id) {
7326                Ok(profile) if profile.revision() == &lease.profile_revision => profile,
7327                _ => {
7328                    self.mark_managed_recovery_required(
7329                        &lease.lease_id,
7330                        ManagedWorktreeCleanupFailure::OwnershipConflict,
7331                    );
7332                    continue;
7333                }
7334            };
7335            if profile.validate_target_authority(&lease.lease_id, &lease.target_root).is_err() {
7336                self.mark_managed_recovery_required(
7337                    &lease.lease_id,
7338                    ManagedWorktreeCleanupFailure::OwnershipConflict,
7339                );
7340                continue;
7341            }
7342            let source_root = match self.workspace_root(&lease.source_workspace_id) {
7343                Ok(root) => root,
7344                Err(_) => {
7345                    self.mark_managed_recovery_required(
7346                        &lease.lease_id,
7347                        ManagedWorktreeCleanupFailure::OwnershipConflict,
7348                    );
7349                    continue;
7350                }
7351            };
7352            let worktrees = match list_git_worktrees(&source_root).await {
7353                Ok(worktrees) => worktrees,
7354                Err(_) => {
7355                    self.mark_managed_recovery_required(
7356                        &lease.lease_id,
7357                        ManagedWorktreeCleanupFailure::Backend,
7358                    );
7359                    continue;
7360                }
7361            };
7362            let listed = worktrees.iter()
7363                .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
7364            let Some(listed) = listed else {
7365                match std::fs::symlink_metadata(&lease.target_root) {
7366                    Err(error) if error.kind() == io::ErrorKind::NotFound
7367                        && lease.state == ManagedWorktreeLeaseState::Allocating
7368                        && !lease.has_holders() => {
7369                            let removed = self.managed_worktrees.lock()
7370                                .unwrap_or_else(|poisoned| poisoned.into_inner())
7371                                .tombstone(&lease.lease_id, unix_time_ms());
7372                            if self.persist_state().is_ok() && removed.is_some() {
7373                                self.publish(NodeEvent::ManagedWorktreeRemoved {
7374                                    lease_id: lease.lease_id.clone(),
7375                                });
7376                            }
7377                        }
7378                    _ => self.mark_managed_recovery_required(
7379                        &lease.lease_id,
7380                        ManagedWorktreeCleanupFailure::OwnershipConflict,
7381                    ),
7382                }
7383                continue;
7384            };
7385            if listed.is_main || listed.is_bare || !exact_owned_worktree(
7386                &lease,
7387                &listed.path,
7388                listed.branch.as_deref(),
7389                &listed.head,
7390            ) {
7391                self.mark_managed_recovery_required(
7392                    &lease.lease_id,
7393                    ManagedWorktreeCleanupFailure::OwnershipConflict,
7394                );
7395                continue;
7396            }
7397            if !self.workspaces.read()
7398                .unwrap_or_else(|poisoned| poisoned.into_inner())
7399                .contains_key(&lease.workspace_id)
7400            {
7401                if self.register_workspace(lease.workspace_id.clone(), listed.path.clone()).await.is_err() {
7402                    self.mark_managed_recovery_required(
7403                        &lease.lease_id,
7404                        ManagedWorktreeCleanupFailure::Backend,
7405                    );
7406                    continue;
7407                }
7408            }
7409            let snapshot = {
7410                let mut registry = self.managed_worktrees.lock()
7411                    .unwrap_or_else(|poisoned| poisoned.into_inner());
7412                let current = registry.get_mut(&lease.lease_id)
7413                    .expect("reconciled managed lease remains present");
7414                current.expected_head = Some(listed.head.clone());
7415                current.cleanup_failure = None;
7416                current.state = if current.has_holders() {
7417                    ManagedWorktreeLeaseState::InUse
7418                } else if current.retention == ManagedWorktreeRetention::Retain {
7419                    ManagedWorktreeLeaseState::Retained
7420                } else {
7421                    ManagedWorktreeLeaseState::Ready
7422                };
7423                current.updated_at_unix_ms = unix_time_ms();
7424                current.snapshot()
7425            };
7426            if self.persist_state().is_err() {
7427                self.mark_managed_recovery_required(
7428                    &lease.lease_id,
7429                    ManagedWorktreeCleanupFailure::Backend,
7430                );
7431                continue;
7432            }
7433            self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
7434            if snapshot.state == ManagedWorktreeLeaseState::Ready {
7435                let _ = self.cleanup_managed_worktree(&lease.lease_id, false).await;
7436            }
7437        }
7438    }
7439
7440    /// Crash-recovery startup sweep: durably capture the pack for every
7441    /// `Live` managed record whose most recent session has not yet produced
7442    /// one (recovers a session whose `Exited{0}` event was observed but the
7443    /// Node crashed before the export finished). The reactive fast path for
7444    /// a single just-exited record does not go through this — see
7445    /// `drive_runtime_until_shutdown`, which resolves and spawns directly so
7446    /// it can capture the exact `SessionAddress` before `publish_control`
7447    /// clears it, rather than re-deriving it from (by then likely already
7448    /// `Dormant`) record state the way a rescan naturally would.
7449    ///
7450    /// Returns as soon as every eligible record's export has been *spawned*,
7451    /// not completed — each one runs on its own detached task (see
7452    /// `reconcile_context_pack_export_for_record`'s own doc comment for why:
7453    /// `NativeRuntime::tick()` has not even started its own loop yet at the
7454    /// point this is called from `NodeServer::run()`, so nothing could ever
7455    /// drain the history round trip if this awaited it inline here either).
7456    /// Best-effort throughout: never panics, no unwrap/expect; every failure
7457    /// is a silent skip for THIS record — an unusable source or a racing
7458    /// record is not surfaced to any caller.
7459    async fn reconcile_context_pack_exports(self: &Arc<Self>) {
7460        let candidates: Vec<(SessionRecordId, SessionAddress)> = self
7461            .session_records
7462            .lock()
7463            .unwrap_or_else(|poisoned| poisoned.into_inner())
7464            .records
7465            .values()
7466            .filter(|record| {
7467                record.state == ManagedSessionState::Live
7468                    && record.exported_context.is_none()
7469                    && record.provider_session.is_some()
7470            })
7471            .filter_map(|record| {
7472                record
7473                    .active_session
7474                    .clone()
7475                    .map(|session| (record.record_id.clone(), session))
7476            })
7477            .collect();
7478        for (record_id, session) in candidates {
7479            let shared = Arc::clone(self);
7480            tokio::spawn(async move {
7481                shared
7482                    .reconcile_context_pack_export_for_record(&record_id, session)
7483                    .await;
7484            });
7485        }
7486    }
7487
7488    /// Runs on its own detached task (`tokio::spawn`, see both call sites),
7489    /// never inline on `drive_runtime_until_shutdown`'s own task: the history
7490    /// round trip this performs (`discover_history`/`load_history`, via
7491    /// `export_context_pack_for_session_record`) can only ever settle once
7492    /// `NativeRuntime::tick()` runs again to drain the authority worker's
7493    /// completion — and `tick()` is exclusively owned by, and only ever
7494    /// called from, that same drive-loop task. Awaiting this inline there
7495    /// would starve `tick()` and this call could never observe its own
7496    /// command complete.
7497    ///
7498    /// Because this now genuinely races the drive loop's own
7499    /// `publish_control` (which downgrades this exact record from `Live` to
7500    /// `Dormant`, clearing `active_session`, for the very `Exited` event that
7501    /// triggered this reconcile), the record legitimately may already be
7502    /// `Dormant` by the time this reads it, or may transition mid-flight —
7503    /// `allow_clean_detachment: true` on the downstream export call is what
7504    /// tolerates exactly that transition (and only that transition; see
7505    /// `session_record_export_target_matches`).
7506    async fn reconcile_context_pack_export_for_record(
7507        &self,
7508        record_id: &SessionRecordId,
7509        session: SessionAddress,
7510    ) {
7511        let Ok(record) = self.record(record_id) else {
7512            return;
7513        };
7514        if !matches!(
7515            record.state,
7516            ManagedSessionState::Live | ManagedSessionState::Dormant
7517        ) || record.exported_context.is_some()
7518            || record.provider_session.is_none()
7519        {
7520            return;
7521        }
7522        // Pack bytes are already durably committed inside this call, via
7523        // commit_context_pack_for_session_record's own durable
7524        // write-through (context_pack_store) — no separate step here.
7525        // Unclean exits (non-zero/forced/no loadable history) are already
7526        // rejected by context_pack_source_status_is_usable inside
7527        // materialize_context_pack: this simply returns Err and is skipped.
7528        let Ok(receipt) = self
7529            .export_context_pack_for_session_record(&record.record_id, &session, true)
7530            .await
7531        else {
7532            return;
7533        };
7534        self.set_exported_context(&record.record_id, receipt);
7535    }
7536
7537    /// Records the record's most recent clean-exit pack. No-op (not an
7538    /// error) if the record disappeared, or if it already carries an
7539    /// `exported_context` — first-write-wins, matching the harness-side
7540    /// idempotent reconciliation that consumes this field.
7541    fn set_exported_context(&self, record_id: &SessionRecordId, receipt: ResolvedContextPackReceipt) {
7542        let transaction = self
7543            .state_transaction
7544            .lock()
7545            .unwrap_or_else(|poisoned| poisoned.into_inner());
7546        let (previous, updated) = {
7547            let mut records = self
7548                .session_records
7549                .lock()
7550                .unwrap_or_else(|poisoned| poisoned.into_inner());
7551            let Some(record) = records.records.get_mut(record_id) else {
7552                return;
7553            };
7554            if record.exported_context.is_some() {
7555                return;
7556            }
7557            let previous = record.clone();
7558            record.exported_context = Some(receipt);
7559            record.updated_at_unix_ms = unix_time_ms().max(record.updated_at_unix_ms);
7560            (previous, record.clone())
7561        };
7562        if self.persist_state_locked().is_err() {
7563            self.session_records
7564                .lock()
7565                .unwrap_or_else(|poisoned| poisoned.into_inner())
7566                .records
7567                .insert(record_id.clone(), previous);
7568            return;
7569        }
7570        drop(transaction);
7571        self.publish_record(updated);
7572    }
7573
7574    /// Resolves the managed record and its exact `SessionAddress` currently
7575    /// bound to `instance_id` at exactly `generation`, used by the reactive
7576    /// `Exited{0}` hook in `drive_runtime_until_shutdown` to target a single
7577    /// record without a full rescan. Mirrors the lookup already inlined in
7578    /// `publish_control`. Captured synchronously, before `publish_control`
7579    /// for the same event clears the record's own `active_session` — the
7580    /// caller passes the resolved address through to a detached
7581    /// reconciliation task rather than re-deriving it from the record later,
7582    /// after that race has necessarily already happened.
7583    fn managed_record_export_target_for_instance(
7584        &self,
7585        instance_id: AgentInstanceId,
7586        generation: SessionGeneration,
7587    ) -> Option<(SessionRecordId, SessionAddress)> {
7588        let bindings = self
7589            .session_bindings
7590            .lock()
7591            .unwrap_or_else(|poisoned| poisoned.into_inner());
7592        let binding = bindings.get(&instance_id).filter(|binding| binding.generation == generation)?;
7593        let record_id = binding.record_id.clone()?;
7594        Some((
7595            record_id,
7596            SessionAddress {
7597                workspace_id: binding.workspace_id.clone(),
7598                session: SessionKey { instance_id, generation },
7599            },
7600        ))
7601    }
7602
7603    fn mark_managed_recovery_required(
7604        &self,
7605        lease_id: &ManagedWorktreeLeaseId,
7606        failure_kind: ManagedWorktreeCleanupFailure,
7607    ) {
7608        let snapshot = {
7609            let mut registry = self.managed_worktrees.lock()
7610                .unwrap_or_else(|poisoned| poisoned.into_inner());
7611            let Some(lease) = registry.get_mut(lease_id) else { return };
7612            lease.state = ManagedWorktreeLeaseState::RecoveryRequired;
7613            lease.cleanup_failure = Some(failure_kind);
7614            lease.updated_at_unix_ms = unix_time_ms();
7615            lease.snapshot()
7616        };
7617        let _ = self.persist_state();
7618        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
7619    }
7620
7621    fn set_persistence_error(&self, error: Option<String>) {
7622        *self
7623            .persistence_error
7624            .write()
7625            .unwrap_or_else(|poisoned| poisoned.into_inner()) = error
7626            .map(|message| session_registry::sanitized_persistence_summary(&message));
7627    }
7628
7629    fn persist_state(&self) -> io::Result<()> {
7630        let _transaction = self
7631            .state_transaction
7632            .lock()
7633            .unwrap_or_else(|poisoned| poisoned.into_inner());
7634        self.persist_state_locked()
7635    }
7636
7637    fn persist_state_locked(&self) -> io::Result<()> {
7638        let workspaces = self
7639            .workspaces
7640            .read()
7641            .unwrap_or_else(|poisoned| poisoned.into_inner())
7642            .clone();
7643        let records = self
7644            .session_records
7645            .lock()
7646            .unwrap_or_else(|poisoned| poisoned.into_inner())
7647            .records
7648            .values()
7649            .cloned()
7650            .collect::<Vec<_>>();
7651        let mut managed = self.managed_worktrees
7652            .lock()
7653            .unwrap_or_else(|poisoned| poisoned.into_inner());
7654        managed.reattach_record_holders(
7655            &managed_worktree_record_holders(&records),
7656            unix_time_ms(),
7657        );
7658        let materializations = self
7659            .materializations
7660            .lock()
7661            .unwrap_or_else(|poisoned| poisoned.into_inner())
7662            .values()
7663            .cloned()
7664            .collect::<Vec<_>>();
7665        let managed_spawn_replays = self.managed_spawn_replays
7666            .lock()
7667            .unwrap_or_else(|poisoned| poisoned.into_inner())
7668            .values()
7669            .cloned()
7670            .collect::<Vec<_>>();
7671        let result = session_registry::save_v10(
7672            self.state_path.as_deref(),
7673            &self.node_id,
7674            &workspaces,
7675            &records,
7676            &managed.records(),
7677            &managed.tombstones(),
7678            &materializations,
7679            &managed_spawn_replays,
7680        );
7681        match &result {
7682            Ok(warning) => self.set_persistence_error(warning.clone()),
7683            Err(_) => self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned())),
7684        }
7685        result.map(|_| ())
7686    }
7687
7688    async fn begin_shutdown(&self) -> Result<(), NodeFailure> {
7689        let _mutation_guard = self.mutation_gate.lock().await;
7690        self.begin_shutdown_locked().await
7691    }
7692
7693    async fn begin_shutdown_locked(&self) -> Result<(), NodeFailure> {
7694        if self.shutdown.swap(true, Ordering::AcqRel) {
7695            return Ok(());
7696        }
7697        SessionLifecycleHook::on_shutdown(self);
7698        let deadline = Instant::now() + Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
7699        let mut first_error = None;
7700        for session in &self.handle.snapshot().sessions {
7701            let remaining = deadline.saturating_duration_since(Instant::now());
7702            if remaining.is_zero() {
7703                first_error.get_or_insert_with(|| {
7704                    failure(NodeFailureCode::BackendBusy, "stop-all dispatch exceeded its bounded deadline")
7705                });
7706                break;
7707            }
7708            if let Err(error) = self
7709                .dispatch_bounded(
7710                    ControlCommand::Stop {
7711                        instance_id: session.instance_id,
7712                        force: true,
7713                    },
7714                    remaining,
7715                )
7716                .await
7717            {
7718                first_error.get_or_insert(error);
7719            }
7720        }
7721        self.shutdown_notify.notify_waiters();
7722        match first_error {
7723            Some(error) => Err(error),
7724            None => Ok(()),
7725        }
7726    }
7727
7728    fn ensure_binding_capacity(&self) -> Result<(), NodeFailure> {
7729        let bindings = self
7730            .session_bindings
7731            .lock()
7732            .unwrap_or_else(|poisoned| poisoned.into_inner());
7733        if bindings.len() >= CONTROL_SESSIONS_MAX {
7734            Err(failure(
7735                NodeFailureCode::BackendBusy,
7736                "node session-binding capacity is full; remove an existing session first",
7737            ))
7738        } else {
7739            Ok(())
7740        }
7741    }
7742
7743    /// One-owner workspace/env collision guard: a live session on
7744    /// `workspace_id` with a different `environment_profile` (option
7745    /// equality on the resolved receipt, including Some vs None) blocks
7746    /// another spawn on that same cwd. Same profile may still share the
7747    /// workspace; distinct profiles need distinct workspace ids (managed
7748    /// worktree).
7749    fn ensure_workspace_environment_profile_compatible(
7750        &self,
7751        workspace_id: &WorkspaceId,
7752        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
7753    ) -> Result<(), NodeFailure> {
7754        let bindings = self
7755            .session_bindings
7756            .lock()
7757            .unwrap_or_else(|poisoned| poisoned.into_inner());
7758        for binding in bindings.values() {
7759            if &binding.workspace_id != workspace_id {
7760                continue;
7761            }
7762            if binding.environment_profile.as_ref() != environment_profile {
7763                return Err(failure(
7764                    NodeFailureCode::WorkspaceBusy,
7765                    "workspace already has a live session with a different environment profile; use a managed worktree for concurrent distinct ProviderHome/env bindings on one repo",
7766                ));
7767            }
7768        }
7769        Ok(())
7770    }
7771
7772    #[cfg(test)]
7773    fn bind_session(
7774        &self,
7775        instance_id: AgentInstanceId,
7776        workspace_id: WorkspaceId,
7777        generation: SessionGeneration,
7778    ) {
7779        self.bind_session_with_policy(
7780            &SessionAddress {
7781                workspace_id,
7782                session: SessionKey { instance_id, generation },
7783            },
7784            ProviderRuntimePolicy::raw_pty(),
7785            None,
7786        );
7787    }
7788
7789    #[cfg(test)]
7790    fn bind_session_with_policy(
7791        &self,
7792        address: &SessionAddress,
7793        runtime_policy: ProviderRuntimePolicy,
7794        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7795    ) {
7796        self.bind_session_with_materialization(
7797            address,
7798            runtime_policy,
7799            environment_profile,
7800            None,
7801            None,
7802            None,
7803        );
7804    }
7805
7806    fn bind_session_with_materialization(
7807        &self,
7808        address: &SessionAddress,
7809        runtime_policy: ProviderRuntimePolicy,
7810        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7811        bundle: Option<ResolvedBundleReceipt>,
7812        context: Option<ResolvedContextPackReceipt>,
7813        materialization_id: Option<MaterializationId>,
7814    ) {
7815        let mut bindings = self
7816            .session_bindings
7817            .lock()
7818            .unwrap_or_else(|poisoned| poisoned.into_inner());
7819        debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7820        debug_assert!(!bindings.contains_key(&address.session.instance_id));
7821        bindings.insert(address.session.instance_id, SessionBinding {
7822            workspace_id: address.workspace_id.clone(),
7823            generation: address.session.generation,
7824            runtime_policy,
7825            pending_resume: None,
7826            record_id: None,
7827            managed_worktree_lease_id: None,
7828            environment_profile,
7829            bundle,
7830            context,
7831            materialization_id,
7832        });
7833    }
7834
7835    #[cfg(test)]
7836    fn bind_managed_session(
7837        &self,
7838        address: &SessionAddress,
7839        record_id: SessionRecordId,
7840        runtime_policy: ProviderRuntimePolicy,
7841        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7842    ) {
7843        self.bind_managed_session_with_materialization(
7844            address,
7845            record_id,
7846            runtime_policy,
7847            environment_profile,
7848            None,
7849            None,
7850            None,
7851        );
7852    }
7853
7854    fn bind_managed_session_with_materialization(
7855        &self,
7856        address: &SessionAddress,
7857        record_id: SessionRecordId,
7858        runtime_policy: ProviderRuntimePolicy,
7859        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7860        bundle: Option<ResolvedBundleReceipt>,
7861        context: Option<ResolvedContextPackReceipt>,
7862        materialization_id: Option<MaterializationId>,
7863    ) {
7864        let mut bindings = self
7865            .session_bindings
7866            .lock()
7867            .unwrap_or_else(|poisoned| poisoned.into_inner());
7868        debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7869        debug_assert!(!bindings.contains_key(&address.session.instance_id));
7870        bindings.insert(
7871            address.session.instance_id,
7872            SessionBinding {
7873                workspace_id: address.workspace_id.clone(),
7874                generation: address.session.generation,
7875                runtime_policy,
7876                pending_resume: None,
7877                record_id: Some(record_id),
7878                managed_worktree_lease_id: None,
7879                environment_profile,
7880                bundle,
7881                context,
7882                materialization_id,
7883            },
7884        );
7885    }
7886
7887    #[cfg(test)]
7888    fn bind_spawn_session(
7889        &self,
7890        address: &SessionAddress,
7891        provider: AgentId,
7892        mode: SessionMode,
7893        runtime_policy: ProviderRuntimePolicy,
7894        record_policy: SpawnRecordPolicy,
7895        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7896    ) -> Result<Option<SessionRecordId>, NodeFailure> {
7897        self.bind_spawn_session_with_materialization(
7898            address,
7899            provider,
7900            mode,
7901            runtime_policy,
7902            record_policy,
7903            environment_profile,
7904            None,
7905            None,
7906            None,
7907        )
7908    }
7909
7910    fn bind_spawn_session_with_materialization(
7911        &self,
7912        address: &SessionAddress,
7913        provider: AgentId,
7914        mode: SessionMode,
7915        runtime_policy: ProviderRuntimePolicy,
7916        record_policy: SpawnRecordPolicy,
7917        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7918        bundle: Option<ResolvedBundleReceipt>,
7919        context: Option<ResolvedContextPackReceipt>,
7920        materialization_id: Option<MaterializationId>,
7921    ) -> Result<Option<SessionRecordId>, NodeFailure> {
7922        if record_policy == SpawnRecordPolicy::ProviderIdentityOnly
7923            && !runtime_policy.provider_session_identity
7924        {
7925            self.bind_session_with_materialization(
7926                address,
7927                runtime_policy,
7928                environment_profile,
7929                bundle,
7930                context,
7931                materialization_id,
7932            );
7933            return Ok(None);
7934        }
7935        let record_owns_lifecycle = runtime_policy.provider_session_identity;
7936        let record = self.new_record(
7937            address,
7938            provider,
7939            mode,
7940            active_record_state(runtime_policy.provider_session_identity, false),
7941            None,
7942            record_owns_lifecycle.then(|| environment_profile.clone()).flatten(),
7943            record_owns_lifecycle.then(|| bundle.clone()).flatten(),
7944            record_owns_lifecycle.then(|| context.clone()).flatten(),
7945        )?;
7946        let record_id = record.record_id.clone();
7947        let transaction = self
7948            .state_transaction
7949            .lock()
7950            .unwrap_or_else(|poisoned| poisoned.into_inner());
7951        self.insert_record(record.clone())?;
7952        let previous_materialization = if record_owns_lifecycle {
7953            if let Some(materialization_id) = materialization_id.as_ref() {
7954                let mut materializations = self
7955                    .materializations
7956                    .lock()
7957                    .unwrap_or_else(|poisoned| poisoned.into_inner());
7958                let Some(ownership) = materializations.get_mut(materialization_id) else {
7959                    drop(materializations);
7960                    self.remove_record_memory(&record_id);
7961                    return Err(failure(
7962                        NodeFailureCode::BackendOperationFailed,
7963                        "session environment ownership disappeared before binding",
7964                    ));
7965                };
7966                let previous = ownership.clone();
7967                if ownership
7968                    .transfer_to_record(record_id.clone(), unix_time_ms())
7969                    .is_err()
7970                {
7971                    drop(materializations);
7972                    self.remove_record_memory(&record_id);
7973                    return Err(failure(
7974                        NodeFailureCode::BackendOperationFailed,
7975                        "session environment ownership transfer failed",
7976                    ));
7977                }
7978                Some(previous)
7979            } else {
7980                None
7981            }
7982        } else {
7983            None
7984        };
7985        self.bind_managed_session_with_materialization(
7986            address,
7987            record_id.clone(),
7988            runtime_policy,
7989            environment_profile,
7990            bundle,
7991            context,
7992            materialization_id.clone(),
7993        );
7994        if let Err(error) = self.persist_state_locked() {
7995            self.remove_binding(address);
7996            self.remove_record_memory(&record_id);
7997            if let (Some(materialization_id), Some(previous)) =
7998                (materialization_id.as_ref(), previous_materialization)
7999            {
8000                self.materializations
8001                    .lock()
8002                    .unwrap_or_else(|poisoned| poisoned.into_inner())
8003                    .insert(materialization_id.clone(), previous);
8004            }
8005            return Err(persistence_failure(error));
8006        }
8007        drop(transaction);
8008        self.publish_record(record);
8009        Ok(Some(record_id))
8010    }
8011
8012    fn allocate_record_id(&self) -> Result<SessionRecordId, NodeFailure> {
8013        for _ in 0..8 {
8014            let bytes = random_nonce().map_err(|error| {
8015                failure(
8016                    NodeFailureCode::BackendOperationFailed,
8017                    &format!("session record identity generation failed: {error}"),
8018                )
8019            })?;
8020            let mut value = String::with_capacity(27);
8021            value.push_str("sr-");
8022            for byte in &bytes[..12] {
8023                use std::fmt::Write as _;
8024                let _ = write!(&mut value, "{byte:02x}");
8025            }
8026            let record_id = SessionRecordId::new(value).map_err(|error| {
8027                failure(NodeFailureCode::BackendOperationFailed, &error.to_string())
8028            })?;
8029            let records = self
8030                .session_records
8031                .lock()
8032                .unwrap_or_else(|poisoned| poisoned.into_inner());
8033            if !records.records.contains_key(&record_id) {
8034                return Ok(record_id);
8035            }
8036        }
8037        Err(failure(
8038            NodeFailureCode::BackendBusy,
8039            "could not allocate a unique session record ID",
8040        ))
8041    }
8042
8043    fn allocate_task_id(&self) -> Result<TaskId, NodeFailure> {
8044        for _ in 0..8 {
8045            let bytes = random_nonce().map_err(|error| {
8046                failure(
8047                    NodeFailureCode::BackendOperationFailed,
8048                    &format!("task identity generation failed: {error}"),
8049                )
8050            })?;
8051            let mut nonce = [0_u8; 12];
8052            nonce.copy_from_slice(&bytes[..12]);
8053            let task_id = TaskId::from_nonce(nonce);
8054            let records = self
8055                .session_records
8056                .lock()
8057                .unwrap_or_else(|poisoned| poisoned.into_inner());
8058            if !records.records.values().any(|record| {
8059                record.task_binding.as_ref().and_then(|binding| binding.task_id.as_ref())
8060                    == Some(&task_id)
8061            }) {
8062                return Ok(task_id);
8063            }
8064        }
8065        Err(failure(
8066            NodeFailureCode::BackendBusy,
8067            "could not allocate a unique task ID",
8068        ))
8069    }
8070
8071    fn default_record_name(&self, provider: &AgentId) -> String {
8072        let records = self
8073            .session_records
8074            .lock()
8075            .unwrap_or_else(|poisoned| poisoned.into_inner());
8076        let ordinal = records
8077            .records
8078            .values()
8079            .filter(|record| &record.provider == provider)
8080            .count()
8081            .saturating_add(1);
8082        format!("{} #{ordinal}", provider.as_str())
8083    }
8084
8085    fn new_record(
8086        &self,
8087        address: &SessionAddress,
8088        provider: AgentId,
8089        mode: SessionMode,
8090        state: ManagedSessionState,
8091        provider_session: Option<gate4agent_types::ProviderSessionIdentity>,
8092        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
8093        bundle: Option<ResolvedBundleReceipt>,
8094        context: Option<ResolvedContextPackReceipt>,
8095    ) -> Result<ManagedSessionRecord, NodeFailure> {
8096        let canonical_root = self.workspace_root(&address.workspace_id)?;
8097        let now = unix_time_ms();
8098        Ok(ManagedSessionRecord {
8099            record_id: self.allocate_record_id()?,
8100            display_name: self.m4a_nick_of(address.session.instance_id).unwrap_or_else(|| self.default_record_name(&provider)),
8101            provider,
8102            mode,
8103            state,
8104            workspace_id: address.workspace_id.clone(),
8105            canonical_root: opaque_windows_path(canonical_root),
8106            provider_session,
8107            active_session: Some(address.clone()),
8108            environment_profile,
8109            bundle,
8110            context_id: context.as_ref().map(|receipt| receipt.id.clone()),
8111            context,
8112            exported_context: None,
8113            task_binding: None,
8114            created_at_unix_ms: now,
8115            updated_at_unix_ms: now,
8116            last_error: None,
8117        })
8118    }
8119
8120    fn insert_record(&self, record: ManagedSessionRecord) -> Result<(), NodeFailure> {
8121        let mut records = self
8122            .session_records
8123            .lock()
8124            .unwrap_or_else(|poisoned| poisoned.into_inner());
8125        if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
8126            return Err(failure(
8127                NodeFailureCode::BackendBusy,
8128                "managed session record capacity is full; forget an inactive record first",
8129            ));
8130        }
8131        if let Some(identity) = record.provider_session.as_ref() {
8132            if records.records.values().any(|candidate| {
8133                candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8134                    session_registry::same_provider_session(
8135                        &candidate.provider,
8136                        candidate_identity,
8137                        &record.provider,
8138                        identity,
8139                    )
8140                })
8141            }) {
8142                return Err(failure(
8143                    NodeFailureCode::SessionRecordConflict,
8144                    "provider session is already represented by another managed record",
8145                ));
8146            }
8147        }
8148        match records.records.entry(record.record_id.clone()) {
8149            std::collections::btree_map::Entry::Vacant(entry) => {
8150                entry.insert(record);
8151            }
8152            std::collections::btree_map::Entry::Occupied(_) => {
8153                return Err(failure(
8154                    NodeFailureCode::SessionRecordConflict,
8155                    "session record ID collision",
8156                ));
8157            }
8158        }
8159        Ok(())
8160    }
8161
8162    fn record(&self, record_id: &SessionRecordId) -> Result<ManagedSessionRecord, NodeFailure> {
8163        self.session_records
8164            .lock()
8165            .unwrap_or_else(|poisoned| poisoned.into_inner())
8166            .records
8167            .get(record_id)
8168            .cloned()
8169            .ok_or_else(|| {
8170                failure(
8171                    NodeFailureCode::UnknownSessionRecord,
8172                    "managed session record does not exist",
8173                )
8174            })
8175    }
8176
8177    fn revalidate_session_record_preview(
8178        &self,
8179        expected: &ManagedSessionRecord,
8180        identity: &ProviderSessionIdentity,
8181    ) -> Result<(), NodeFailure> {
8182        let current = self.record(&expected.record_id)?;
8183        let current_root = self.workspace_root(&current.workspace_id)?;
8184        if current.provider != expected.provider
8185            || current.workspace_id != expected.workspace_id
8186            || current.canonical_root != expected.canonical_root
8187            || current.provider_session.as_ref() != Some(identity)
8188            || !platform::roots_equal(
8189                &current_root,
8190                windows_path_text(&current.canonical_root),
8191            )
8192        {
8193            return Err(failure(
8194                NodeFailureCode::SessionRecordConflict,
8195                "managed session changed while its preview was loading",
8196            ));
8197        }
8198        Ok(())
8199    }
8200
8201    fn revalidate_session_record_context_export(
8202        &self,
8203        expected: &ManagedSessionRecord,
8204        identity: Option<&ProviderSessionIdentity>,
8205        session: &SessionAddress,
8206        allow_clean_detachment: bool,
8207    ) -> Result<(), NodeFailure> {
8208        let current = self.record(&expected.record_id)?;
8209        let current_root = self.workspace_root(&current.workspace_id)?;
8210        let snapshot = self.internal_session_snapshot(session)?;
8211        let binding = self
8212            .session_bindings
8213            .lock()
8214            .unwrap_or_else(|poisoned| poisoned.into_inner())
8215            .get(&session.session.instance_id)
8216            .cloned()
8217            .ok_or_else(|| {
8218                failure(
8219                    NodeFailureCode::SessionRecordConflict,
8220                    "managed session runtime binding disappeared during context export",
8221                )
8222            })?;
8223        if !session_record_context_export_binding_is_exact(
8224            expected,
8225            &current,
8226            identity,
8227            session,
8228            &binding,
8229            &snapshot.agent_id,
8230            &current_root,
8231            allow_clean_detachment,
8232        ) || !session_record_context_export_source_is_usable(
8233            &current,
8234            &snapshot.status,
8235            allow_clean_detachment,
8236        ) {
8237            return Err(failure(
8238                NodeFailureCode::SessionRecordConflict,
8239                "managed session binding changed during context export",
8240            ));
8241        }
8242        Ok(())
8243    }
8244
8245    fn revalidate_loaded_history(
8246        &self,
8247        session: &SessionAddress,
8248        candidate_id: &str,
8249        loaded: &HistorySessionRecord,
8250    ) -> Result<(), NodeFailure> {
8251        let snapshot = self.internal_session_snapshot(session)?;
8252        if snapshot.history.pending.is_some()
8253            || snapshot.history.loaded_candidate_id.as_deref() != Some(candidate_id)
8254            || snapshot.history.loaded.as_ref() != Some(loaded)
8255        {
8256            return Err(failure(
8257                NodeFailureCode::SessionRecordConflict,
8258                "managed session history changed during context export",
8259            ));
8260        }
8261        Ok(())
8262    }
8263
8264    fn remove_record_memory(&self, record_id: &SessionRecordId) -> Option<ManagedSessionRecord> {
8265        self.session_records
8266            .lock()
8267            .unwrap_or_else(|poisoned| poisoned.into_inner())
8268            .records
8269            .remove(record_id)
8270    }
8271
8272    fn discard_record(&self, record_id: &SessionRecordId) -> Result<(), NodeFailure> {
8273        if self.destroy_record_materialization(record_id)? {
8274            self.publish(NodeEvent::SessionRecordRemoved {
8275                record_id: record_id.clone(),
8276            });
8277            return Ok(());
8278        }
8279        let transaction = self
8280            .state_transaction
8281            .lock()
8282            .unwrap_or_else(|poisoned| poisoned.into_inner());
8283        let Some(removed) = self.remove_record_memory(record_id) else {
8284            return Ok(());
8285        };
8286        if let Err(error) = self.persist_state_locked() {
8287            let _ = self.insert_record(removed);
8288            return Err(persistence_failure(error));
8289        }
8290        drop(transaction);
8291        self.publish(NodeEvent::SessionRecordRemoved {
8292            record_id: record_id.clone(),
8293        });
8294        Ok(())
8295    }
8296
8297    fn publish_record(&self, record: ManagedSessionRecord) {
8298        self.publish(NodeEvent::SessionRecordUpserted { record });
8299    }
8300
8301    fn mark_record_error(
8302        &self,
8303        record_id: &SessionRecordId,
8304        message: &str,
8305    ) -> Result<(), NodeFailure> {
8306        let transaction = self
8307            .state_transaction
8308            .lock()
8309            .unwrap_or_else(|poisoned| poisoned.into_inner());
8310        let previous = {
8311            let mut records = self
8312                .session_records
8313                .lock()
8314                .unwrap_or_else(|poisoned| poisoned.into_inner());
8315            let Some(record) = records.records.get_mut(record_id) else {
8316                return Ok(());
8317            };
8318            let previous = record.clone();
8319            record.state = ManagedSessionState::Unavailable;
8320            record.last_error = Some(session_registry::sanitized_record_error_summary(message));
8321            record.updated_at_unix_ms = unix_time_ms();
8322            previous
8323        };
8324        if let Err(error) = self.persist_state_locked() {
8325            self.session_records
8326                .lock()
8327                .unwrap_or_else(|poisoned| poisoned.into_inner())
8328                .records
8329                .insert(record_id.clone(), previous);
8330            return Err(persistence_failure(error));
8331        }
8332        let record = self.record(record_id)?;
8333        drop(transaction);
8334        self.publish_record(record);
8335        Ok(())
8336    }
8337
8338    fn rename_session_record(
8339        &self,
8340        record_id: &SessionRecordId,
8341        display_name: String,
8342    ) -> Result<ManagedSessionRecord, NodeFailure> {
8343        validate_display_name(&display_name)
8344            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8345        let _transaction = self
8346            .state_transaction
8347            .lock()
8348            .unwrap_or_else(|poisoned| poisoned.into_inner());
8349        let previous = {
8350            let mut records = self
8351                .session_records
8352                .lock()
8353                .unwrap_or_else(|poisoned| poisoned.into_inner());
8354            let record = records.records.get_mut(record_id).ok_or_else(|| {
8355                failure(
8356                    NodeFailureCode::UnknownSessionRecord,
8357                    "managed session record does not exist",
8358                )
8359            })?;
8360            let previous = record.clone();
8361            record.display_name = display_name;
8362            record.updated_at_unix_ms = unix_time_ms();
8363            previous
8364        };
8365        if let Err(error) = self.persist_state_locked() {
8366            self.session_records
8367                .lock()
8368                .unwrap_or_else(|poisoned| poisoned.into_inner())
8369                .records
8370                .insert(record_id.clone(), previous);
8371            return Err(persistence_failure(error));
8372        }
8373        let updated = self.record(record_id)?;
8374        self.publish_record(updated.clone());
8375        Ok(updated)
8376    }
8377
8378    fn set_session_task(
8379        &self,
8380        record_id: &SessionRecordId,
8381        expected_revision: u64,
8382        target: SessionTaskTargetV1,
8383    ) -> Result<ManagedSessionRecord, NodeFailure> {
8384        let transaction = self
8385            .state_transaction
8386            .lock()
8387            .unwrap_or_else(|poisoned| poisoned.into_inner());
8388        let current = self.record(record_id)?;
8389        let current_task_id = current
8390            .task_binding
8391            .as_ref()
8392            .and_then(|binding| binding.task_id.as_ref());
8393        let current_revision = current
8394            .task_binding
8395            .as_ref()
8396            .map_or(0, |binding| binding.revision);
8397        if expected_revision != current_revision {
8398            return Err(failure(
8399                NodeFailureCode::SessionRecordConflict,
8400                "managed session task binding revision changed",
8401            ));
8402        }
8403        let exact = match &target {
8404            SessionTaskTargetV1::Existing { task_id } => current_task_id == Some(task_id),
8405            SessionTaskTargetV1::Clear => current_task_id.is_none(),
8406            SessionTaskTargetV1::New => false,
8407        };
8408        if exact {
8409            return Ok(current);
8410        }
8411        let revision = current_revision.checked_add(1).ok_or_else(|| {
8412            failure(
8413                NodeFailureCode::SessionRecordConflict,
8414                "managed session task binding revision is exhausted",
8415            )
8416        })?;
8417        let task_id = match target {
8418            SessionTaskTargetV1::New => Some(self.allocate_task_id()?),
8419            SessionTaskTargetV1::Existing { task_id } => Some(task_id),
8420            SessionTaskTargetV1::Clear => None,
8421        };
8422        let changed_at_unix_ms = unix_time_ms()
8423            .max(current.updated_at_unix_ms.checked_add(1).ok_or_else(|| {
8424                failure(
8425                    NodeFailureCode::SessionRecordConflict,
8426                    "managed session record timestamp is exhausted",
8427                )
8428            })?);
8429        let updated = {
8430            let mut records = self
8431                .session_records
8432                .lock()
8433                .unwrap_or_else(|poisoned| poisoned.into_inner());
8434            let record = records.records.get_mut(record_id).ok_or_else(|| {
8435                failure(
8436                    NodeFailureCode::UnknownSessionRecord,
8437                    "managed session record does not exist",
8438                )
8439            })?;
8440            record.task_binding = Some(SessionTaskBindingV1 {
8441                revision,
8442                task_id,
8443                changed_at_unix_ms,
8444            });
8445            record.updated_at_unix_ms = changed_at_unix_ms;
8446            record.clone()
8447        };
8448        if let Err(error) = self.persist_state_locked() {
8449            self.session_records
8450                .lock()
8451                .unwrap_or_else(|poisoned| poisoned.into_inner())
8452                .records
8453                .insert(record_id.clone(), current);
8454            return Err(persistence_failure(error));
8455        }
8456        drop(transaction);
8457        self.publish_record(updated.clone());
8458        Ok(updated)
8459    }
8460
8461    fn index_provider_session(
8462        &self,
8463        workspace_id: WorkspaceId,
8464        provider: AgentId,
8465        identity: ProviderSessionIdentity,
8466        display_name: String,
8467    ) -> Result<ManagedSessionRecord, NodeFailure> {
8468        self.index_provider_session_with_policy(
8469            workspace_id,
8470            provider,
8471            identity,
8472            display_name,
8473            false,
8474        )
8475    }
8476
8477    fn index_provider_session_with_policy(
8478        &self,
8479        workspace_id: WorkspaceId,
8480        provider: AgentId,
8481        identity: ProviderSessionIdentity,
8482        display_name: String,
8483        allow_validated_transcript_path: bool,
8484    ) -> Result<ManagedSessionRecord, NodeFailure> {
8485        validate_display_name(&display_name)
8486            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8487        identity
8488            .validate()
8489            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8490        if identity.transcript_path.is_some() && !allow_validated_transcript_path {
8491            return Err(failure(
8492                NodeFailureCode::InvalidRequest,
8493                "provider session reference index v1 does not accept transcript paths",
8494            ));
8495        }
8496        let canonical_root = self.workspace_root(&workspace_id)?;
8497        if !self.enabled_providers.contains(&provider) {
8498            return Err(failure(
8499                NodeFailureCode::InvalidRequest,
8500                "provider is not enabled on this node",
8501            ));
8502        }
8503
8504        let transaction = self
8505            .state_transaction
8506            .lock()
8507            .unwrap_or_else(|poisoned| poisoned.into_inner());
8508        {
8509            let records = self
8510                .session_records
8511                .lock()
8512                .unwrap_or_else(|poisoned| poisoned.into_inner());
8513            if let Some(existing) = records.records.values().find(|candidate| {
8514                candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8515                    candidate_identity.key == identity.key && candidate_identity.id == identity.id
8516                })
8517            }) {
8518                if existing.provider == provider && existing.workspace_id == workspace_id {
8519                    return Ok(existing.clone());
8520                }
8521                return Err(failure(
8522                    NodeFailureCode::SessionRecordConflict,
8523                    "provider session reference conflicts with another provider or workspace",
8524                ));
8525            }
8526            if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
8527                return Err(failure(
8528                    NodeFailureCode::BackendBusy,
8529                    "managed session record capacity is full; forget an inactive record first",
8530                ));
8531            }
8532        }
8533
8534        let now = unix_time_ms();
8535        let record = ManagedSessionRecord {
8536            record_id: self.allocate_record_id()?,
8537            display_name,
8538            provider,
8539            mode: SessionMode::Pty,
8540            state: ManagedSessionState::Dormant,
8541            workspace_id,
8542            canonical_root: opaque_windows_path(canonical_root),
8543            provider_session: Some(identity),
8544            active_session: None,
8545            environment_profile: None,
8546            bundle: None,
8547            context_id: None,
8548            context: None,
8549            exported_context: None,
8550            task_binding: None,
8551            created_at_unix_ms: now,
8552            updated_at_unix_ms: now,
8553            last_error: None,
8554        };
8555        self.insert_record(record.clone())?;
8556        if let Err(error) = self.persist_state_locked() {
8557            self.remove_record_memory(&record.record_id);
8558            return Err(persistence_failure(error));
8559        }
8560        drop(transaction);
8561        self.publish_record(record.clone());
8562        Ok(record)
8563    }
8564
8565    fn bound_address_for_record(&self, record_id: &SessionRecordId) -> Option<SessionAddress> {
8566        self.session_bindings
8567            .lock()
8568            .unwrap_or_else(|poisoned| poisoned.into_inner())
8569            .iter()
8570            .find_map(|(instance_id, binding)| {
8571                (binding.record_id.as_ref() == Some(record_id)).then(|| SessionAddress {
8572                    workspace_id: binding.workspace_id.clone(),
8573                    session: SessionKey {
8574                        instance_id: *instance_id,
8575                        generation: binding.generation,
8576                    },
8577                })
8578            })
8579    }
8580
8581    async fn forget_session_record(
8582        &self,
8583        record_id: &SessionRecordId,
8584    ) -> Result<(), NodeFailure> {
8585        let record = self.record(record_id)?;
8586        if record.active_session.is_some()
8587            || matches!(
8588                record.state,
8589                ManagedSessionState::Live | ManagedSessionState::IdentityPending
8590            )
8591        {
8592            return Err(failure(
8593                NodeFailureCode::SessionRecordBusy,
8594                "stop the managed session before forgetting it",
8595            ));
8596        }
8597        if let Some(address) = self.bound_address_for_record(record_id) {
8598            self.remove_session(&address).await?;
8599        }
8600        self.remove_managed_session_record(record_id, &record.workspace_id).await
8601    }
8602
8603    /// Removes one managed session record from durable state and notifies
8604    /// every listener -- the shared transaction body behind both
8605    /// `forget_session_record`'s operator-driven path (busy-checked above
8606    /// it, and clears any still-live session binding before calling in
8607    /// here) and the retention sweep (`retire_unavailable_session_records`,
8608    /// which only ever selects `ManagedSessionState::Unavailable` records
8609    /// that are already known never-busy and never bound, so it calls
8610    /// straight into this with neither check). Cleans a managed-worktree
8611    /// lease if the record held one, removes the record's environment
8612    /// materialization (or, absent one, removes it from memory directly),
8613    /// persists under `state_transaction` with rollback on persist failure,
8614    /// then publishes `NodeEvent::SessionRecordRemoved` so c2's own
8615    /// legacy-provider bookkeeping (`record_is_legacy`) stays in sync
8616    /// exactly as it does for an operator-driven forget.
8617    async fn remove_managed_session_record(
8618        &self,
8619        record_id: &SessionRecordId,
8620        workspace_id: &WorkspaceId,
8621    ) -> Result<(), NodeFailure> {
8622        let managed_lease_id = self.managed_worktrees.lock()
8623            .unwrap_or_else(|poisoned| poisoned.into_inner())
8624            .lease_for_workspace(workspace_id);
8625        if !self.destroy_record_materialization(record_id)? {
8626            let transaction = self
8627                .state_transaction
8628                .lock()
8629                .unwrap_or_else(|poisoned| poisoned.into_inner());
8630            let removed = self.remove_record_memory(record_id).ok_or_else(|| {
8631                failure(
8632                    NodeFailureCode::UnknownSessionRecord,
8633                    "managed session record does not exist",
8634                )
8635            })?;
8636            if let Err(error) = self.persist_state_locked() {
8637                let _ = self.insert_record(removed);
8638                return Err(persistence_failure(error));
8639            }
8640            drop(transaction);
8641        }
8642        self.publish(NodeEvent::SessionRecordRemoved {
8643            record_id: record_id.clone(),
8644        });
8645        if let Some(lease_id) = managed_lease_id {
8646            if let Some(snapshot) = self.managed_worktrees.lock()
8647                .unwrap_or_else(|poisoned| poisoned.into_inner())
8648                .get(&lease_id)
8649                .map(ManagedWorktreeLeaseRecord::snapshot)
8650            {
8651                self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
8652            }
8653            let _ = self.cleanup_managed_worktree(&lease_id, false).await;
8654        }
8655        Ok(())
8656    }
8657
8658    /// Retires `ManagedSessionState::Unavailable` records against the
8659    /// node's configured `session_record_retention` age/keep-N policy.
8660    /// Never touches `Live`, `IdentityPending`, or `Dormant` records --
8661    /// `Dormant` is resumable and folding it into this sweep would delete
8662    /// records a caller can still act on. Both policy fields default to
8663    /// `0` (disabled); when both are `0` this is a no-op. When both are
8664    /// set, a record is retired only if it fails BOTH tests (older than
8665    /// `age_ms` AND beyond the newest `keep_per_workspace` in its
8666    /// workspace); when only one is set, that one alone decides. Called
8667    /// once at startup (alongside `reconcile_managed_worktrees`) and then
8668    /// on a `SESSION_RECORD_RETENTION_SWEEP_PERIOD` cadence from the drive
8669    /// loop -- see both call sites.
8670    async fn retire_unavailable_session_records(&self) {
8671        let policy = self.session_record_retention;
8672        let age_enabled = policy.age_ms > 0;
8673        let keep_enabled = policy.keep_per_workspace > 0;
8674        if !age_enabled && !keep_enabled {
8675            return;
8676        }
8677        let now = unix_time_ms();
8678        let candidates: Vec<(SessionRecordId, WorkspaceId, AgentId, u64)> = {
8679            self.session_records
8680                .lock()
8681                .unwrap_or_else(|poisoned| poisoned.into_inner())
8682                .records
8683                .values()
8684                .filter(|record| record.state == ManagedSessionState::Unavailable)
8685                .map(|record| {
8686                    (
8687                        record.record_id.clone(),
8688                        record.workspace_id.clone(),
8689                        record.provider.clone(),
8690                        record.updated_at_unix_ms,
8691                    )
8692                })
8693                .collect()
8694        };
8695        if candidates.is_empty() {
8696            return;
8697        }
8698        let mut by_workspace: BTreeMap<WorkspaceId, Vec<(SessionRecordId, AgentId, u64)>> =
8699            BTreeMap::new();
8700        for (record_id, workspace_id, provider, updated_at_unix_ms) in candidates {
8701            by_workspace
8702                .entry(workspace_id)
8703                .or_default()
8704                .push((record_id, provider, updated_at_unix_ms));
8705        }
8706        let reason = if age_enabled && keep_enabled {
8707            "age+keep-n"
8708        } else if age_enabled {
8709            "age"
8710        } else {
8711            "keep-n"
8712        };
8713        let mut retired: u64 = 0;
8714        for (workspace_id, mut records) in by_workspace {
8715            // Newest `updated_at_unix_ms` first, so index `>=
8716            // keep_per_workspace` names exactly "beyond the newest N".
8717            records.sort_by(|left, right| right.2.cmp(&left.2));
8718            for (index, (record_id, provider, updated_at_unix_ms)) in
8719                records.into_iter().enumerate()
8720            {
8721                let record_age_ms = now.saturating_sub(updated_at_unix_ms);
8722                let age_hit = age_enabled && record_age_ms >= policy.age_ms;
8723                let keep_hit = keep_enabled && index >= policy.keep_per_workspace as usize;
8724                let should_retire = if age_enabled && keep_enabled {
8725                    age_hit && keep_hit
8726                } else {
8727                    age_hit || keep_hit
8728                };
8729                if !should_retire {
8730                    continue;
8731                }
8732                match self.remove_managed_session_record(&record_id, &workspace_id).await {
8733                    Ok(()) => {
8734                        self.retired_records_total.fetch_add(1, Ordering::Relaxed);
8735                        retired += 1;
8736                        tracing::info!(
8737                            node_id = %self.node_id,
8738                            record_id = %record_id,
8739                            workspace_id = %workspace_id,
8740                            provider = %provider,
8741                            age_ms = record_age_ms,
8742                            reason,
8743                            "managed session record retired"
8744                        );
8745                    }
8746                    Err(error) => tracing::warn!(
8747                        node_id = %self.node_id,
8748                        record_id = %record_id,
8749                        workspace_id = %workspace_id,
8750                        code = ?error.code,
8751                        cause = %error.message,
8752                        "session record retention sweep failed to retire a record"
8753                    ),
8754                }
8755            }
8756        }
8757        if retired > 0 {
8758            let remaining_unavailable = self
8759                .session_records
8760                .lock()
8761                .unwrap_or_else(|poisoned| poisoned.into_inner())
8762                .records
8763                .values()
8764                .filter(|record| record.state == ManagedSessionState::Unavailable)
8765                .count();
8766            tracing::info!(
8767                node_id = %self.node_id,
8768                retired,
8769                remaining_unavailable,
8770                "session record retention sweep completed"
8771            );
8772        }
8773    }
8774
8775    async fn resume_session_record(
8776        &self,
8777        record_id: &SessionRecordId,
8778        terminal_size: gate4agent_types::TerminalSize,
8779        initial_prompt: Option<String>,
8780    ) -> Result<(ManagedSessionRecord, SessionAddress), NodeFailure> {
8781        let mut record = self.record(record_id)?;
8782        // claude's ACP agent has no session/load: an ACP claude conversation whose transcript
8783        // is on disk is resumed as a PTY `claude --resume <id>` of that same transcript.
8784        if record.mode == SessionMode::Acp
8785            && record.provider.as_str() == "claude"
8786            && record.provider_session.as_ref().is_some_and(|i| provider_session_exists("claude", &i.id))
8787        {
8788            record.mode = SessionMode::Pty;
8789            if let Some(r) = self.session_records.lock().unwrap_or_else(|p| p.into_inner()).records.get_mut(record_id) {
8790                r.mode = SessionMode::Pty;
8791            }
8792            tracing::info!(record = %record_id, "acp claude record resumed over pty (--resume of its transcript)");
8793        }
8794        if record.active_session.is_some()
8795            || matches!(
8796                record.state,
8797                ManagedSessionState::Live | ManagedSessionState::IdentityPending
8798            )
8799        {
8800            return Err(failure(
8801                NodeFailureCode::SessionRecordBusy,
8802                "managed session already has a live runtime binding",
8803            ));
8804        }
8805        let identity = record.provider_session.clone().ok_or_else(|| {
8806            failure(
8807                NodeFailureCode::SessionRecordNotResumable,
8808                "managed session has no verified provider session identity",
8809            )
8810        })?;
8811        let working_directory = self.workspace_root(&record.workspace_id)?;
8812        if !platform::roots_equal(
8813            &working_directory,
8814            windows_path_text(&record.canonical_root),
8815        ) {
8816            return Err(failure(
8817                NodeFailureCode::SessionRecordConflict,
8818                "managed session workspace root changed; refusing to resume in another directory",
8819            ));
8820        }
8821        {
8822            let records = self
8823                .session_records
8824                .lock()
8825                .unwrap_or_else(|poisoned| poisoned.into_inner());
8826            if records.records.values().any(|candidate| {
8827                candidate.record_id != *record_id
8828                    && candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8829                        session_registry::same_provider_session(
8830                            &candidate.provider,
8831                            candidate_identity,
8832                            &record.provider,
8833                            &identity,
8834                        )
8835                    })
8836                    && (candidate.active_session.is_some()
8837                        || matches!(
8838                            candidate.state,
8839                            ManagedSessionState::Live | ManagedSessionState::IdentityPending
8840                        ))
8841            }) {
8842                return Err(failure(
8843                    NodeFailureCode::SessionRecordConflict,
8844                    "provider session is already active through another managed record",
8845                ));
8846            }
8847        }
8848        let runtime_requirement = if initial_prompt.is_some() {
8849            ProviderRuntimeRequirement::ResumeWithPrompt
8850        } else {
8851            ProviderRuntimeRequirement::Resume
8852        };
8853        let runtime_policy = self.admit_provider_runtime(
8854            &record.provider,
8855            runtime_requirement,
8856        )
8857        .await?;
8858        // The resumed runtime runs in the session folder its record was named after.
8859        let working_directory = match self.m4a_accounts.as_ref() {
8860            Some(_) => {
8861                let dir = std::path::Path::new(&working_directory).join(".hatchery-sessions").join(&record.display_name);
8862                if !record.display_name.contains(' ') && dir.is_dir() { dir.to_string_lossy().into_owned() } else { working_directory }
8863            }
8864            None => working_directory,
8865        };
8866        let request = ResumeLaunchRequest {
8867            working_directory,
8868            terminal_size,
8869            initial_prompt,
8870        };
8871        request
8872            .validate()
8873            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8874        self.ensure_binding_capacity()?;
8875        let resolved_materialization = match self.resolve_record_materialization(
8876            record_id,
8877            &record.provider,
8878            record.mode,
8879            record.environment_profile.as_ref(),
8880            record.bundle.as_ref(),
8881            record.context.as_ref(),
8882        ) {
8883            Ok(materialization) => materialization,
8884            Err(error) => {
8885                let label = match error.code {
8886                    NodeFailureCode::UnknownBundle => "bundle-unavailable",
8887                    NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
8888                    NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
8889                    _ => "environment-profile-unavailable",
8890                };
8891                self.mark_record_error(record_id, label)?;
8892                return Err(error);
8893            }
8894        };
8895        let (materialization_id, environment_overlay) = match resolved_materialization {
8896            Some((id, overlay)) => (Some(id), overlay),
8897            None => (None, None),
8898        };
8899        let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
8900        // Resumed runtime: same auto-permit flags and launch options (model / effort) as a spawn.
8901        let environment_overlay = {
8902            let mut args: Vec<OsString> = Vec::new();
8903            if let Ok(cwd) = self.workspace_root(&record.workspace_id) {
8904                let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
8905                if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") { homes.extend(std::env::split_paths(&extra)); }
8906                crate::auto_permit::prepare(record.provider.as_str(), &homes, Some(std::path::Path::new(&cwd)));
8907            }
8908            args.extend(crate::auto_permit::launch_args(record.provider.as_str(), record.mode));
8909            let options = SPAWN_SESSION_OPTIONS.try_with(|o| o.clone()).ok().flatten();
8910            if record.mode == SessionMode::Pty && record.provider.as_str() == "claude" {
8911                if let Some(sel) = options.as_ref() {
8912                    args.push("--model".into()); args.push(sel.model.clone().into());
8913                    if let Some(effort) = sel.values.get("effort").and_then(|v| v.as_str()) {
8914                        args.push("--effort".into()); args.push(effort.to_owned().into());
8915                    }
8916                }
8917            }
8918            if matches!(record.mode, SessionMode::Pty | SessionMode::Acp) && crate::pty_resume::supported(record.provider.as_str()) {
8919                if let (Some(file), Some(identity)) = (self.session_resume_journal(), record.provider_session.as_ref()) {
8920                    crate::pty_resume::upsert(&file, crate::pty_resume::Entry {
8921                        instance_id: instance_id.0,
8922                        provider: record.provider.as_str().to_owned(),
8923                        workspace_id: record.workspace_id.as_str().to_owned(),
8924                        provider_session_id: identity.id.clone(),
8925                        session_key: self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).get(&instance_id).cloned().unwrap_or_default(),
8926                        cwd: String::new(),
8927                        acp: record.mode == SessionMode::Acp,
8928                        options,
8929                        cols: 0,
8930                        rows: 0,
8931                        origin: SPAWN_ORIGIN.try_with(|o| o.clone()).ok().flatten(),
8932                        parent_session_id: SPAWN_PARENT.try_with(|o| o.clone()).ok().flatten(),
8933                    });
8934                }
8935            }
8936            overlay_with_extra_args(&record.provider, record.mode, environment_overlay, args)?
8937        };
8938        // The resumed runtime keeps the m4a account of the runtime it replaces.
8939        let environment_overlay = match (self.m4a_accounts.as_ref(), crate::m4a_accounts::m4a_provider(record.provider.as_str())) {
8940            (Some(accounts), Some(m4a_provider)) if matches!(record.mode, SessionMode::Pty | SessionMode::Acp) => {
8941                if let Some(old) = self.bound_address_for_record(record_id) {
8942                    if let Some(key) = self.m4a_keys.lock().unwrap_or_else(|p| p.into_inner()).get(&old.session.instance_id).cloned() {
8943                        self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).insert(instance_id, key);
8944                    }
8945                }
8946                let session_key = self.m4a_session_key(instance_id);
8947                self.m4a_session_overlay(accounts, m4a_provider, &record.provider, instance_id, environment_overlay, &session_key, record.mode)?
8948            }
8949            _ => environment_overlay,
8950        };
8951        let mut environment_selection = match SpawnPreparer::select_environment(self, 
8952            instance_id,
8953            &record.provider,
8954            record.mode,
8955            record.environment_profile.as_ref(),
8956        ) {
8957            Ok(selection) => selection,
8958            Err(error) => {
8959                self.mark_record_error(record_id, "environment-profile-unavailable")?;
8960                return Err(error);
8961            }
8962        };
8963        let mut instance_overlay = environment_overlay
8964            .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
8965            .transpose()?
8966            .flatten();
8967        if let Some(stale_address) = self.bound_address_for_record(record_id) {
8968            self.remove_session(&stale_address).await?;
8969        }
8970        let address = SessionAddress {
8971            workspace_id: record.workspace_id.clone(),
8972            session: SessionKey {
8973                instance_id,
8974                generation: SessionGeneration(1),
8975            },
8976        };
8977        let transaction = self
8978            .state_transaction
8979            .lock()
8980            .unwrap_or_else(|poisoned| poisoned.into_inner());
8981        let current = self.record(record_id)?;
8982        if current.active_session.is_some()
8983            || matches!(
8984                current.state,
8985                ManagedSessionState::Live | ManagedSessionState::IdentityPending
8986            )
8987        {
8988            return Err(failure(
8989                NodeFailureCode::SessionRecordBusy,
8990                "managed session became active while resume was being prepared",
8991            ));
8992        }
8993        self.bind_managed_session_with_materialization(
8994            &address,
8995            record_id.clone(),
8996            runtime_policy,
8997            record.environment_profile.clone(),
8998            record.bundle.clone(),
8999            record.context.clone(),
9000            materialization_id,
9001        );
9002        if let Some(selection) = environment_selection.take() {
9003            selection.retain();
9004        }
9005        if let Some(overlay) = instance_overlay.take() {
9006            overlay.retain();
9007        }
9008        drop(transaction);
9009
9010        let transport = match record.mode {
9011            SessionMode::Pty => TransportKind::Pty,
9012            SessionMode::Inline => TransportKind::Pipe,
9013            SessionMode::Acp => TransportKind::Acp,
9014        };
9015        let agent_id = record.provider.clone();
9016        let dispatch_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
9017        if let Err(error) = self
9018            .dispatch_bounded(
9019                ControlCommand::Register {
9020                    instance_id,
9021                    agent_id,
9022                    transport,
9023                },
9024                dispatch_timeout,
9025            )
9026            .await
9027        {
9028            if let Some(binding) = self.remove_binding(&address) {
9029                SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
9030            }
9031            return Err(error);
9032        }
9033        let command = self.prepare_command(ControlCommand::Resume {
9034            instance_id,
9035            target: ResumeTarget::ProviderSession { identity },
9036            runtime_policy,
9037            request,
9038        });
9039        let started_after = self.current_sequence();
9040        if let Err(error) = self.arm_resume(&address, command.id, runtime_policy) {
9041            self.rollback_spawn(&address, dispatch_timeout).await?;
9042            return Err(error);
9043        }
9044        if let Err(error) = self.dispatch_envelope(command) {
9045            self.clear_armed_resume(&address);
9046            self.rollback_spawn(&address, dispatch_timeout).await?;
9047            return Err(error);
9048        }
9049        match self
9050            .wait_for_record_resume(&address, started_after)
9051            .await
9052        {
9053            Ok(settled_address) => {
9054                let managed_lease_id = {
9055                    self.managed_worktrees.lock()
9056                        .unwrap_or_else(|poisoned| poisoned.into_inner())
9057                        .lease_for_workspace(&record.workspace_id)
9058                };
9059                if let Some(lease_id) = managed_lease_id {
9060                    {
9061                        let mut bindings = self.session_bindings.lock()
9062                            .unwrap_or_else(|poisoned| poisoned.into_inner());
9063                        let binding = bindings.get_mut(&settled_address.session.instance_id)
9064                            .ok_or_else(|| failure(
9065                                NodeFailureCode::ManagedWorktreeRecoveryRequired,
9066                                "resumed managed session lost its exact binding",
9067                            ))?;
9068                        if binding.generation != settled_address.session.generation {
9069                            return Err(failure(
9070                                NodeFailureCode::ManagedWorktreeRecoveryRequired,
9071                                "resumed managed session generation diverged",
9072                            ));
9073                        }
9074                        binding.managed_worktree_lease_id = Some(lease_id.clone());
9075                    }
9076                    let snapshot = self.managed_worktrees.lock()
9077                        .unwrap_or_else(|poisoned| poisoned.into_inner())
9078                        .bind_session(
9079                            &lease_id,
9080                            ManagedWorktreeSessionHolder {
9081                                incarnation_id: self.incarnation_id,
9082                                instance_id: settled_address.session.instance_id,
9083                                generation: settled_address.session.generation,
9084                            },
9085                            Some(record_id.clone()),
9086                            unix_time_ms(),
9087                        ).map_err(|message| failure(
9088                            NodeFailureCode::ManagedWorktreeRecoveryRequired,
9089                            &message,
9090                        ))?;
9091                    if let Err(error) = self.persist_state() {
9092                        let _ = self.remove_session(&settled_address).await;
9093                        return Err(persistence_failure(error));
9094                    }
9095                    self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
9096                }
9097                Ok((self.record(record_id)?, settled_address))
9098            }
9099            Err(error) => {
9100                if matches!(
9101                    error.code,
9102                    NodeFailureCode::SessionRecordNotResumable
9103                        | NodeFailureCode::BackendOperationFailed
9104                ) {
9105                    if let Some(bound) = self.bound_address_for_record(record_id) {
9106                        let _ = self.remove_session(&bound).await;
9107                    }
9108                }
9109                Err(error)
9110            }
9111        }
9112    }
9113
9114    async fn wait_for_record_resume(
9115        &self,
9116        initial_address: &SessionAddress,
9117        after_sequence: u64,
9118    ) -> Result<SessionAddress, NodeFailure> {
9119        let deadline = Instant::now()
9120            + Duration::from_millis(MANAGED_RESUME_SETTLE_TIMEOUT_MS);
9121        let mut scan_after = after_sequence;
9122        loop {
9123            let events = self
9124                .history
9125                .lock()
9126                .unwrap_or_else(|poisoned| poisoned.into_inner())
9127                .events
9128                .iter()
9129                .filter(|event| event.sequence > scan_after)
9130                .cloned()
9131                .collect::<Vec<_>>();
9132            for envelope in events {
9133                scan_after = scan_after.max(envelope.sequence);
9134                let NodeEvent::Control { address, event } = envelope.event else {
9135                    continue;
9136                };
9137                if address.session.instance_id != initial_address.session.instance_id {
9138                    continue;
9139                }
9140                match event.event {
9141                    ControlEventKind::Resumed { .. } => return Ok(address),
9142                    ControlEventKind::ResumeDenied { reason } => {
9143                        return Err(failure(
9144                            NodeFailureCode::SessionRecordNotResumable,
9145                            &reason,
9146                        ));
9147                    }
9148                    ControlEventKind::ResumeFailed { message }
9149                    | ControlEventKind::CommandRejected { message } => {
9150                        return Err(failure(
9151                            NodeFailureCode::BackendOperationFailed,
9152                            &message,
9153                        ));
9154                    }
9155                    _ => {}
9156                }
9157            }
9158            if Instant::now() >= deadline {
9159                return Err(failure(
9160                    NodeFailureCode::BackendBusy,
9161                    "managed resume did not settle before the bounded deadline",
9162                ));
9163            }
9164            sleep(Duration::from_millis(5)).await;
9165        }
9166    }
9167
9168    fn workspace_root(&self, workspace_id: &WorkspaceId) -> Result<String, NodeFailure> {
9169        self.workspaces
9170            .read()
9171            .unwrap_or_else(|poisoned| poisoned.into_inner())
9172            .get(workspace_id)
9173            .cloned()
9174            .ok_or_else(|| failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"))
9175    }
9176
9177    fn workspace_roots(&self) -> Vec<PathBuf> {
9178        self.workspaces
9179            .read()
9180            .unwrap_or_else(|poisoned| poisoned.into_inner())
9181            .values()
9182            .map(PathBuf::from)
9183            .collect()
9184    }
9185
9186    async fn inspect_workspace(
9187        &self,
9188        workspace_id: WorkspaceId,
9189    ) -> Result<WorkspaceInspection, NodeFailure> {
9190        let _permit = self
9191            .inspection_slots
9192            .clone()
9193            .try_acquire_owned()
9194            .map_err(|_| {
9195                tracing::warn!(
9196                    workspace_id = %workspace_id,
9197                    "workspace inspection rejected: inspection capacity is busy",
9198                );
9199                failure(
9200                    NodeFailureCode::BackendBusy,
9201                    "workspace inspection capacity is busy",
9202                )
9203            })?;
9204        let canonical_root = self.workspace_root(&workspace_id).map_err(|error| {
9205            tracing::warn!(
9206                workspace_id = %workspace_id,
9207                code = ?error.code,
9208                "workspace inspection rejected: workspace is not registered",
9209            );
9210            error
9211        })?;
9212        let tree_root = canonical_root.clone();
9213        let started_at = Instant::now();
9214        let time_budget_ms = workspace_inspection_time_budget_ms();
9215        let entry_cap = workspace_inspection_entry_cap();
9216        let deadline = started_at + Duration::from_millis(time_budget_ms);
9217        let (entries, tree_truncated, walk_budget) = tokio::task::spawn_blocking(move || {
9218            collect_workspace_entries(Path::new(&tree_root), deadline, entry_cap)
9219        })
9220        .await
9221        .map_err(|error| {
9222            tracing::warn!(
9223                workspace_id = %workspace_id,
9224                error = %error,
9225                "workspace inspection rejected: walk task failed",
9226            );
9227            failure(
9228                NodeFailureCode::BackendOperationFailed,
9229                &format!("workspace inspection task failed: {error}"),
9230            )
9231        })?;
9232        let (mut git, git_time_budget_exceeded) =
9233            inspect_git_workspace(&canonical_root, deadline).await;
9234        if !git.worktrees.is_empty() {
9235            let registered = self
9236                .workspaces
9237                .read()
9238                .unwrap_or_else(|poisoned| poisoned.into_inner())
9239                .iter()
9240                .map(|(workspace_id, root)| (workspace_id.clone(), root.clone()))
9241                .collect::<Vec<_>>();
9242            for worktree in &mut git.worktrees {
9243                worktree.workspace_id = registered
9244                    .iter()
9245                    .find(|(_, root)| {
9246                        worktree_paths_equal(root, windows_path_text(&worktree.path))
9247                    })
9248                    .map(|(workspace_id, _)| workspace_id.clone());
9249            }
9250        }
9251        if git.is_repository {
9252            git.managed_worktree = self
9253                .managed_worktrees
9254                .lock()
9255                .unwrap_or_else(|poisoned| poisoned.into_inner())
9256                .git_scope(&workspace_id, git.branch.as_deref());
9257        }
9258        let truncation = workspace_inspection_truncation(
9259            &walk_budget,
9260            git_time_budget_exceeded,
9261            started_at.elapsed(),
9262        );
9263        if let Some(truncation) = &truncation {
9264            tracing::info!(
9265                workspace_id = %workspace_id,
9266                walk_time_budget_exceeded = truncation.walk_time_budget_exceeded,
9267                walk_entry_cap_exceeded = truncation.walk_entry_cap_exceeded,
9268                git_time_budget_exceeded = truncation.git_time_budget_exceeded,
9269                entries_visited = truncation.entries_visited,
9270                elapsed_ms = truncation.elapsed_ms,
9271                "workspace inspection truncated by its inner budget",
9272            );
9273        }
9274        Ok(WorkspaceInspection {
9275            workspace_id,
9276            entries,
9277            tree_truncated,
9278            git,
9279            truncation,
9280        })
9281    }
9282
9283    async fn browse_host_directories(
9284        &self,
9285        directory: Option<OpaqueHostPath>,
9286        after: Option<OpaqueHostPath>,
9287    ) -> Result<HostDirectoryListing, NodeFailure> {
9288        let permit = self
9289            .inspection_slots
9290            .clone()
9291            .try_acquire_owned()
9292            .map_err(|_| {
9293                failure(
9294                    NodeFailureCode::BackendBusy,
9295                    "host directory browse capacity is busy",
9296                )
9297            })?;
9298        let task = tokio::task::spawn_blocking(move || {
9299            let _permit = permit;
9300            browse_host_directories(directory, after)
9301        });
9302        timeout(Duration::from_millis(HOST_DIRECTORY_BROWSE_TIMEOUT_MS), task)
9303            .await
9304            .map_err(|_| {
9305                failure(
9306                    NodeFailureCode::HostDirectoryReadTimedOut,
9307                    "host directory browse exceeded its bounded deadline",
9308                )
9309            })?
9310            .map_err(|_| {
9311                failure(
9312                    NodeFailureCode::HostDirectoryReadFailed,
9313                    "host directory browse task failed",
9314                )
9315            })?
9316            .map_err(host_directory_failure)
9317    }
9318
9319    async fn read_workspace_file(
9320        &self,
9321        workspace_id: WorkspaceId,
9322        path: RepositoryPath,
9323    ) -> Result<WorkspaceFileRead, NodeFailure> {
9324        let canonical_root = self.workspace_root(&workspace_id)?;
9325        let permit = self
9326            .inspection_slots
9327            .clone()
9328            .try_acquire_owned()
9329            .map_err(|_| {
9330                failure(
9331                    NodeFailureCode::BackendBusy,
9332                    "workspace file read capacity is busy",
9333                )
9334            })?;
9335        let path_to_read = path.clone();
9336        let task = tokio::task::spawn_blocking(move || {
9337            let _permit = permit;
9338            read_workspace_file_from_disk(Path::new(&canonical_root), &path_to_read)
9339        });
9340        let content = timeout(
9341            Duration::from_millis(WORKSPACE_FILE_READ_TIMEOUT_MS),
9342            task,
9343        )
9344        .await
9345        .map_err(|_| {
9346            failure(
9347                NodeFailureCode::RepositoryFileReadTimedOut,
9348                "repository file read exceeded its bounded deadline",
9349            )
9350        })?
9351        .map_err(|_| {
9352            failure(
9353                NodeFailureCode::RepositoryFileReadFailed,
9354                "repository file read task failed",
9355            )
9356        })?
9357        .map_err(workspace_file_failure)?;
9358        let content = match content {
9359            WorkspaceFileBytes::Utf8(text) => {
9360                let revision = Some(workspace_file_revision(text.as_bytes()));
9361                return Ok(WorkspaceFileRead {
9362                    workspace_id,
9363                    path,
9364                    content: WorkspaceFileContent::Utf8 {
9365                        byte_len: u32::try_from(text.len())
9366                            .expect("bounded workspace text length must fit u32"),
9367                        text,
9368                    },
9369                    revision,
9370                });
9371            }
9372            WorkspaceFileBytes::NonUtf8 { byte_length } => {
9373                WorkspaceFileContent::NonUtf8 {
9374                    byte_len: u32::try_from(byte_length)
9375                        .expect("bounded workspace file length must fit u32"),
9376                }
9377            }
9378            WorkspaceFileBytes::TooLarge => WorkspaceFileContent::TooLarge {
9379                limit_bytes: MAX_WORKSPACE_FILE_BYTES as u32,
9380            },
9381        };
9382        Ok(WorkspaceFileRead {
9383            workspace_id,
9384            path,
9385            content,
9386            revision: None,
9387        })
9388    }
9389
9390    async fn write_workspace_file(
9391        &self,
9392        workspace_id: WorkspaceId,
9393        path: RepositoryPath,
9394        expected_revision: WorkspaceFileRevision,
9395        text: String,
9396    ) -> Result<WorkspaceFileRead, NodeFailure> {
9397        let canonical_root = self.workspace_root(&workspace_id)?;
9398        let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9399            failure(NodeFailureCode::BackendBusy, "workspace file write capacity is busy")
9400        })?;
9401        let path_to_write = path.clone();
9402        let expected = expected_revision.as_str().to_owned();
9403        let text_to_write = text.clone();
9404        let task = tokio::task::spawn_blocking(move || {
9405            let _permit = permit;
9406            write_workspace_file_to_disk(
9407                Path::new(&canonical_root),
9408                &path_to_write,
9409                &expected,
9410                &text_to_write,
9411            )
9412        });
9413        let revision = timeout(Duration::from_millis(WORKSPACE_FILE_WRITE_TIMEOUT_MS), task)
9414            .await
9415            .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteTimedOut, "repository file write exceeded its bounded deadline"))?
9416            .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteFailed, "repository file write task failed"))?
9417            .map_err(workspace_file_write_failure)?;
9418        Ok(WorkspaceFileRead {
9419            workspace_id,
9420            path,
9421            content: WorkspaceFileContent::Utf8 {
9422                byte_len: u32::try_from(text.len()).expect("bounded workspace text length must fit u32"),
9423                text,
9424            },
9425            revision: Some(WorkspaceFileRevision::new(revision)
9426                .expect("workspace writer returns a valid SHA-256 revision")),
9427        })
9428    }
9429
9430    async fn create_workspace_file(
9431        &self,
9432        workspace_id: WorkspaceId,
9433        path: RepositoryPath,
9434    ) -> Result<WorkspaceFileRead, NodeFailure> {
9435        let canonical_root = self.workspace_root(&workspace_id)?;
9436        let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9437            failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
9438        })?;
9439        let path_to_create = path.clone();
9440        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
9441        let task_commit_state = Arc::clone(&commit_state);
9442        let task = tokio::task::spawn_blocking(move || {
9443            let _permit = permit;
9444            create_workspace_file_on_disk(
9445                Path::new(&canonical_root),
9446                &path_to_create,
9447                &task_commit_state,
9448            )
9449        });
9450        let revision = settle_workspace_entry_create(
9451            task,
9452            commit_state,
9453            Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
9454            "repository file create exceeded its bounded deadline",
9455            "repository file create task failed",
9456        )
9457        .await?;
9458        Ok(WorkspaceFileRead {
9459            workspace_id,
9460            path,
9461            content: WorkspaceFileContent::Utf8 {
9462                text: String::new(),
9463                byte_len: 0,
9464            },
9465            revision: Some(WorkspaceFileRevision::new(revision)
9466                .expect("workspace creator returns a valid SHA-256 revision")),
9467        })
9468    }
9469
9470    async fn create_workspace_directory(
9471        &self,
9472        workspace_id: WorkspaceId,
9473        path: RepositoryPath,
9474    ) -> Result<WorkspaceEntry, NodeFailure> {
9475        let canonical_root = self.workspace_root(&workspace_id)?;
9476        let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9477            failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
9478        })?;
9479        let path_to_create = path.clone();
9480        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
9481        let task_commit_state = Arc::clone(&commit_state);
9482        let task = tokio::task::spawn_blocking(move || {
9483            let _permit = permit;
9484            create_workspace_directory_on_disk(
9485                Path::new(&canonical_root),
9486                &path_to_create,
9487                &task_commit_state,
9488            )
9489        });
9490        settle_workspace_entry_create(
9491            task,
9492            commit_state,
9493            Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
9494            "repository directory create exceeded its bounded deadline",
9495            "repository directory create task failed",
9496        )
9497        .await?;
9498        Ok(WorkspaceEntry {
9499            relative_path: path,
9500            kind: WorkspaceEntryKind::Directory,
9501        })
9502    }
9503
9504    async fn read_git_history(
9505        &self,
9506        workspace_id: WorkspaceId,
9507        path: Option<RepositoryPath>,
9508        before: Option<GitObjectId>,
9509        limit: u16,
9510    ) -> Result<GitHistoryPage, NodeFailure> {
9511        let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9512            failure(NodeFailureCode::BackendBusy, "git history capacity is busy")
9513        })?;
9514        let root = self.workspace_root(&workspace_id)?;
9515        read_git_history_bounded(&root, path.as_ref(), before.as_ref(), limit).await
9516    }
9517
9518    async fn read_git_diff(
9519        &self,
9520        workspace_id: WorkspaceId,
9521        request: GitDiffRequest,
9522    ) -> Result<GitDiff, NodeFailure> {
9523        let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9524            failure(NodeFailureCode::BackendBusy, "git diff capacity is busy")
9525        })?;
9526        let root = self.workspace_root(&workspace_id)?;
9527        read_git_diff_bounded(&root, request).await
9528    }
9529
9530    fn native_session_route_root(
9531        &self,
9532        route: &NativeSessionCatalogRoute,
9533    ) -> Result<Option<String>, NodeFailure> {
9534        route
9535            .validate()
9536            .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9537        match route.scope {
9538            gate4agent_types::NativeSessionCatalogScope::Workspace => route
9539                .workspace_id
9540                .as_ref()
9541                .map(|workspace_id| self.workspace_root(workspace_id))
9542                .transpose(),
9543            gate4agent_types::NativeSessionCatalogScope::Unregistered => Ok(None),
9544        }
9545    }
9546
9547    fn project_native_session_entries(
9548        &self,
9549        route: &NativeSessionCatalogRoute,
9550        entries: Vec<ScopedNativeSessionCatalogEntry>,
9551    ) -> Vec<crate::protocol::NativeSessionCatalogEntry> {
9552        let current_root = route
9553            .workspace_id
9554            .as_ref()
9555            .and_then(|workspace_id| self.workspace_root(workspace_id).ok());
9556        let records = self
9557            .session_records
9558            .lock()
9559            .unwrap_or_else(|poisoned| poisoned.into_inner());
9560        let mut seen_records = std::collections::HashSet::new();
9561        entries
9562            .into_iter()
9563            .map(|entry| {
9564                let record_id = route.workspace_id.as_ref().and_then(|workspace_id| {
9565                    let matches = records
9566                        .records
9567                        .values()
9568                        .filter(|record| {
9569                            record.provider == route.provider
9570                                && record.workspace_id == *workspace_id
9571                                && current_root.as_deref().is_some_and(|root| {
9572                                    platform::roots_equal(
9573                                        root,
9574                                        windows_path_text(&record.canonical_root),
9575                                    )
9576                                })
9577                                && record.provider_session.as_ref().is_some_and(|identity| {
9578                                    session_registry::same_provider_session(
9579                                        &record.provider,
9580                                        identity,
9581                                        &route.provider,
9582                                        &entry.provider_session,
9583                                    )
9584                                })
9585                        })
9586                        .map(|record| record.record_id.clone())
9587                        .collect::<Vec<_>>();
9588                    (matches.len() == 1
9589                        && seen_records.insert(matches[0].clone()))
9590                        .then(|| matches[0].clone())
9591                });
9592                let metadata = entry.metadata;
9593                crate::protocol::NativeSessionCatalogEntry {
9594                    selection_id: metadata.selection_id,
9595                    title: metadata.title,
9596                    modified_at_unix_ms: metadata.modified_at_unix_ms,
9597                    model: metadata.model,
9598                    message_count: metadata.message_count,
9599                    completed_turn_count: metadata.completed_turn_count,
9600                    external_group: entry.external_group,
9601                    record_id,
9602                }
9603            })
9604            .collect()
9605    }
9606
9607    async fn catalog_native_sessions(
9608        &self,
9609        route: NativeSessionCatalogRoute,
9610        limit: u16,
9611    ) -> Result<(
9612        Vec<crate::protocol::NativeSessionCatalogEntry>,
9613        crate::protocol::NativeSessionCatalogSummary,
9614    ), NodeFailure> {
9615        let canonical_root = self.native_session_route_root(&route)?;
9616        let registered_workspace_roots = self.workspace_roots();
9617        let provider = route.provider.clone();
9618        let scope = route.scope;
9619        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9620            failure(
9621                NodeFailureCode::InvalidRequest,
9622                "native session catalog is not configured",
9623            )
9624        })?;
9625        let _permit = Arc::clone(&self.inspection_slots)
9626            .try_acquire_owned()
9627            .map_err(|_| {
9628                failure(
9629                    NodeFailureCode::BackendBusy,
9630                    "native session catalog capacity is busy",
9631                )
9632            })?;
9633        let page = run_native_session_catalog_operation(
9634            catalog,
9635            "native session catalog",
9636            move |catalog| {
9637                catalog.catalog_initial_for_scope(
9638                    &provider,
9639                    scope,
9640                    canonical_root.as_deref().map(Path::new),
9641                    &registered_workspace_roots,
9642                    limit,
9643                )
9644            },
9645        )
9646        .await?;
9647        let summary = crate::protocol::NativeSessionCatalogSummary {
9648            catalog_revision: page.revision,
9649            recent_cutoff_unix_ms: page.cutoff_unix_ms,
9650            recent_total_count: page.recent_total_count,
9651            older_total_count: page.older_total_count,
9652            recent_next_after_selection_id: page.next_after_selection_id,
9653            recent_has_more: page.remaining_count > 0,
9654        };
9655        Ok((self.project_native_session_entries(&route, page.entries), summary))
9656    }
9657
9658    async fn page_native_sessions(
9659        &self,
9660        route: NativeSessionCatalogRoute,
9661        window: crate::protocol::NativeSessionCatalogWindow,
9662        catalog_revision: u64,
9663        recent_cutoff_unix_ms: u64,
9664        after_selection_id: Option<String>,
9665        limit: u16,
9666    ) -> Result<crate::protocol::NativeSessionCatalogPage, NodeFailure> {
9667        let canonical_root = self.native_session_route_root(&route)?;
9668        let registered_workspace_roots = self.workspace_roots();
9669        let provider = route.provider.clone();
9670        let scope = route.scope;
9671        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9672            failure(
9673                NodeFailureCode::InvalidRequest,
9674                "native session catalog is not configured",
9675            )
9676        })?;
9677        let _permit = Arc::clone(&self.inspection_slots)
9678            .try_acquire_owned()
9679            .map_err(|_| {
9680                failure(
9681                    NodeFailureCode::BackendBusy,
9682                    "native session catalog capacity is busy",
9683                )
9684            })?;
9685        let page = run_native_session_catalog_operation(
9686            catalog,
9687            "native session catalog page",
9688            move |catalog| {
9689                Ok::<_, std::convert::Infallible>(catalog.catalog_page_for_scope(
9690                    &provider,
9691                    scope,
9692                    canonical_root.as_deref().map(Path::new),
9693                    &registered_workspace_roots,
9694                    window,
9695                    catalog_revision,
9696                    recent_cutoff_unix_ms,
9697                    after_selection_id.as_deref(),
9698                    limit,
9699                ))
9700            },
9701        )
9702        .await?;
9703        let page = page.map_err(|error| match error {
9704            NativeSessionCatalogError::StaleCatalog => failure(
9705                NodeFailureCode::StaleNativeSessionCatalog,
9706                "native session catalog revision or cursor is stale",
9707            ),
9708            _ => failure(
9709                NodeFailureCode::BackendOperationFailed,
9710                "native session catalog page failed",
9711            ),
9712        })?;
9713        let entries = self.project_native_session_entries(&route, page.entries);
9714        Ok(crate::protocol::NativeSessionCatalogPage {
9715            window: page.window,
9716            revision: page.revision,
9717            entries,
9718            next_after_selection_id: page.next_after_selection_id,
9719            remaining_count: page.remaining_count,
9720            has_more: page.remaining_count > 0,
9721        })
9722    }
9723
9724    async fn preview_native_session(
9725        &self,
9726        selection: NativeSessionSelection,
9727        message_limit: u16,
9728    ) -> Result<crate::protocol::NativeSessionPreview, NodeFailure> {
9729        selection
9730            .validate()
9731            .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9732        let canonical_root = self.native_session_route_root(&selection.route)?;
9733        let registered_workspace_roots = self.workspace_roots();
9734        let provider = selection.route.provider.clone();
9735        let scope = selection.route.scope;
9736        let catalog_revision = selection.catalog_revision;
9737        let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9738        let selection_id = selection.selection_id.clone();
9739        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9740            failure(
9741                NodeFailureCode::InvalidRequest,
9742                "native session preview is not configured",
9743            )
9744        })?;
9745        let _permit = Arc::clone(&self.inspection_slots)
9746            .try_acquire_owned()
9747            .map_err(|_| {
9748                failure(
9749                    NodeFailureCode::BackendBusy,
9750                    "native session preview capacity is busy",
9751                )
9752            })?;
9753        let preview = run_native_session_catalog_operation(
9754            catalog,
9755            "native session preview",
9756            move |catalog| {
9757                Ok::<_, std::convert::Infallible>(catalog.preview_for_scope(
9758                    &provider,
9759                    scope,
9760                    canonical_root.as_deref().map(Path::new),
9761                    &registered_workspace_roots,
9762                    catalog_revision,
9763                    recent_cutoff_unix_ms,
9764                    &selection_id,
9765                    message_limit,
9766                ))
9767            },
9768        )
9769        .await?;
9770        preview
9771            .map(Into::into)
9772            .map_err(native_session_preview_failure)
9773    }
9774
9775    async fn index_native_session(
9776        &self,
9777        selection: NativeSessionSelection,
9778        display_name: String,
9779    ) -> Result<ManagedSessionRecord, NodeFailure> {
9780        selection
9781            .validate()
9782            .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9783        if selection.route.scope
9784            != gate4agent_types::NativeSessionCatalogScope::Workspace
9785        {
9786            return Err(failure(
9787                NodeFailureCode::WorkspaceRegistrationRequired,
9788                "register the external project as a workspace before indexing or resuming it",
9789            ));
9790        }
9791        let workspace_id = selection
9792            .route
9793            .workspace_id
9794            .clone()
9795            .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9796        let canonical_root = self
9797            .native_session_route_root(&selection.route)?
9798            .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9799        let registered_workspace_roots = self.workspace_roots();
9800        let provider = selection.route.provider.clone();
9801        let catalog_revision = selection.catalog_revision;
9802        let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9803        let selection_id = selection.selection_id.clone();
9804        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9805            failure(
9806                NodeFailureCode::InvalidRequest,
9807                "native session index is not configured",
9808            )
9809        })?;
9810        let _permit = Arc::clone(&self.inspection_slots)
9811            .try_acquire_owned()
9812            .map_err(|_| {
9813                failure(
9814                    NodeFailureCode::BackendBusy,
9815                    "native session index capacity is busy",
9816                )
9817            })?;
9818        let resolved = run_native_session_catalog_operation(
9819            catalog,
9820            "native session index resolution",
9821            move |catalog| {
9822                Ok::<_, std::convert::Infallible>(catalog.resolve_selection_for_scope(
9823                    &provider,
9824                    gate4agent_types::NativeSessionCatalogScope::Workspace,
9825                    Some(Path::new(&canonical_root)),
9826                    &registered_workspace_roots,
9827                    catalog_revision,
9828                    recent_cutoff_unix_ms,
9829                    &selection_id,
9830                ))
9831            },
9832        )
9833        .await?
9834        .map_err(native_session_preview_failure)?;
9835        self.index_provider_session_with_policy(
9836            workspace_id,
9837            selection.route.provider,
9838            resolved.identity,
9839            display_name,
9840            true,
9841        )
9842    }
9843
9844    async fn preview_native_session_by_session_id(
9845        &self,
9846        workspace_id: WorkspaceId,
9847        provider: AgentId,
9848        session_id: String,
9849        message_limit: u16,
9850    ) -> Result<gate4agent_types::NativeSessionPreview, NodeFailure> {
9851        let canonical_root = self.workspace_root(&workspace_id)?;
9852        let registered_workspace_roots = self.workspace_roots();
9853        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9854            failure(
9855                NodeFailureCode::InvalidRequest,
9856                "native session preview is not configured",
9857            )
9858        })?;
9859        let _permit = Arc::clone(&self.inspection_slots)
9860            .try_acquire_owned()
9861            .map_err(|_| {
9862                failure(
9863                    NodeFailureCode::BackendBusy,
9864                    "native session preview capacity is busy",
9865                )
9866            })?;
9867        run_native_session_catalog_operation(
9868            catalog,
9869            "native session preview",
9870            move |catalog| {
9871                catalog.preview_session_id_for_workspace(
9872                    &provider,
9873                    Path::new(&canonical_root),
9874                    &registered_workspace_roots,
9875                    &session_id,
9876                    message_limit,
9877                )
9878            },
9879        )
9880        .await
9881    }
9882
9883    async fn register_workspace(
9884        &self,
9885        workspace_id: WorkspaceId,
9886        root: String,
9887    ) -> Result<WorkspaceSnapshot, NodeFailure> {
9888        validate_workspace_request_root(&root)?;
9889        let canonicalize_workspace_id = workspace_id.clone();
9890        let workspace = tokio::task::spawn_blocking(move || {
9891            WorkspaceConfig::new(canonicalize_workspace_id, root)
9892        })
9893        .await
9894        .map_err(|error| {
9895            failure(
9896                NodeFailureCode::BackendOperationFailed,
9897                &format!("workspace canonicalization task failed: {error}"),
9898            )
9899        })?
9900        .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
9901        let transaction = self
9902            .state_transaction
9903            .lock()
9904            .unwrap_or_else(|poisoned| poisoned.into_inner());
9905        let mut workspaces = self
9906            .workspaces
9907            .write()
9908            .unwrap_or_else(|poisoned| poisoned.into_inner());
9909        if workspaces.contains_key(&workspace_id) {
9910            return Err(failure(
9911                NodeFailureCode::DuplicateWorkspaceId,
9912                "workspace ID is already registered",
9913            ));
9914        }
9915        if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
9916            platform::roots_equal(existing_root, workspace.canonical_root())
9917        }) {
9918            return Err(failure(
9919                NodeFailureCode::DuplicateWorkspaceRoot,
9920                &format!("workspace root is already registered as '{existing_id}'"),
9921            ));
9922        }
9923        {
9924            let records = self
9925                .session_records
9926                .lock()
9927                .unwrap_or_else(|poisoned| poisoned.into_inner());
9928            if let Some(record) = records.records.values().find(|record| {
9929                record.workspace_id == workspace_id
9930                    && !record
9931                        .canonical_root
9932                        .as_utf8()
9933                        .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9934            }) {
9935                return Err(failure(
9936                    NodeFailureCode::SessionRecordConflict,
9937                    &format!(
9938                        "workspace ID is retained by session record '{}' at another root",
9939                        record.record_id,
9940                    ),
9941                ));
9942            }
9943        }
9944        let snapshot = WorkspaceSnapshot {
9945            workspace_id: workspace_id.clone(),
9946            canonical_root: opaque_windows_path(workspace.canonical_root().to_owned()),
9947            sessions: Vec::new(),
9948            worktree_service_mode: Some(WorktreeServiceMode::Manual),
9949            managed_worktree_profiles: Some(WorktreeProfileInventory {
9950                profiles: Vec::new(),
9951            }),
9952        };
9953        workspaces.insert(workspace_id.clone(), workspace.canonical_root().to_owned());
9954        drop(workspaces);
9955        let previous_records = {
9956            let mut records = self
9957                .session_records
9958                .lock()
9959                .unwrap_or_else(|poisoned| poisoned.into_inner());
9960            let previous = records.records.clone();
9961            for record in records.records.values_mut().filter(|record| {
9962                record.workspace_id == workspace_id
9963                    && record
9964                        .canonical_root
9965                        .as_utf8()
9966                        .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9967            }) {
9968                if record.active_session.is_none() {
9969                    record.state = detached_record_state(record);
9970                }
9971                if record.last_error.as_deref() == Some(WORKSPACE_UNAVAILABLE_ERROR) {
9972                    record.last_error = None;
9973                }
9974                record.updated_at_unix_ms = unix_time_ms();
9975            }
9976            previous
9977        };
9978        if let Err(error) = self.persist_state_locked() {
9979            self.workspaces
9980                .write()
9981                .unwrap_or_else(|poisoned| poisoned.into_inner())
9982                .remove(&workspace_id);
9983            self.session_records
9984                .lock()
9985                .unwrap_or_else(|poisoned| poisoned.into_inner())
9986                .records = previous_records;
9987            return Err(persistence_failure(error));
9988        }
9989        let restored = self
9990            .session_records
9991            .lock()
9992            .unwrap_or_else(|poisoned| poisoned.into_inner())
9993            .records
9994            .values()
9995            .filter(|record| record.workspace_id == workspace_id)
9996            .cloned()
9997            .collect::<Vec<_>>();
9998        drop(transaction);
9999        self.publish(NodeEvent::WorkspaceAdded {
10000            workspace: snapshot.clone(),
10001        });
10002        for record in restored {
10003            self.publish_record(record);
10004        }
10005        Ok(snapshot)
10006    }
10007
10008    async fn create_standalone_workspace(
10009        &self,
10010        workspace_id: WorkspaceId,
10011        root: String,
10012        initial_branch: Option<String>,
10013    ) -> Result<WorkspaceSnapshot, NodeFailure> {
10014        validate_workspace_request_root(&root)?;
10015        validate_git_revision("initial branch", initial_branch.as_deref())?;
10016        let preflight_workspace_id = workspace_id.clone();
10017        let preflight_root = root.clone();
10018        let canonical_candidate = tokio::task::spawn_blocking(move || {
10019            canonical_standalone_workspace_candidate(&preflight_workspace_id, &preflight_root)
10020        })
10021        .await
10022        .map_err(|_| failure(
10023            NodeFailureCode::BackendOperationFailed,
10024            "standalone workspace root preflight task failed",
10025        ))?
10026        .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
10027        {
10028            let workspaces = self
10029                .workspaces
10030                .read()
10031                .unwrap_or_else(|poisoned| poisoned.into_inner());
10032            if workspaces.contains_key(&workspace_id) {
10033                return Err(failure(
10034                    NodeFailureCode::DuplicateWorkspaceId,
10035                    "workspace ID is already registered",
10036                ));
10037            }
10038            if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
10039                platform::roots_equal(existing_root, &canonical_candidate)
10040            }) {
10041                return Err(failure(
10042                    NodeFailureCode::DuplicateWorkspaceRoot,
10043                    &format!("workspace root is already registered as '{existing_id}'"),
10044                ));
10045            }
10046        }
10047        let prepared = prepare_standalone_workspace(root, initial_branch.as_deref())
10048            .await
10049            .map_err(standalone_workspace_failure)?;
10050        prepared
10051            .verify_for_registration()
10052            .await
10053            .map_err(standalone_workspace_failure)?;
10054        match self
10055            .register_workspace(workspace_id, prepared.root().to_owned())
10056            .await
10057        {
10058            Ok(workspace) => Ok(workspace),
10059            Err(registration_error) => {
10060                if prepared.compensate_registration_failure().await {
10061                    Err(registration_error)
10062                } else {
10063                    Err(failure(
10064                        NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
10065                        "standalone workspace initialization succeeded but registration failed; recovery is required",
10066                    ))
10067                }
10068            }
10069        }
10070    }
10071
10072    fn unregister_workspace(&self, workspace_id: &WorkspaceId) -> Result<(), NodeFailure> {
10073        let bindings = self
10074            .session_bindings
10075            .lock()
10076            .unwrap_or_else(|poisoned| poisoned.into_inner());
10077        if bindings.values().any(|binding| &binding.workspace_id == workspace_id) {
10078            return Err(failure(
10079                NodeFailureCode::WorkspaceBusy,
10080                "workspace retains a bound session; remove every session before unregistering it",
10081            ));
10082        }
10083        drop(bindings);
10084        let transaction = self
10085            .state_transaction
10086            .lock()
10087            .unwrap_or_else(|poisoned| poisoned.into_inner());
10088        let mut workspaces = self
10089            .workspaces
10090            .write()
10091            .unwrap_or_else(|poisoned| poisoned.into_inner());
10092        if !workspaces.contains_key(workspace_id) {
10093            return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
10094        }
10095        if workspaces.len() == 1 {
10096            return Err(failure(
10097                NodeFailureCode::LastWorkspace,
10098                "node must retain at least one workspace",
10099            ));
10100        }
10101        let removed_root = workspaces
10102            .remove(workspace_id)
10103            .expect("workspace presence was validated");
10104        drop(workspaces);
10105        let previous_records = {
10106            let mut records = self
10107                .session_records
10108                .lock()
10109                .unwrap_or_else(|poisoned| poisoned.into_inner());
10110            let previous = records.records.clone();
10111            for record in records
10112                .records
10113                .values_mut()
10114                .filter(|record| &record.workspace_id == workspace_id)
10115            {
10116                record.active_session = None;
10117                record.state = ManagedSessionState::Unavailable;
10118                record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
10119                record.updated_at_unix_ms = unix_time_ms();
10120            }
10121            previous
10122        };
10123        if let Err(error) = self.persist_state_locked() {
10124            self.workspaces
10125                .write()
10126                .unwrap_or_else(|poisoned| poisoned.into_inner())
10127                .insert(workspace_id.clone(), removed_root);
10128            self.session_records
10129                .lock()
10130                .unwrap_or_else(|poisoned| poisoned.into_inner())
10131                .records = previous_records;
10132            return Err(persistence_failure(error));
10133        }
10134        let unavailable = self
10135            .session_records
10136            .lock()
10137            .unwrap_or_else(|poisoned| poisoned.into_inner())
10138            .records
10139            .values()
10140            .filter(|record| &record.workspace_id == workspace_id)
10141            .cloned()
10142            .collect::<Vec<_>>();
10143        drop(transaction);
10144        self.publish(NodeEvent::WorkspaceRemoved {
10145            workspace_id: workspace_id.clone(),
10146        });
10147        for record in unavailable {
10148            self.publish_record(record);
10149        }
10150        Ok(())
10151    }
10152
10153    async fn create_worktree(
10154        &self,
10155        source_workspace_id: WorkspaceId,
10156        workspace_id: WorkspaceId,
10157        target_root: String,
10158        branch: String,
10159        base: Option<String>,
10160    ) -> Result<(GitWorktreeSnapshot, WorkspaceSnapshot), NodeFailure> {
10161        self.require_worktree_service(&source_workspace_id)?;
10162        validate_workspace_request_root(&target_root)?;
10163        validate_git_revision("worktree base", base.as_deref())?;
10164        let source_root = self.workspace_root(&source_workspace_id)?;
10165        {
10166            let workspaces = self
10167                .workspaces
10168                .read()
10169                .unwrap_or_else(|poisoned| poisoned.into_inner());
10170            if workspaces.contains_key(&workspace_id) {
10171                return Err(failure(
10172                    NodeFailureCode::DuplicateWorkspaceId,
10173                    "workspace ID is already registered",
10174                ));
10175            }
10176            if let Some((existing_id, _)) = workspaces
10177                .iter()
10178                .find(|(_, root)| worktree_paths_equal(root, &target_root))
10179            {
10180                return Err(failure(
10181                    NodeFailureCode::DuplicateWorkspaceRoot,
10182                    &format!("workspace root is already registered as '{existing_id}'"),
10183                ));
10184            }
10185        }
10186        let mut worktree = create_git_worktree(
10187            &source_root,
10188            &target_root,
10189            &branch,
10190            base.as_deref(),
10191        )
10192        .await
10193        .map_err(git_worktree_failure)?;
10194        let workspace = match self
10195            .register_workspace(workspace_id, worktree.path.clone())
10196            .await
10197        {
10198            Ok(workspace) => workspace,
10199            Err(registration_error) => {
10200                if remove_git_worktree(&source_root, &worktree.path).await.is_err() {
10201                    return Err(failure(
10202                        NodeFailureCode::ManagedWorktreeRecoveryRequired,
10203                        "worktree registration failed and compensation requires recovery",
10204                    ));
10205                }
10206                return Err(failure(
10207                    registration_error.code,
10208                    &registration_error.message,
10209                ));
10210            }
10211        };
10212        worktree.workspace_id = Some(workspace.workspace_id.clone());
10213        Ok((protocol_worktree(worktree), workspace))
10214    }
10215
10216    async fn remove_worktree(
10217        &self,
10218        source_workspace_id: WorkspaceId,
10219        target_root: String,
10220    ) -> Result<Option<WorkspaceId>, NodeFailure> {
10221        self.require_worktree_service(&source_workspace_id)?;
10222        validate_workspace_request_root(&target_root)?;
10223        let source_root = self.workspace_root(&source_workspace_id)?;
10224        let target_root = normalize_worktree_removal_target(&target_root)
10225            .map_err(git_worktree_failure)?;
10226        let target = list_git_worktrees(&source_root)
10227            .await
10228            .map_err(git_worktree_failure)?
10229            .into_iter()
10230            .find(|worktree| worktree_paths_equal(&worktree.path, &target_root))
10231            .ok_or_else(|| {
10232                failure(
10233                    NodeFailureCode::WorktreeProtected,
10234                    "refusing to remove a path that is not in Git's worktree listing",
10235                )
10236            })?;
10237        let registered_workspace_id = {
10238            let workspaces = self
10239                .workspaces
10240                .read()
10241                .unwrap_or_else(|poisoned| poisoned.into_inner());
10242            let matched = workspaces
10243                .iter()
10244                .find(|(_, root)| worktree_paths_equal(root, &target.path))
10245                .map(|(workspace_id, _)| workspace_id.clone());
10246            if matched.is_some() && workspaces.len() == 1 {
10247                return Err(failure(
10248                    NodeFailureCode::LastWorkspace,
10249                    "node must retain at least one workspace",
10250                ));
10251            }
10252            matched
10253        };
10254        if let Some(workspace_id) = registered_workspace_id.as_ref() {
10255            let bindings = self
10256                .session_bindings
10257                .lock()
10258                .unwrap_or_else(|poisoned| poisoned.into_inner());
10259            if bindings
10260                .values()
10261                .any(|binding| &binding.workspace_id == workspace_id)
10262            {
10263                return Err(failure(
10264                    NodeFailureCode::WorkspaceBusy,
10265                    "worktree workspace retains a bound session; remove every session first",
10266                ));
10267            }
10268        }
10269        remove_git_worktree(&source_root, &target.path)
10270            .await
10271            .map_err(git_worktree_failure)?;
10272        if let Some(workspace_id) = registered_workspace_id.as_ref() {
10273            self.unregister_workspace(workspace_id)
10274                .map_err(|error| failure(error.code, &error.message))?;
10275        }
10276        Ok(registered_workspace_id)
10277    }
10278
10279    async fn cleanup_managed_worktree(
10280        &self,
10281        lease_id: &ManagedWorktreeLeaseId,
10282        explicit: bool,
10283    ) -> Result<ManagedWorktreeLeaseSnapshot, NodeFailure> {
10284        let lease = self.managed_worktrees.lock()
10285            .unwrap_or_else(|poisoned| poisoned.into_inner())
10286            .get(lease_id)
10287            .cloned()
10288            .ok_or_else(|| failure(
10289                NodeFailureCode::UnknownManagedWorktreeLease,
10290                "managed worktree lease does not exist",
10291            ))?;
10292        if lease.state == ManagedWorktreeLeaseState::Removed {
10293            return Ok(lease.snapshot());
10294        }
10295        if lease.has_holders() {
10296            return Err(failure(
10297                NodeFailureCode::ManagedWorktreeBusy,
10298                "managed worktree lease still has a session or durable-record holder",
10299            ));
10300        }
10301        if self
10302            .materializations
10303            .lock()
10304            .unwrap_or_else(|poisoned| poisoned.into_inner())
10305            .values()
10306            .any(|record| record.managed_lease_id() == Some(lease_id))
10307        {
10308            return Err(failure(
10309                NodeFailureCode::ManagedWorktreeRecoveryRequired,
10310                "managed worktree cleanup is blocked by session environment ownership",
10311            ));
10312        }
10313        if !explicit && lease.retention == ManagedWorktreeRetention::Retain {
10314            let snapshot = {
10315                let mut registry = self.managed_worktrees.lock()
10316                    .unwrap_or_else(|poisoned| poisoned.into_inner());
10317                let current = registry.get_mut(lease_id)
10318                    .expect("managed worktree lease remains present");
10319                current.state = ManagedWorktreeLeaseState::Retained;
10320                current.cleanup_failure = None;
10321                current.updated_at_unix_ms = unix_time_ms();
10322                current.snapshot()
10323            };
10324            if self.persist_state().is_err() {
10325                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
10326            }
10327            self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
10328            return Ok(snapshot);
10329        }
10330        let profile = self.managed_profile(&lease.source_workspace_id, &lease.profile_id)?;
10331        if profile.revision() != &lease.profile_revision {
10332            self.mark_managed_recovery_required(
10333                lease_id,
10334                ManagedWorktreeCleanupFailure::OwnershipConflict,
10335            );
10336            return Err(failure(
10337                NodeFailureCode::ManagedWorktreeRecoveryRequired,
10338                "managed worktree profile revision changed",
10339            ));
10340        }
10341        profile.validate_target_authority(&lease.lease_id, &lease.target_root).map_err(|message| {
10342            self.mark_managed_recovery_required(
10343                lease_id,
10344                ManagedWorktreeCleanupFailure::OwnershipConflict,
10345            );
10346            failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message)
10347        })?;
10348        let source_root = self.workspace_root(&lease.source_workspace_id)?;
10349        let listed = list_git_worktrees(&source_root).await.map_err(|error| {
10350            self.mark_managed_cleanup_blocked(lease_id, ManagedWorktreeCleanupFailure::Backend);
10351            managed_git_worktree_failure(error)
10352        })?;
10353        let target = listed.iter()
10354            .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
10355        if let Some(target) = target {
10356            if target.is_main || target.is_bare || !exact_owned_worktree(
10357                &lease,
10358                &target.path,
10359                target.branch.as_deref(),
10360                &target.head,
10361            ) {
10362                self.mark_managed_recovery_required(
10363                    lease_id,
10364                    ManagedWorktreeCleanupFailure::OwnershipConflict,
10365                );
10366                return Err(failure(
10367                    NodeFailureCode::ManagedWorktreeOwnershipConflict,
10368                    "Git worktree identity no longer matches the managed lease",
10369                ));
10370            }
10371            if target.prunable {
10372                self.mark_managed_cleanup_blocked(
10373                    lease_id,
10374                    ManagedWorktreeCleanupFailure::Prunable,
10375                );
10376                return Err(failure(
10377                    NodeFailureCode::ManagedWorktreeRecoveryRequired,
10378                    "managed worktree is prunable and requires recovery",
10379                ));
10380            }
10381            if let Err(error) = remove_git_worktree(&source_root, &target.path).await {
10382                let failure_kind = match error.kind {
10383                    GitWorktreeErrorKind::Dirty => ManagedWorktreeCleanupFailure::Dirty,
10384                    GitWorktreeErrorKind::Locked => ManagedWorktreeCleanupFailure::Locked,
10385                    GitWorktreeErrorKind::Protected => ManagedWorktreeCleanupFailure::OwnershipConflict,
10386                    GitWorktreeErrorKind::Conflict => ManagedWorktreeCleanupFailure::Busy,
10387                    GitWorktreeErrorKind::Invalid | GitWorktreeErrorKind::NotRepository
10388                    | GitWorktreeErrorKind::Failed => ManagedWorktreeCleanupFailure::Backend,
10389                };
10390                if failure_kind == ManagedWorktreeCleanupFailure::OwnershipConflict {
10391                    self.mark_managed_recovery_required(lease_id, failure_kind);
10392                } else {
10393                    self.mark_managed_cleanup_blocked(lease_id, failure_kind);
10394                }
10395                return Err(managed_git_worktree_failure(error));
10396            }
10397        } else {
10398            match std::fs::symlink_metadata(&lease.target_root) {
10399                Err(error) if error.kind() == io::ErrorKind::NotFound => {}
10400                Ok(_) => {
10401                    self.mark_managed_recovery_required(
10402                        lease_id,
10403                        ManagedWorktreeCleanupFailure::OwnershipConflict,
10404                    );
10405                    return Err(failure(
10406                        NodeFailureCode::ManagedWorktreeOwnershipConflict,
10407                        "managed target exists but is not owned by Git",
10408                    ));
10409                }
10410                Err(_) => {
10411                    self.mark_managed_cleanup_blocked(
10412                        lease_id,
10413                        ManagedWorktreeCleanupFailure::Backend,
10414                    );
10415                    return Err(failure(
10416                        NodeFailureCode::BackendOperationFailed,
10417                        "managed target identity could not be inspected",
10418                    ));
10419                }
10420            }
10421        }
10422        let registered = self.workspaces.read()
10423            .unwrap_or_else(|poisoned| poisoned.into_inner())
10424            .contains_key(&lease.workspace_id);
10425        if registered {
10426            self.unregister_workspace(&lease.workspace_id)?;
10427        }
10428        let removed = {
10429            self.managed_worktrees.lock()
10430                .unwrap_or_else(|poisoned| poisoned.into_inner())
10431                .tombstone(lease_id, unix_time_ms())
10432                .expect("managed worktree lease remains present during cleanup")
10433        };
10434        self.persist_state().map_err(persistence_failure)?;
10435        self.publish(NodeEvent::ManagedWorktreeRemoved { lease_id: lease_id.clone() });
10436        Ok(removed.snapshot())
10437    }
10438
10439    fn mark_managed_cleanup_blocked(
10440        &self,
10441        lease_id: &ManagedWorktreeLeaseId,
10442        failure_kind: ManagedWorktreeCleanupFailure,
10443    ) {
10444        let snapshot = {
10445            let mut registry = self.managed_worktrees.lock()
10446                .unwrap_or_else(|poisoned| poisoned.into_inner());
10447            let Some(lease) = registry.get_mut(lease_id) else { return };
10448            lease.state = ManagedWorktreeLeaseState::CleanupBlocked;
10449            lease.cleanup_failure = Some(failure_kind);
10450            lease.updated_at_unix_ms = unix_time_ms();
10451            lease.snapshot()
10452        };
10453        let _ = self.persist_state();
10454        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
10455    }
10456
10457    fn require_worktree_service(
10458        &self,
10459        source_workspace_id: &WorkspaceId,
10460    ) -> Result<(), NodeFailure> {
10461        if self.managed_worktrees.lock()
10462            .unwrap_or_else(|poisoned| poisoned.into_inner())
10463            .lease_for_workspace(source_workspace_id)
10464            .is_some()
10465        {
10466            return Err(failure(
10467                NodeFailureCode::ManagedWorktreeOwnershipConflict,
10468                "managed worktree targets are internal-only workspaces",
10469            ));
10470        }
10471        match self
10472            .worktree_service_modes
10473            .get(source_workspace_id)
10474            .copied()
10475            .unwrap_or(WorktreeServiceMode::Manual)
10476        {
10477            WorktreeServiceMode::Manual => Ok(()),
10478            WorktreeServiceMode::Managed | WorktreeServiceMode::Off => Err(failure(
10479                NodeFailureCode::UnsupportedSpawnCapability,
10480                "Git worktree service mode is unavailable in this node build",
10481            )),
10482        }
10483    }
10484
10485    fn managed_reservation_conflict(
10486        &self,
10487        workspace_id: Option<&WorkspaceId>,
10488        root: Option<&str>,
10489    ) -> bool {
10490        self.managed_worktrees.lock()
10491            .unwrap_or_else(|poisoned| poisoned.into_inner())
10492            .active_records()
10493            .any(|lease| {
10494                workspace_id.is_some_and(|workspace_id| {
10495                    workspace_id == &lease.source_workspace_id
10496                        || workspace_id == &lease.workspace_id
10497                }) || root.is_some_and(|root| worktree_paths_equal(root, &lease.target_root))
10498            })
10499    }
10500
10501    fn reject_managed_reservation(
10502        &self,
10503        workspace_id: Option<&WorkspaceId>,
10504        root: Option<&str>,
10505    ) -> Result<(), NodeFailure> {
10506        if self.managed_reservation_conflict(workspace_id, root) {
10507            Err(failure(
10508                NodeFailureCode::ManagedWorktreeOwnershipConflict,
10509                "managed worktree ownership may only be mutated through its lease cleanup",
10510            ))
10511        } else {
10512            Ok(())
10513        }
10514    }
10515
10516    fn bound_workspace_root(&self, address: &SessionAddress) -> Result<String, NodeFailure> {
10517        self.validate_address(address)?;
10518        self.workspace_root(&address.workspace_id)
10519    }
10520
10521    /// `Resume`/`ResumeWithPrompt` are always a raw-PTY relaunch (see
10522    /// `require_policy`'s own doc on those two requirements), so they keep
10523    /// gating on `binding.runtime_policy`'s PTY-terminal-text-inference
10524    /// flags exactly as before; `pipe_transport`/`acp_transport` stay
10525    /// `false` on the `require_policy` call below for them because a PTY
10526    /// resume never needs those facts, not because this method cannot look
10527    /// them up.
10528    ///
10529    /// `SemanticPrompt` -- the requirement `Prompt`/`Paste` share -- reads
10530    /// the SAME `binding.runtime_policy` every other requirement reads here
10531    /// (one computed policy, read by every gate). That policy is no longer
10532    /// PTY-terminal-verification-shaped for an ACP session --
10533    /// `spawn_session_with_deadline` computes it BY TRANSPORT now, through
10534    /// `provider_runtime::policy_for_transport` -- so consulting it for
10535    /// `Prompt`/`Paste` on ACP is sound where it used to gate the wrong fact
10536    /// (this is what made an ACP `Prompt` come back `UnsupportedCapability`
10537    /// even though a prompt is an ACP session's only interface). Pipe keeps
10538    /// reading the same PTY-probe-derived value it always has --
10539    /// `policy_for_transport` passes Pipe through unchanged this pass (see
10540    /// its own doc for why).
10541    ///
10542    /// What this method still adds on top, for ACP/Pipe only, is a
10543    /// turn-in-flight PRECONDITION ahead of that policy check: a session
10544    /// takes one turn at a time, so a `Prompt`/`Paste` is refused by name
10545    /// (`NodeFailureCode::TurnInFlight`) while a turn is already running,
10546    /// read off the same live `SessionSnapshot` state `require_acp_session`
10547    /// already reads for the ACP control verbs -- `TurnStarted` observed
10548    /// with no matching `TurnCompleted` yet is exactly `ProviderSnapshot::
10549    /// lead_activity != Idle`. Sending a second prompt mid-turn would put
10550    /// its fate in vendor-specific behaviour this node cannot observe (one
10551    /// agent may queue it, another drop it, another interleave it), so this
10552    /// refuses it outright rather than gambling on that -- a rule with no
10553    /// PTY analogue, which is why PTY does not take this extra precondition.
10554    fn require_session_runtime_policy(
10555        &self,
10556        address: &SessionAddress,
10557        requirement: ProviderRuntimeRequirement,
10558    ) -> Result<ProviderRuntimePolicy, NodeFailure> {
10559        let runtime_policy = {
10560            let bindings = self
10561                .session_bindings
10562                .lock()
10563                .unwrap_or_else(|poisoned| poisoned.into_inner());
10564            let binding = bindings
10565                .get(&address.session.instance_id)
10566                .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10567            if binding.workspace_id != address.workspace_id {
10568                return Err(failure(
10569                    NodeFailureCode::SessionWorkspaceMismatch,
10570                    "session belongs to another workspace",
10571                ));
10572            }
10573            if binding.generation != address.session.generation {
10574                return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10575            }
10576            binding.runtime_policy
10577        };
10578        if requirement == ProviderRuntimeRequirement::SemanticPrompt {
10579            let snapshot = self.internal_session_snapshot(address)?;
10580            if matches!(snapshot.transport, TransportKind::Acp | TransportKind::Pipe)
10581                && snapshot.provider.lead_activity != ProviderActivity::Idle
10582            {
10583                return Err(failure(
10584                    NodeFailureCode::TurnInFlight,
10585                    "session already has a turn in flight; wait for it to complete before sending another prompt",
10586                ));
10587            }
10588        }
10589        require_policy(runtime_policy, false, false, requirement)
10590            .map(|()| runtime_policy)
10591            .map_err(|_| failure(
10592                NodeFailureCode::UnsupportedCapability,
10593                "session runtime policy does not admit this semantic operation",
10594            ))
10595    }
10596
10597    fn remove_binding(&self, address: &SessionAddress) -> Option<SessionBinding> {
10598        let mut bindings = self
10599            .session_bindings
10600            .lock()
10601            .unwrap_or_else(|poisoned| poisoned.into_inner());
10602        if bindings.get(&address.session.instance_id).is_some_and(|binding| {
10603            binding.workspace_id == address.workspace_id
10604                && binding.generation == address.session.generation
10605        }) {
10606            let removed = bindings.remove(&address.session.instance_id);
10607            self.clear_terminal_frame_watermark(address);
10608            SessionLifecycleHook::on_binding_removed(self, address, removed.as_ref());
10609            return removed;
10610        }
10611        None
10612    }
10613
10614    fn cleanup_session_owned_materialization(
10615        &self,
10616        address: &SessionAddress,
10617        binding: &SessionBinding,
10618    ) -> Result<(), NodeFailure> {
10619        let Some(id) = binding.materialization_id.as_ref() else {
10620            return Ok(());
10621        };
10622        let owner = self
10623            .materializations
10624            .lock()
10625            .unwrap_or_else(|poisoned| poisoned.into_inner())
10626            .get(id)
10627            .map(|record| record.owner().clone());
10628        match owner {
10629            None | Some(MaterializationOwner::Record { .. }) => Ok(()),
10630            Some(MaterializationOwner::Session {
10631                incarnation_id,
10632                instance_id,
10633                generation,
10634            }) if incarnation_id == self.incarnation_id
10635                && instance_id == address.session.instance_id
10636                && generation == address.session.generation =>
10637            {
10638                self.cleanup_materialization(id)
10639            }
10640            Some(MaterializationOwner::Session { .. }) => Err(failure(
10641                NodeFailureCode::BackendOperationFailed,
10642                "session environment ownership requires recovery",
10643            )),
10644        }
10645    }
10646
10647    fn clear_terminal_frame_watermark(&self, address: &SessionAddress) {
10648        let mut watermarks = self
10649            .terminal_frame_watermarks
10650            .lock()
10651            .unwrap_or_else(|poisoned| poisoned.into_inner());
10652        if watermarks
10653            .get(&address.session.instance_id)
10654            .is_some_and(|(current, _)| current == address)
10655        {
10656            watermarks.remove(&address.session.instance_id);
10657        }
10658    }
10659
10660    #[cfg(test)]
10661    fn address_for(
10662        &self,
10663        instance_id: AgentInstanceId,
10664        generation: SessionGeneration,
10665    ) -> Option<SessionAddress> {
10666        let binding = self.session_bindings
10667            .lock()
10668            .unwrap_or_else(|poisoned| poisoned.into_inner())
10669            .get(&instance_id)
10670            .cloned()?;
10671        (binding.generation == generation).then_some(SessionAddress {
10672            workspace_id: binding.workspace_id,
10673            session: SessionKey { instance_id, generation },
10674        })
10675    }
10676
10677    fn arm_resume(
10678        &self,
10679        address: &SessionAddress,
10680        command_id: CommandId,
10681        runtime_policy: ProviderRuntimePolicy,
10682    ) -> Result<(), NodeFailure> {
10683        let mut bindings = self
10684            .session_bindings
10685            .lock()
10686            .unwrap_or_else(|poisoned| poisoned.into_inner());
10687        let binding = bindings
10688            .get_mut(&address.session.instance_id)
10689            .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10690        if binding.workspace_id != address.workspace_id {
10691            return Err(failure(
10692                NodeFailureCode::SessionWorkspaceMismatch,
10693                "session belongs to another workspace",
10694            ));
10695        }
10696        if binding.generation != address.session.generation {
10697            return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10698        }
10699        binding.pending_resume = Some((binding.generation, command_id, runtime_policy));
10700        Ok(())
10701    }
10702
10703    fn clear_armed_resume(&self, address: &SessionAddress) {
10704        let mut bindings = self
10705            .session_bindings
10706            .lock()
10707            .unwrap_or_else(|poisoned| poisoned.into_inner());
10708        if let Some(binding) = bindings.get_mut(&address.session.instance_id) {
10709            if binding.workspace_id == address.workspace_id
10710                && binding.generation == address.session.generation
10711            {
10712                binding.pending_resume = None;
10713            }
10714        }
10715    }
10716
10717    fn publish_control(&self, event: ControlEvent) {
10718        let revokes_harness_mcp = matches!(
10719            &event.event,
10720            ControlEventKind::Exited { .. }
10721                | ControlEventKind::Failed { .. }
10722                | ControlEventKind::Removed
10723        );
10724        let (address, previous_address, record_id, identity_admitted, resume_command_rejected) = {
10725            let mut bindings = self
10726                .session_bindings
10727                .lock()
10728                .unwrap_or_else(|poisoned| poisoned.into_inner());
10729            let Some(binding) = bindings.get_mut(&event.instance_id) else {
10730                return;
10731            };
10732            let previous_address = SessionAddress {
10733                workspace_id: binding.workspace_id.clone(),
10734                session: SessionKey {
10735                    instance_id: event.instance_id,
10736                    generation: binding.generation,
10737                },
10738            };
10739            let mut resume_command_rejected = false;
10740            if binding.generation == event.generation {
10741                if let Some((pending_generation, pending_command_id, _)) = binding.pending_resume {
10742                    let rejected_resume = matches!(
10743                        &event.event,
10744                        ControlEventKind::CommandRejected { .. }
10745                    ) && event.command_id == Some(pending_command_id);
10746                    let resume_failed = matches!(
10747                        &event.event,
10748                        ControlEventKind::ResumeDenied { .. }
10749                            | ControlEventKind::ResumeFailed { .. }
10750                    );
10751                    if pending_generation == binding.generation
10752                        && (rejected_resume || resume_failed)
10753                    {
10754                        resume_command_rejected = rejected_resume;
10755                        binding.pending_resume = None;
10756                    }
10757                }
10758            } else {
10759                let expected = binding.generation.0.checked_add(1);
10760                let authorized = matches!(&event.event, ControlEventKind::ResumeAuthorized { .. });
10761                if !binding.pending_resume.is_some_and(|(generation, _, _)| {
10762                    generation == binding.generation
10763                })
10764                    || expected != Some(event.generation.0)
10765                    || !authorized
10766                {
10767                    return;
10768                }
10769                let (_, _, runtime_policy) = binding
10770                    .pending_resume
10771                    .expect("authorized resume retains its pending runtime policy");
10772                binding.generation = event.generation;
10773                binding.runtime_policy = runtime_policy;
10774                binding.pending_resume = None;
10775            }
10776            (
10777                SessionAddress {
10778                    workspace_id: binding.workspace_id.clone(),
10779                    session: SessionKey {
10780                        instance_id: event.instance_id,
10781                        generation: event.generation,
10782                    },
10783                },
10784                (previous_address.session.generation != event.generation)
10785                    .then_some(previous_address),
10786                binding.record_id.clone(),
10787                binding.runtime_policy.provider_session_identity,
10788                resume_command_rejected,
10789            )
10790        };
10791        if let Some(previous_address) = previous_address.as_ref() {
10792            self.clear_terminal_frame_watermark(previous_address);
10793        }
10794        if let Some(record_id) = record_id {
10795            self.reconcile_managed_record(
10796                &record_id,
10797                &address,
10798                &event,
10799                identity_admitted,
10800                resume_command_rejected,
10801            );
10802        }
10803        // Every state change a session's process lifecycle can reach gets a
10804        // line here: this is the single chokepoint every `ControlEventKind`
10805        // passes through regardless of transport (PTY/pipe/ACP) or whether it
10806        // came from a fresh spawn or a resume. Per-message provider chatter
10807        // (`ProviderEvent`/`ProviderGap`/terminal frames) is deliberately not
10808        // matched here -- that is exactly the per-frame noise this crate
10809        // family's level discipline forbids at INFO.
10810        match &event.event {
10811            ControlEventKind::Running { process_id } => {
10812                #[cfg(unix)]
10813                if let (Some(proxy), Some(pid)) = (self.signer_proxy.get(), process_id) {
10814                    proxy.attach_pid(&format!("{}-{}", self.incarnation_id, address.session.instance_id.0), *pid);
10815                }
10816                tracing::info!(
10817                    node_id = %self.node_id,
10818                    workspace_id = %address.workspace_id,
10819                    session = ?address.session,
10820                    pid = ?process_id,
10821                    "session process is running",
10822                );
10823            }
10824            ControlEventKind::Exited { exit_code, forced } => {
10825                #[cfg(unix)]
10826                if let Some(proxy) = self.signer_proxy.get().cloned() {
10827                    let key = format!("{}-{}", self.incarnation_id, address.session.instance_id.0);
10828                    if proxy.has_session(&key) {
10829                        // Revoke at core off the event path (blocking uplink call).
10830                        std::thread::spawn(move || proxy.release(&key));
10831                    }
10832                }
10833                tracing::info!(
10834                    node_id = %self.node_id,
10835                    workspace_id = %address.workspace_id,
10836                    session = ?address.session,
10837                    exit_code = ?exit_code,
10838                    forced = forced,
10839                    "session process exited",
10840                );
10841            }
10842            ControlEventKind::Failed { message } => {
10843                tracing::warn!(
10844                    node_id = %self.node_id,
10845                    workspace_id = %address.workspace_id,
10846                    session = ?address.session,
10847                    cause = %message,
10848                    "session failed",
10849                );
10850            }
10851            ControlEventKind::CommandRejected { message } => {
10852                tracing::warn!(
10853                    node_id = %self.node_id,
10854                    workspace_id = %address.workspace_id,
10855                    session = ?address.session,
10856                    cause = %message,
10857                    "session command rejected",
10858                );
10859            }
10860            ControlEventKind::Removed => {
10861                tracing::info!(
10862                    node_id = %self.node_id,
10863                    workspace_id = %address.workspace_id,
10864                    session = ?address.session,
10865                    "session removed",
10866                );
10867            }
10868            ControlEventKind::ResumeAuthorized { session: provider_session } => {
10869                tracing::info!(
10870                    node_id = %self.node_id,
10871                    workspace_id = %address.workspace_id,
10872                    session = ?address.session,
10873                    provider_session = ?provider_session,
10874                    "session resume authorized",
10875                );
10876            }
10877            ControlEventKind::Resumed { session: provider_session, process_id } => {
10878                tracing::info!(
10879                    node_id = %self.node_id,
10880                    workspace_id = %address.workspace_id,
10881                    session = ?address.session,
10882                    provider_session = ?provider_session,
10883                    pid = ?process_id,
10884                    "session resumed",
10885                );
10886            }
10887            ControlEventKind::ResumeDenied { reason } => {
10888                tracing::warn!(
10889                    node_id = %self.node_id,
10890                    workspace_id = %address.workspace_id,
10891                    session = ?address.session,
10892                    cause = %reason,
10893                    "session resume denied",
10894                );
10895            }
10896            ControlEventKind::ResumeFailed { message } => {
10897                tracing::warn!(
10898                    node_id = %self.node_id,
10899                    workspace_id = %address.workspace_id,
10900                    session = ?address.session,
10901                    cause = %message,
10902                    "session resume failed",
10903                );
10904            }
10905            _ => {}
10906        }
10907        self.apply_pending_acp_options(&address, &event);
10908        self.apply_acp_auto_permit(&address, &event);
10909        let stream_chunk = agent_stream_chunk(&event);
10910        self.publish(NodeEvent::Control {
10911            address: address.clone(),
10912            event,
10913        });
10914        if let Some(chunk) = stream_chunk {
10915            self.publish_agent_stream_chunk(address.clone(), chunk);
10916        }
10917        if revokes_harness_mcp {
10918            SessionLifecycleHook::on_session_ended(self, &address);
10919        }
10920    }
10921
10922    fn reconcile_managed_record(
10923        &self,
10924        record_id: &SessionRecordId,
10925        address: &SessionAddress,
10926        event: &ControlEvent,
10927        identity_admitted: bool,
10928        resume_command_rejected: bool,
10929    ) {
10930        let _transaction = self
10931            .state_transaction
10932            .lock()
10933            .unwrap_or_else(|poisoned| poisoned.into_inner());
10934        let observed_identity = match &event.event {
10935            ControlEventKind::ProviderEvent {
10936                event: gate4agent_types::ProviderEvent::SessionIdentityObserved { identity },
10937                ..
10938            } if identity_admitted => Some(identity.clone()),
10939            _ => None,
10940        };
10941        let identity_was_observed = observed_identity.is_some();
10942        let replacement_id = observed_identity
10943            .as_ref()
10944            .and_then(|_| self.allocate_record_id().ok());
10945        let mut upserts = Vec::new();
10946        let mut removals = Vec::new();
10947        let mut rebind = None;
10948        let original_records;
10949        let original_materializations = self
10950            .materializations
10951            .lock()
10952            .unwrap_or_else(|poisoned| poisoned.into_inner())
10953            .clone();
10954        {
10955            let mut records = self
10956                .session_records
10957                .lock()
10958                .unwrap_or_else(|poisoned| poisoned.into_inner());
10959            original_records = records.records.clone();
10960            let Some(current_snapshot) = records.records.get(record_id).cloned() else {
10961                return;
10962            };
10963            if let Some(identity) = observed_identity {
10964                let existing_id = records
10965                    .records
10966                    .values()
10967                    .find(|record| {
10968                        record.record_id != *record_id
10969                            && record.provider_session.as_ref().is_some_and(|record_identity| {
10970                                session_registry::same_provider_session(
10971                                    &record.provider,
10972                                    record_identity,
10973                                    &current_snapshot.provider,
10974                                    &identity,
10975                                )
10976                            })
10977                    })
10978                    .map(|record| record.record_id.clone());
10979                if let Some(existing_id) = existing_id {
10980                    let scope_matches = records.records.get(&existing_id).is_some_and(|record| {
10981                        record.mode == current_snapshot.mode
10982                            && record.workspace_id == current_snapshot.workspace_id
10983                            && record.environment_profile == current_snapshot.environment_profile
10984                            && record.bundle == current_snapshot.bundle
10985                            && record.context_id == current_snapshot.context_id
10986                            && record.context == current_snapshot.context
10987                            && record
10988                                .canonical_root
10989                                .as_utf8()
10990                                .zip(current_snapshot.canonical_root.as_utf8())
10991                                .is_some_and(|(left, right)| platform::roots_equal(left, right))
10992                    });
10993                    if !scope_matches {
10994                        if let Some(current) = records.records.get_mut(record_id) {
10995                            current.state = ManagedSessionState::Unavailable;
10996                            current.last_error =
10997                                Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR.to_owned());
10998                            current.updated_at_unix_ms = unix_time_ms();
10999                            upserts.push(current.clone());
11000                        }
11001                    } else {
11002                        let existing_is_active = records
11003                            .records
11004                            .get(&existing_id)
11005                            .is_some_and(|record| record.active_session.is_some());
11006                        if existing_is_active {
11007                            if let Some(current) = records.records.get_mut(record_id) {
11008                                current.state = ManagedSessionState::Unavailable;
11009                                current.last_error =
11010                                    Some(PROVIDER_SESSION_LIVE_CONFLICT_ERROR.to_owned());
11011                                current.updated_at_unix_ms = unix_time_ms();
11012                                upserts.push(current.clone());
11013                            }
11014                        } else {
11015                            if current_snapshot.provider_session.is_some() {
11016                                if let Some(current) = records.records.get_mut(record_id) {
11017                                    current.active_session = None;
11018                                    current.state = ManagedSessionState::Dormant;
11019                                    current.updated_at_unix_ms = unix_time_ms();
11020                                    upserts.push(current.clone());
11021                                }
11022                            } else {
11023                                records.records.remove(record_id);
11024                                removals.push(record_id.clone());
11025                            }
11026                            if let Some(existing) = records.records.get_mut(&existing_id) {
11027                                existing.provider_session = Some(identity.clone());
11028                                existing.active_session = Some(address.clone());
11029                                existing.state = ManagedSessionState::Live;
11030                                existing.updated_at_unix_ms = unix_time_ms();
11031                                existing.last_error = None;
11032                                upserts.push(existing.clone());
11033                            }
11034                            rebind = Some(existing_id);
11035                        }
11036                    }
11037                } else if current_snapshot
11038                    .provider_session
11039                    .as_ref()
11040                    .is_some_and(|current| {
11041                        !session_registry::same_provider_session(
11042                            &current_snapshot.provider,
11043                            current,
11044                            &current_snapshot.provider,
11045                            &identity,
11046                        )
11047                    })
11048                {
11049                    if let Some(current) = records.records.get_mut(record_id) {
11050                        current.active_session = None;
11051                        if current.bundle.is_some() || current.context.is_some() {
11052                            let had_bundle = current.bundle.is_some();
11053                            current.provider_session = None;
11054                            current.bundle = None;
11055                            current.context_id = None;
11056                            current.context = None;
11057                            current.state = ManagedSessionState::Unavailable;
11058                            current.last_error = Some(if had_bundle {
11059                                "bundle-unavailable".to_owned()
11060                            } else {
11061                                "context-unavailable".to_owned()
11062                            });
11063                        } else {
11064                            current.state = ManagedSessionState::Dormant;
11065                        }
11066                        current.updated_at_unix_ms = unix_time_ms();
11067                        upserts.push(current.clone());
11068                    }
11069                    if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
11070                        if let Some(current) = records.records.get_mut(record_id) {
11071                            current.state = ManagedSessionState::Unavailable;
11072                            current.last_error =
11073                                Some(MANAGED_SESSION_CAPACITY_ERROR.to_owned());
11074                            current.updated_at_unix_ms = unix_time_ms();
11075                            upserts.push(current.clone());
11076                        }
11077                    } else if let Some(new_record_id) = replacement_id {
11078                        let now = unix_time_ms();
11079                        let replacement = ManagedSessionRecord {
11080                            record_id: new_record_id.clone(),
11081                            display_name: format!(
11082                                "{} #{}",
11083                                current_snapshot.provider.as_str(),
11084                                records
11085                                    .records
11086                                    .values()
11087                                    .filter(|record| {
11088                                        record.provider == current_snapshot.provider
11089                                    })
11090                                    .count()
11091                                    .saturating_add(1),
11092                            ),
11093                            provider: current_snapshot.provider.clone(),
11094                            mode: current_snapshot.mode,
11095                            state: ManagedSessionState::Live,
11096                            workspace_id: current_snapshot.workspace_id,
11097                            canonical_root: current_snapshot.canonical_root,
11098                            provider_session: Some(identity),
11099                            active_session: Some(address.clone()),
11100                            environment_profile: current_snapshot.environment_profile,
11101                            bundle: current_snapshot.bundle,
11102                            context_id: current_snapshot.context_id,
11103                            context: current_snapshot.context,
11104                            // This is a fresh record_id representing the session under its
11105                            // newly-observed identity; it has not yet produced any clean
11106                            // exit of its own, so it starts with no exported pack (the old
11107                            // record, still addressable under its own record_id, keeps
11108                            // whatever exported_context it already had).
11109                            exported_context: None,
11110                            task_binding: current_snapshot.task_binding,
11111                            created_at_unix_ms: now,
11112                            updated_at_unix_ms: now,
11113                            last_error: None,
11114                        };
11115                        records
11116                            .records
11117                            .insert(new_record_id.clone(), replacement.clone());
11118                        upserts.push(replacement);
11119                        rebind = Some(new_record_id);
11120                    } else if let Some(current) = records.records.get_mut(record_id) {
11121                        current.state = ManagedSessionState::Unavailable;
11122                        current.last_error =
11123                            Some(PROVIDER_IDENTITY_ALLOCATION_ERROR.to_owned());
11124                        upserts.push(current.clone());
11125                    }
11126                } else if let Some(current) = records.records.get_mut(record_id) {
11127                    current.provider_session = Some(identity);
11128                    current.active_session = Some(address.clone());
11129                    current.state = ManagedSessionState::Live;
11130                    current.updated_at_unix_ms = unix_time_ms();
11131                    current.last_error = None;
11132                    upserts.push(current.clone());
11133                }
11134            } else if let Some(current) = records.records.get_mut(record_id) {
11135                if resume_command_rejected {
11136                    current.active_session = None;
11137                    current.state = detached_record_state(current);
11138                    current.updated_at_unix_ms = unix_time_ms();
11139                    current.last_error =
11140                        Some(PROVIDER_RESUME_REJECTED_ERROR.to_owned());
11141                    upserts.push(current.clone());
11142                } else {
11143                    match &event.event {
11144                    ControlEventKind::Running { .. }
11145                    | ControlEventKind::ResumeAuthorized { .. }
11146                    | ControlEventKind::Resumed { .. } => {
11147                        current.active_session = Some(address.clone());
11148                        current.state = active_record_state(
11149                            identity_admitted,
11150                            current.provider_session.is_some(),
11151                        );
11152                        current.updated_at_unix_ms = unix_time_ms();
11153                        current.last_error = None;
11154                        upserts.push(current.clone());
11155                    }
11156                    ControlEventKind::Exited { .. } | ControlEventKind::Removed => {
11157                        current.active_session = None;
11158                        current.state = detached_record_state(current);
11159                        current.updated_at_unix_ms = unix_time_ms();
11160                        upserts.push(current.clone());
11161                    }
11162                    ControlEventKind::Failed { message }
11163                    | ControlEventKind::ResumeFailed { message } => {
11164                        current.active_session = None;
11165                        current.state = detached_record_state(current);
11166                        current.updated_at_unix_ms = unix_time_ms();
11167                        current.last_error = Some(
11168                            session_registry::sanitized_record_error_summary(message),
11169                        );
11170                        upserts.push(current.clone());
11171                    }
11172                    ControlEventKind::ResumeDenied { reason } => {
11173                        current.active_session = None;
11174                        current.state = detached_record_state(current);
11175                        current.updated_at_unix_ms = unix_time_ms();
11176                        current.last_error = Some(
11177                            session_registry::sanitized_record_error_summary(reason),
11178                        );
11179                        upserts.push(current.clone());
11180                    }
11181                        _ => {}
11182                    }
11183                }
11184            }
11185        }
11186        let materialization_owner_record = identity_was_observed.then(|| {
11187            rebind.clone().unwrap_or_else(|| record_id.clone())
11188        }).and_then(|candidate| {
11189            self.session_records
11190                .lock()
11191                .unwrap_or_else(|poisoned| poisoned.into_inner())
11192                .records
11193                .get(&candidate)
11194                .filter(|record| {
11195                    record.provider_session.is_some()
11196                        && record.active_session.as_ref() == Some(address)
11197                })
11198                .map(|_| candidate)
11199        });
11200        if let Some(owner_record_id) = materialization_owner_record {
11201            let materialization_id = self
11202                .session_bindings
11203                .lock()
11204                .unwrap_or_else(|poisoned| poisoned.into_inner())
11205                .get(&address.session.instance_id)
11206                .and_then(|binding| binding.materialization_id.clone());
11207            if let Some(materialization_id) = materialization_id {
11208                let mut transfer_failed = false;
11209                {
11210                    let mut materializations = self
11211                        .materializations
11212                        .lock()
11213                        .unwrap_or_else(|poisoned| poisoned.into_inner());
11214                    if let Some(ownership) = materializations.get_mut(&materialization_id) {
11215                        match ownership.owner() {
11216                            MaterializationOwner::Session {
11217                                incarnation_id,
11218                                instance_id,
11219                                generation,
11220                            } if *incarnation_id == self.incarnation_id
11221                                && *instance_id == address.session.instance_id
11222                                && *generation == address.session.generation =>
11223                            {
11224                                transfer_failed = ownership
11225                                    .transfer_to_record(owner_record_id.clone(), unix_time_ms())
11226                                    .is_err();
11227                            }
11228                            MaterializationOwner::Record { record_id }
11229                                if record_id == &owner_record_id => {}
11230                            MaterializationOwner::Record { record_id } => {
11231                                let expected = record_id.clone();
11232                                transfer_failed = ownership
11233                                    .transfer_record_owner(
11234                                        &expected,
11235                                        owner_record_id.clone(),
11236                                        unix_time_ms(),
11237                                    )
11238                                    .is_err();
11239                            }
11240                            _ => transfer_failed = true,
11241                        }
11242                        if transfer_failed {
11243                            let _ = ownership.mark_recovery_required(unix_time_ms());
11244                        }
11245                    } else {
11246                        transfer_failed = true;
11247                    }
11248                }
11249                if transfer_failed {
11250                    if let Some(record) = self
11251                        .session_records
11252                        .lock()
11253                        .unwrap_or_else(|poisoned| poisoned.into_inner())
11254                        .records
11255                        .get_mut(&owner_record_id)
11256                    {
11257                        record.state = ManagedSessionState::Unavailable;
11258                        record.last_error = Some("environment-profile-unavailable".to_owned());
11259                        record.updated_at_unix_ms = unix_time_ms();
11260                        upserts.push(record.clone());
11261                    }
11262                }
11263            }
11264        }
11265        if let Some(new_record_id) = rebind.clone() {
11266            if let Some(binding) = self
11267                .session_bindings
11268                .lock()
11269                .unwrap_or_else(|poisoned| poisoned.into_inner())
11270                .get_mut(&address.session.instance_id)
11271            {
11272                binding.record_id = Some(new_record_id);
11273            }
11274        }
11275        if upserts.is_empty() && removals.is_empty() {
11276            return;
11277        }
11278        if self.persist_state_locked().is_err() {
11279            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
11280            let mut records = self
11281                .session_records
11282                .lock()
11283                .unwrap_or_else(|poisoned| poisoned.into_inner());
11284            records.records = original_records;
11285            *self
11286                .materializations
11287                .lock()
11288                .unwrap_or_else(|poisoned| poisoned.into_inner()) = original_materializations;
11289            if let Some(current) = records.records.get_mut(record_id) {
11290                current.state = ManagedSessionState::Unavailable;
11291                current.last_error = Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned());
11292                current.updated_at_unix_ms = unix_time_ms();
11293                upserts = vec![current.clone()];
11294            } else {
11295                upserts.clear();
11296            }
11297            removals.clear();
11298            drop(records);
11299            if let Some(binding) = self
11300                .session_bindings
11301                .lock()
11302                .unwrap_or_else(|poisoned| poisoned.into_inner())
11303                .get_mut(&address.session.instance_id)
11304            {
11305                binding.record_id = Some(record_id.clone());
11306            }
11307            eprintln!("gate4agent-node: {DURABLE_STATE_COMMIT_FAILED_ERROR}");
11308        }
11309        for record in upserts {
11310            self.publish_record(record);
11311        }
11312        for record_id in removals {
11313            self.publish(NodeEvent::SessionRecordRemoved { record_id });
11314        }
11315    }
11316
11317    fn snapshot(&self) -> NodeSnapshot {
11318        let control = self.handle.snapshot();
11319        let bindings = self
11320            .session_bindings
11321            .lock()
11322            .unwrap_or_else(|poisoned| poisoned.into_inner())
11323            .clone();
11324        let workspace_roots = self
11325            .workspaces
11326            .read()
11327            .unwrap_or_else(|poisoned| poisoned.into_inner())
11328            .clone();
11329        let environment_profiles = self
11330            .environment_profiles
11331            .read()
11332            .unwrap_or_else(|poisoned| poisoned.into_inner());
11333        let mut workspaces = workspace_roots
11334            .iter()
11335            .map(|(workspace_id, canonical_root)| {
11336                (
11337                    workspace_id.clone(),
11338                    WorkspaceSnapshot {
11339                        workspace_id: workspace_id.clone(),
11340                        canonical_root: opaque_windows_path(canonical_root.clone()),
11341                        sessions: Vec::new(),
11342                        worktree_service_mode: Some(
11343                            self.worktree_service_modes
11344                                .get(workspace_id)
11345                                .copied()
11346                                .unwrap_or(WorktreeServiceMode::Manual),
11347                        ),
11348                        managed_worktree_profiles: Some(WorktreeProfileInventory {
11349                            profiles: self
11350                                .managed_worktree_profiles
11351                                .get(workspace_id)
11352                                .into_iter()
11353                                .flat_map(BTreeMap::values)
11354                                .map(|profile| ManagedWorktreeProfileSummary {
11355                                    id: profile.profile_id().clone(),
11356                                    revision: profile.revision().clone(),
11357                                    retention: profile.retention(),
11358                                })
11359                                .collect(),
11360                        }),
11361                    },
11362                )
11363            })
11364            .collect::<BTreeMap<_, _>>();
11365        let mut agent_progress = Vec::new();
11366        for session in &control.sessions {
11367            let Some(binding) = bindings.get(&session.instance_id) else {
11368                continue;
11369            };
11370            if binding.generation != session.generation {
11371                continue;
11372            }
11373            if let Some(workspace) = workspaces.get_mut(&binding.workspace_id) {
11374                let address = SessionAddress {
11375                    workspace_id: binding.workspace_id.clone(),
11376                    session: SessionKey {
11377                        instance_id: session.instance_id,
11378                        generation: session.generation,
11379                    },
11380                };
11381                if let Some(progress) =
11382                    agent_progress_from_provider_snapshot(address, &session.provider)
11383                {
11384                    agent_progress.push(progress);
11385                }
11386                workspace.sessions.push(session.clone());
11387            }
11388        }
11389        NodeSnapshot {
11390            node_id: self.node_id.clone(),
11391            enabled_providers: self.enabled_providers.clone(),
11392            provider_runtime_statuses: {
11393                let updates = self
11394                    .provider_runtime_status_updates
11395                    .read()
11396                    .unwrap_or_else(|poisoned| poisoned.into_inner());
11397                ProviderRuntimeStatuses::new(
11398                    self.provider_runtime_statuses
11399                        .iter()
11400                        .filter(|status| !updates.contains_key(status.provider()))
11401                        .cloned()
11402                        .chain(updates.values().cloned()),
11403                )
11404                .expect("refreshed provider runtime inventory remains bounded and unique")
11405            },
11406            workspaces: workspaces.into_values().collect(),
11407            session_records: self
11408                .session_records
11409                .lock()
11410                .unwrap_or_else(|poisoned| poisoned.into_inner())
11411                .records
11412                .values()
11413                .cloned()
11414                .collect(),
11415            managed_worktrees: self.managed_worktrees
11416                .lock()
11417                .unwrap_or_else(|poisoned| poisoned.into_inner())
11418                .snapshots(),
11419            launch_inventory: Some(LaunchInventory {
11420                spawn_profiles: Some(
11421                    self.spawn_profiles
11422                        .iter()
11423                        .filter_map(|profile| {
11424                            resolved_spawn_profile_summary(profile, &environment_profiles)
11425                        })
11426                        .collect(),
11427                ),
11428                bundles: Some(
11429                    self.bundle_catalog
11430                        .read()
11431                        .unwrap_or_else(|poisoned| poisoned.into_inner())
11432                        .iter()
11433                        .map(NodeBundle::receipt)
11434                        .collect(),
11435                ),
11436                // Opaque station allowlist ids from empty-default catalog /
11437                // optional startup loader. Never secrets. Dig2 probe stays stub.
11438                network_allowlists: {
11439                    let catalog = self
11440                        .network_allowlist_catalog
11441                        .read()
11442                        .unwrap_or_else(|poisoned| poisoned.into_inner());
11443                    if catalog.is_empty() {
11444                        None
11445                    } else {
11446                        Some(catalog.ids().cloned().collect())
11447                    }
11448                },
11449            }),
11450            agent_progress,
11451        }
11452    }
11453
11454    fn publish(&self, event: NodeEvent) -> NodeEventEnvelope {
11455        assert!(
11456            !matches!(
11457                &event,
11458                NodeEvent::TerminalFrame { .. } | NodeEvent::AgentStream { .. }
11459            ),
11460            "terminal frame and agent stream events must use their own dedicated live channel",
11461        );
11462        let mut history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11463        let sequence = history
11464            .last_sequence
11465            .checked_add(1)
11466            .expect("node event sequence exhausted");
11467        if history.events.len() == NODE_EVENT_HISTORY_MAX {
11468            if let Some(evicted) = history.events.pop_front() {
11469                history.replay_floor_sequence = evicted
11470                    .sequence
11471                    .checked_add(1)
11472                    .expect("node replay floor sequence exhausted");
11473                history.removed_record_providers.remove(&evicted.sequence);
11474            }
11475        }
11476        match &event {
11477            NodeEvent::HarnessMcpReadCall { .. } => {
11478                panic!("transient harness MCP calls must not enter durable event history")
11479            }
11480            NodeEvent::SessionRecordUpserted { record } => {
11481                history
11482                    .record_providers
11483                    .insert(record.record_id.clone(), record.provider.clone());
11484            }
11485            NodeEvent::SessionRecordRemoved { record_id } => {
11486                if let Some(provider) = history.record_providers.remove(record_id) {
11487                    history.removed_record_providers.insert(sequence, provider);
11488                }
11489            }
11490            NodeEvent::ManagedWorktreeUpserted { .. }
11491            | NodeEvent::ManagedWorktreeRemoved { .. }
11492            | NodeEvent::Control { .. }
11493            | NodeEvent::ControllerChanged { .. }
11494            | NodeEvent::SessionRecordHistorySummarized { .. }
11495            | NodeEvent::WorkspaceAdded { .. }
11496            | NodeEvent::WorkspaceRemoved { .. }
11497            | NodeEvent::ResyncRequired { .. }
11498            | NodeEvent::AgentStream { .. }
11499            | NodeEvent::TerminalFrame { .. } => {}
11500        }
11501        let envelope = NodeEventEnvelope { sequence, event };
11502        history.events.push_back(envelope.clone());
11503        history.last_sequence = sequence;
11504        let _ = self.event_tx.send(envelope.clone());
11505        drop(history);
11506        envelope
11507    }
11508
11509    /// Publishes a transient (never durable) `NodeEvent` -- today only
11510    /// `NodeEvent::HarnessMcpReadCall` -- onto the dedicated
11511    /// `harness_mcp_proxy_event_tx` broadcast (see that field's own doc for
11512    /// why this must never ride the shared `event_tx` bus). Returns whether
11513    /// the send reached at least one subscriber: `broadcast::Sender::send`
11514    /// fails in exactly one way, no active receiver at that instant, and
11515    /// the caller (`HarnessMcpProxyRegistry::begin_call`) uses a `false`
11516    /// return to fail the pending call immediately rather than leave it to
11517    /// expire silently at `LOCAL_READ_TIMEOUT`/
11518    /// `MAX_HARNESS_MCP_CALL_DEADLINE_MS`.
11519    fn publish_transient(&self, event: NodeEvent) -> bool {
11520        assert!(event.requires_harness_mcp_proxy_capability());
11521        assert!(event.harness_mcp_contract_is_valid_at(unix_time_ms()));
11522        let envelope = NodeEventEnvelope { sequence: 0, event };
11523        match self.harness_mcp_proxy_event_tx.send(Arc::new(vec![envelope])) {
11524            Ok(_) => true,
11525            Err(broadcast::error::SendError(undelivered)) => {
11526                if let Some(NodeEventEnvelope {
11527                    event: NodeEvent::HarnessMcpReadCall { call_id, request, .. },
11528                    ..
11529                }) = undelivered.first() {
11530                    // `request.content_type` only, never a per-tool label --
11531                    // this crate does not decode `request.body` and does not
11532                    // hold `hatchery-harness-api` (Nested Control Plane
11533                    // doctrine, Law 3; see this crate's dependency on
11534                    // `gate4agent-node-protocol`'s own `Forbidden:` line).
11535                    tracing::warn!(
11536                        call_id = call_id.as_str(),
11537                        content_type = ?request.content_type,
11538                        cause = "no-subscribers",
11539                        "harness MCP proxy event could not be published",
11540                    );
11541                }
11542                false
11543            }
11544        }
11545    }
11546
11547    fn publish_terminal_frames(&self) {
11548        let snapshot = self.handle.snapshot();
11549        let bindings = self
11550            .session_bindings
11551            .lock()
11552            .unwrap_or_else(|poisoned| poisoned.into_inner());
11553        let candidates = snapshot
11554            .sessions
11555            .iter()
11556            .filter_map(|session| {
11557                let binding = bindings.get(&session.instance_id)?;
11558                if binding.generation != session.generation {
11559                    return None;
11560                }
11561                let frame = session.terminal_frame.clone()?;
11562                crate::session_signals::feed(
11563                    binding.workspace_id.as_str(),
11564                    session.instance_id.0,
11565                    session.agent_id.as_str(),
11566                    &frame.contents,
11567                );
11568                crate::provider_limits::observe_screen(session.agent_id.as_str(), &frame.contents);
11569                Some((
11570                    SessionAddress {
11571                        workspace_id: binding.workspace_id.clone(),
11572                        session: SessionKey {
11573                            instance_id: session.instance_id,
11574                            generation: session.generation,
11575                        },
11576                    },
11577                    frame,
11578                ))
11579            })
11580            .collect();
11581        drop(bindings);
11582        self.publish_terminal_frame_candidates(candidates);
11583    }
11584
11585    fn publish_terminal_frame_candidates(
11586        &self,
11587        candidates: Vec<(SessionAddress, TerminalFrame)>,
11588    ) {
11589        let mut watermarks = self
11590            .terminal_frame_watermarks
11591            .lock()
11592            .unwrap_or_else(|poisoned| poisoned.into_inner());
11593        let mut events = Vec::with_capacity(candidates.len());
11594        for (address, frame) in candidates {
11595            let is_first_frame = !watermarks.contains_key(&address.session.instance_id);
11596            let advanced = watermarks
11597                .get(&address.session.instance_id)
11598                .map_or(true, |(current_address, sequence)| {
11599                    current_address != &address || frame.sequence > *sequence
11600                });
11601            if !advanced {
11602                continue;
11603            }
11604            if is_first_frame {
11605                // Do NOT sessionize on first PTY output: a session is a
11606                // provider conversation id, not bytes on the wire. Drafts
11607                // leave the provisional map only when HQ attaches
11608                // (Resize / TerminalBytes). The attach-never-arrived timer
11609                // (PROVISIONAL_SPAWN_GRACE_MS) covers deploy lag; HQ's
11610                // disconnect grace starts only on a real client WS close.
11611                // This is the first PTY output this node has observed for
11612                // this session -- a one-time event per spawn, not per-frame
11613                // chatter (the watermark makes sure of that). A session that
11614                // reaches this line with no controller attached anywhere on
11615                // the node is running unobserved: exactly how processes from
11616                // repeated spawn attempts went orphaned for hours without a
11617                // single line about it.
11618                if self.controller_state().is_none() {
11619                    tracing::warn!(
11620                        node_id = %self.node_id,
11621                        workspace_id = %address.workspace_id,
11622                        session = ?address.session,
11623                        terminal_size = ?frame.size,
11624                        "session producing output with no controller attached",
11625                    );
11626                } else {
11627                    tracing::info!(
11628                        node_id = %self.node_id,
11629                        workspace_id = %address.workspace_id,
11630                        session = ?address.session,
11631                        terminal_size = ?frame.size,
11632                        "session produced its first output",
11633                    );
11634                }
11635            }
11636            watermarks.insert(
11637                address.session.instance_id,
11638                (address.clone(), frame.sequence),
11639            );
11640            if let Some((at, reported)) = self
11641                .pty_input_marks
11642                .lock()
11643                .unwrap_or_else(|poisoned| poisoned.into_inner())
11644                .get_mut(&address.session.instance_id)
11645            {
11646                // First frame after an input: PTY write -> output published.
11647                if !*reported && at.elapsed() < Duration::from_secs(5) {
11648                    *reported = true;
11649                    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");
11650                }
11651            }
11652            events.push(NodeEvent::TerminalFrame { address, frame });
11653        }
11654        drop(watermarks);
11655        if events.is_empty() {
11656            return;
11657        }
11658        let mut history = self
11659            .history
11660            .lock()
11661            .unwrap_or_else(|poisoned| poisoned.into_inner());
11662        let mut envelopes = Vec::with_capacity(events.len());
11663        for event in events {
11664            let sequence = history
11665                .last_sequence
11666                .checked_add(1)
11667                .expect("node event sequence exhausted");
11668            history.last_sequence = sequence;
11669            envelopes.push(NodeEventEnvelope { sequence, event });
11670        }
11671        let _ = self.terminal_event_tx.send(Arc::new(envelopes));
11672        drop(history);
11673    }
11674
11675    /// Publishes one `AgentStreamChunkV1` onto the dedicated agent-stream
11676    /// channel, mirroring `publish_terminal_frame_candidates` above: the
11677    /// envelope's `sequence` is drawn from the same `history.last_sequence`
11678    /// counter every other event kind shares, never a counter of its own --
11679    /// `queue_connection_event`/`queue_connection_event_batch` compare
11680    /// `envelope.sequence` against one `discard_events_through` watermark
11681    /// regardless of which broadcast channel it arrived on, so a
11682    /// per-channel counter would desynchronize that comparison.
11683    fn publish_agent_stream_chunk(&self, address: SessionAddress, chunk: AgentStreamChunkV1) {
11684        let mut history = self
11685            .history
11686            .lock()
11687            .unwrap_or_else(|poisoned| poisoned.into_inner());
11688        let sequence = history
11689            .last_sequence
11690            .checked_add(1)
11691            .expect("node event sequence exhausted");
11692        history.last_sequence = sequence;
11693        drop(history);
11694        // Debug, not info/warn -- this fires on every single chunk, but it
11695        // is exactly the counter the next drop-measurement pass needs: what
11696        // the node actually emitted (`source_sequence`, `kind`) versus what
11697        // a harness subscriber's own forwarder later logs having received
11698        // (`hatchery-harness-service::runtime`'s "forwarding agent stream
11699        // event"), so a gap between the two logs names itself instead of
11700        // needing a new counter added ad hoc.
11701        tracing::debug!(
11702            source_sequence = chunk.source_sequence,
11703            kind = agent_stream_chunk_kind_label(&chunk.kind),
11704            envelope_sequence = sequence,
11705            "publishing agent stream chunk",
11706        );
11707        let envelope = NodeEventEnvelope {
11708            sequence,
11709            event: NodeEvent::AgentStream { address, chunk },
11710        };
11711        let _ = self.agent_stream_event_tx.send(Arc::new(vec![envelope]));
11712    }
11713
11714    fn current_sequence(&self) -> u64 {
11715        self.history
11716            .lock()
11717            .unwrap_or_else(|poisoned| poisoned.into_inner())
11718            .last_sequence
11719    }
11720
11721    fn controller_state(&self) -> Option<ControllerState> {
11722        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11723        let lease = controller.as_ref().copied()?;
11724        let remaining = lease.expires_at.saturating_duration_since(Instant::now());
11725        if remaining.is_zero() {
11726            *controller = None;
11727            return None;
11728        }
11729        Some(ControllerState {
11730            connection_id: lease.connection_id,
11731            lease_remaining_ms: remaining.as_millis().min(u64::MAX as u128) as u64,
11732        })
11733    }
11734
11735    fn acquire_controller(&self, connection_id: u64, role: ClientRole, lease_ms: u64) -> Result<ControllerState, NodeFailure> {
11736        if role != ClientRole::Operator {
11737            tracing::warn!(
11738                node_id = %self.node_id,
11739                connection_id,
11740                "controller lease rejected: observer connections cannot acquire control",
11741            );
11742            return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections cannot acquire control"));
11743        }
11744        let lease_ms = if lease_ms == 0 {
11745            DEFAULT_CONTROLLER_LEASE_MS
11746        } else {
11747            lease_ms.clamp(MIN_CONTROLLER_LEASE_MS, MAX_CONTROLLER_LEASE_MS)
11748        };
11749        let now = Instant::now();
11750        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11751        if let Some(active) = controller.as_ref() {
11752            if active.connection_id != connection_id && active.expires_at > now {
11753                tracing::warn!(
11754                    node_id = %self.node_id,
11755                    connection_id,
11756                    holder_connection_id = active.connection_id,
11757                    "controller lease rejected: another operator holds it",
11758                );
11759                return Err(failure(NodeFailureCode::ControllerBusy, "another operator holds the controller lease"));
11760            }
11761        }
11762        // A reconnect (this connection already holds the lease, renewing
11763        // before expiry) happens on a periodic cadence; logging it at INFO
11764        // every renewal would be exactly the heartbeat chatter this crate
11765        // family's level discipline forbids. A genuine attach -- the
11766        // controller identity actually changing -- is a state change and
11767        // gets INFO; a same-connection renewal gets DEBUG.
11768        let is_new_attach = match controller.as_ref() {
11769            Some(active) => active.connection_id != connection_id,
11770            None => true,
11771        };
11772        *controller = Some(ControllerLease {
11773            connection_id,
11774            expires_at: now + Duration::from_millis(lease_ms),
11775        });
11776        drop(controller);
11777        let state = ControllerState { connection_id, lease_remaining_ms: lease_ms };
11778        if is_new_attach {
11779            tracing::info!(
11780                node_id = %self.node_id,
11781                connection_id,
11782                lease_ms,
11783                "controller lease acquired",
11784            );
11785        } else {
11786            tracing::debug!(
11787                node_id = %self.node_id,
11788                connection_id,
11789                lease_ms,
11790                "controller lease renewed",
11791            );
11792        }
11793        self.publish(NodeEvent::ControllerChanged { controller: Some(state.clone()) });
11794        Ok(state)
11795    }
11796
11797    fn release_controller(&self, connection_id: u64) -> bool {
11798        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11799        let released = controller.as_ref().is_some_and(|active| active.connection_id == connection_id);
11800        if released {
11801            *controller = None;
11802        }
11803        drop(controller);
11804        if released {
11805            tracing::info!(
11806                node_id = %self.node_id,
11807                connection_id,
11808                "controller lease released",
11809            );
11810            self.publish(NodeEvent::ControllerChanged { controller: None });
11811        }
11812        released
11813    }
11814
11815    /// Mark a fresh Spawn as provisional and arm the attach-never-arrived
11816    /// reaper (PROVISIONAL_SPAWN_GRACE_MS from spawn). Removed from this map
11817    /// only when HQ attaches (Resize / TerminalBytes) — that is *not* a
11818    /// provider session; it only cancels the orphan timer. Disconnect/reload
11819    /// draft kills are owned by HQ and arm only on a real client WS close.
11820    /// ACP path of launch options: once the agent advertises its config options, write the
11821    /// pending model / effort through session/set_config_option (session/set_model when the
11822    /// agent has no model option). Gaps are logged, never forced.
11823    fn apply_pending_acp_options(&self, address: &SessionAddress, event: &ControlEvent) {
11824        let ControlEventKind::ProviderEvent { event: ProviderEvent::ConfigOptionsUpdated { options }, .. } = &event.event else {
11825            return;
11826        };
11827        let id = address.session.instance_id;
11828        let Some((model, effort)) = self.pending_acp_options.lock().unwrap_or_else(|p| p.into_inner()).remove(&id) else {
11829            return;
11830        };
11831        let advertised: Vec<(String, Option<String>, Vec<String>)> = options
11832            .iter()
11833            .map(|o| (o.id.clone(), o.category.clone(), o.choices.iter().map(|c| c.value_json.clone()).collect()))
11834            .collect();
11835        let (writes, set_model, effort_gap) =
11836            backend_paths::acp_option_writes(&advertised, model.as_deref(), effort.as_deref());
11837        if set_model {
11838            if let Some(model_id) = model.clone() {
11839                if let Err(e) = self.dispatch(ControlCommand::SetSessionModel { instance_id: id, model_id }) {
11840                    tracing::warn!(instance_id = id.0, message = %e.message, "acp set_model failed");
11841                }
11842            }
11843        }
11844        for (option_id, value_json) in writes {
11845            if let Err(e) = self.dispatch(ControlCommand::SetSessionConfigOption { instance_id: id, option_id, value_json }) {
11846                tracing::warn!(instance_id = id.0, message = %e.message, "acp set_config_option failed");
11847            }
11848        }
11849        if effort_gap {
11850            tracing::warn!(instance_id = id.0, effort = ?effort, "acp agent advertises no matching thought_level option; effort not applied");
11851        }
11852        tracing::info!(instance_id = id.0, model = ?model, effort = ?effort, "launch options applied over acp");
11853    }
11854
11855    /// ACP auto-permit: switch to the provider's no-ask mode once modes are advertised, and
11856    /// answer every session/request_permission with approve.
11857    fn apply_acp_auto_permit(&self, address: &SessionAddress, event: &ControlEvent) {
11858        let id = address.session.instance_id;
11859        let Some(provider) = crate::auto_permit::acp_provider(id.0) else { return };
11860        if matches!(&event.event, ControlEventKind::Exited { .. } | ControlEventKind::Failed { .. } | ControlEventKind::Removed) {
11861            crate::auto_permit::forget_acp(id.0);
11862            return;
11863        }
11864        let ControlEventKind::ProviderEvent { sequence, event: provider_event, .. } = &event.event else { return };
11865        if let ProviderEvent::InteractionRequested { request_id, interaction_kind, options, .. } = provider_event {
11866            tracing::debug!(instance_id = id.0, sequence = *sequence, request_id = ?request_id, kind = ?interaction_kind, options = options.len(), "acp interaction requested");
11867        }
11868        match provider_event {
11869            ProviderEvent::ModeChanged { mode_id, available } => {
11870                if let Some(want) = crate::auto_permit::pick_acp_mode(provider, mode_id, available.iter().map(|m| m.id.as_str())) {
11871                    match self.dispatch(ControlCommand::SetSessionMode { instance_id: id, mode_id: want.clone() }) {
11872                        Ok(_) => tracing::info!(instance_id = id.0, provider, mode = %want, "acp auto-permit mode set"),
11873                        Err(e) => tracing::warn!(instance_id = id.0, provider, message = %e.message, "acp auto-permit set_mode failed"),
11874                    }
11875                }
11876            }
11877            ProviderEvent::InteractionRequested { interaction_kind: gate4agent_types::ProviderInteractionKind::Approval, tool_name, options, .. } if !options.is_empty() => {
11878                match self.dispatch(ControlCommand::ResolveInteraction {
11879                    instance_id: id,
11880                    generation: address.session.generation,
11881                    interaction_id: gate4agent_types::ProviderInteractionId(*sequence),
11882                    response: gate4agent_types::ProviderInteractionResponse::ApproveOnce,
11883                }) {
11884                    Ok(_) => tracing::info!(instance_id = id.0, provider, tool = %tool_name, "acp permission auto-approved"),
11885                    Err(e) => tracing::warn!(instance_id = id.0, provider, message = %e.message, "acp auto-approve failed"),
11886                }
11887            }
11888            // NB: ACP emits SessionEnded at the end of every *turn* (prompt stopReason), so it
11889            // must not drop the auto-permit registration; that happens on process exit below.
11890            _ => {}
11891        }
11892    }
11893
11894    fn mark_spawn_provisional(&self, address: SessionAddress) {
11895        let id = address.session.instance_id;
11896        self.provisional_spawns
11897            .lock()
11898            .unwrap_or_else(|poisoned| poisoned.into_inner())
11899            .insert(id, (address, Instant::now()));
11900        tracing::info!(
11901            node_id = %self.node_id,
11902            instance_id = id.0,
11903            grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
11904            "draft spawn; waiting for HQ view",
11905        );
11906    }
11907
11908    fn sessionize_spawn(&self, instance_id: AgentInstanceId) {
11909        let removed = self
11910            .provisional_spawns
11911            .lock()
11912            .unwrap_or_else(|poisoned| poisoned.into_inner())
11913            .remove(&instance_id);
11914        if removed.is_some() {
11915            tracing::info!(
11916                node_id = %self.node_id,
11917                instance_id = instance_id.0,
11918                "draft spawn claimed by HQ view",
11919            );
11920        }
11921    }
11922
11923    fn take_due_provisional_spawns(&self) -> Vec<SessionAddress> {
11924        let grace = Duration::from_millis(PROVISIONAL_SPAWN_GRACE_MS);
11925        let mut map = self
11926            .provisional_spawns
11927            .lock()
11928            .unwrap_or_else(|poisoned| poisoned.into_inner());
11929        let due: Vec<AgentInstanceId> = map
11930            .iter()
11931            .filter(|(_, (_, at))| at.elapsed() >= grace)
11932            .map(|(id, _)| *id)
11933            .collect();
11934        due.into_iter()
11935            .filter_map(|id| map.remove(&id).map(|(addr, _)| addr))
11936            .collect()
11937    }
11938
11939    async fn reap_provisional_spawn(&self, address: SessionAddress) {
11940        let instance_id = address.session.instance_id;
11941        tracing::warn!(
11942            node_id = %self.node_id,
11943            instance_id = instance_id.0,
11944            workspace = %address.workspace_id,
11945            reason = "attach_never_arrived",
11946            grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
11947            "draft spawn never viewed; stopping PTY",
11948        );
11949        let _ = self.dispatch(ControlCommand::Stop {
11950            instance_id,
11951            force: true,
11952        });
11953        let _ = self.remove_session(&address).await;
11954    }
11955
11956    fn require_controller(&self, connection_id: u64, role: ClientRole) -> Result<(), NodeFailure> {
11957        if role != ClientRole::Operator {
11958            return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections are read-only"));
11959        }
11960        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11961        let now = Instant::now();
11962        match controller.as_ref().copied() {
11963            Some(active) if active.connection_id == connection_id && active.expires_at > now => Ok(()),
11964            Some(active) if active.expires_at <= now => {
11965                *controller = None;
11966                Err(failure(NodeFailureCode::ControllerRequired, "controller lease expired"))
11967            }
11968            _ => Err(failure(NodeFailureCode::ControllerRequired, "controller lease is required")),
11969        }
11970    }
11971
11972    fn validate_address(&self, address: &SessionAddress) -> Result<AgentId, NodeFailure> {
11973        if !self
11974            .workspaces
11975            .read()
11976            .unwrap_or_else(|poisoned| poisoned.into_inner())
11977            .contains_key(&address.workspace_id)
11978        {
11979            return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
11980        }
11981        let binding = self
11982            .session_bindings
11983            .lock()
11984            .unwrap_or_else(|poisoned| poisoned.into_inner())
11985            .get(&address.session.instance_id)
11986            .cloned()
11987            .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
11988        if binding.workspace_id != address.workspace_id {
11989            return Err(failure(
11990                NodeFailureCode::SessionWorkspaceMismatch,
11991                "session belongs to another workspace",
11992            ));
11993        }
11994        if binding.generation != address.session.generation {
11995            return Err(failure(
11996                NodeFailureCode::StaleGeneration,
11997                &format!(
11998                    "stale session generation: expected {}, received {}",
11999                    binding.generation.0,
12000                    address.session.generation.0,
12001                ),
12002            ));
12003        }
12004        let snapshot = self.handle.snapshot();
12005        let Some(current) = snapshot
12006            .sessions
12007            .iter()
12008            .find(|item| item.instance_id == address.session.instance_id)
12009        else {
12010            return Err(failure(NodeFailureCode::UnknownSession, "session instance does not exist"));
12011        };
12012        if current.generation != address.session.generation {
12013            return Err(failure(
12014                NodeFailureCode::StaleGeneration,
12015                &format!(
12016                    "stale session generation: expected {}, received {}",
12017                    current.generation.0,
12018                    address.session.generation.0,
12019                ),
12020            ));
12021        }
12022        Ok(current.agent_id.clone())
12023    }
12024
12025    fn dispatch(&self, command: ControlCommand) -> Result<CommandId, NodeFailure> {
12026        self.dispatch_envelope(self.prepare_command(command))
12027    }
12028
12029    fn prepare_command(&self, command: ControlCommand) -> CommandEnvelope {
12030        let id = self.next_command_id.fetch_add(1, Ordering::AcqRel);
12031        CommandEnvelope {
12032            id: CommandId(id),
12033            command,
12034        }
12035    }
12036
12037    fn dispatch_envelope(&self, envelope: CommandEnvelope) -> Result<CommandId, NodeFailure> {
12038        let command_id = envelope.id;
12039        self.handle
12040            .dispatch(envelope)
12041            .map_err(|error| match error {
12042                PortDispatchError::Full => failure(NodeFailureCode::BackendBusy, "backend command ingress is full"),
12043                PortDispatchError::Disconnected => failure(NodeFailureCode::BackendDisconnected, "backend command ingress is disconnected"),
12044            })?;
12045        Ok(command_id)
12046    }
12047
12048    /// Records the kernel's own reason for asynchronously refusing
12049    /// `command_id` -- see `rejected_command_reasons`'s doc for why a
12050    /// spawn-dispatch waiter otherwise has no way to learn it. Bounded by
12051    /// `REJECTED_COMMAND_REASON_MAX_ENTRIES`: expired entries are swept
12052    /// first, and if the map is still at capacity after that, the single
12053    /// oldest surviving entry is evicted -- `CommandId` is monotonically
12054    /// assigned (`next_command_id.fetch_add`), so the `BTreeMap`'s first key
12055    /// is always the oldest.
12056    fn record_rejected_command(
12057        &self,
12058        command_id: CommandId,
12059        message: String,
12060        unsupported_transport: bool,
12061    ) {
12062        let now = Instant::now();
12063        let mut reasons = self.rejected_command_reasons.lock()
12064            .unwrap_or_else(|poisoned| poisoned.into_inner());
12065        reasons.retain(|_, reason| reason.expires_at > now);
12066        if reasons.len() >= REJECTED_COMMAND_REASON_MAX_ENTRIES {
12067            if let Some(&oldest) = reasons.keys().next() {
12068                reasons.remove(&oldest);
12069            }
12070        }
12071        reasons.insert(
12072            command_id,
12073            RejectedCommandReason {
12074                message,
12075                unsupported_transport,
12076                expires_at: now + Duration::from_millis(REJECTED_COMMAND_REASON_TTL_MS),
12077            },
12078        );
12079    }
12080
12081    /// Removes and returns the kernel's reason for refusing `command_id`,
12082    /// if the drive loop has recorded one and it has not expired -- see
12083    /// `rejected_command_reasons`'s doc. Consumed at most once: a spawn
12084    /// wait loop calls this instead of blindly polling out its whole
12085    /// deadline when the command it is waiting on turns out to have been
12086    /// refused rather than merely slow.
12087    fn take_rejected_command_reason(&self, command_id: CommandId) -> Option<RejectedCommandReason> {
12088        let now = Instant::now();
12089        let mut reasons = self.rejected_command_reasons.lock()
12090            .unwrap_or_else(|poisoned| poisoned.into_inner());
12091        match reasons.remove(&command_id) {
12092            Some(reason) if reason.expires_at > now => Some(reason),
12093            _ => None,
12094        }
12095    }
12096
12097    async fn dispatch_bounded(
12098        &self,
12099        command: ControlCommand,
12100        timeout_duration: Duration,
12101    ) -> Result<CommandId, NodeFailure> {
12102        let deadline = Instant::now() + timeout_duration;
12103        loop {
12104            match self.dispatch(command.clone()) {
12105                Ok(command_id) => return Ok(command_id),
12106                Err(error) if error.code == NodeFailureCode::BackendBusy && Instant::now() < deadline => {
12107                    sleep(Duration::from_millis(2)).await;
12108                }
12109                Err(error) => return Err(error),
12110            }
12111        }
12112    }
12113
12114    fn internal_session_snapshot(
12115        &self,
12116        address: &SessionAddress,
12117    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
12118        self.validate_address(address)?;
12119        self.handle
12120            .snapshot()
12121            .sessions
12122            .clone()
12123            .into_iter()
12124            .find(|session| {
12125                session.instance_id == address.session.instance_id
12126                    && session.generation == address.session.generation
12127            })
12128            .ok_or_else(|| {
12129                failure(
12130                    NodeFailureCode::UnknownSession,
12131                    "session snapshot is unavailable",
12132                )
12133            })
12134    }
12135
12136    /// Confirms `address` names a live session spawned over the ACP
12137    /// transport and returns its current snapshot -- the gate every
12138    /// `acp-control-v1` verb shares. `require_session_runtime_policy`
12139    /// cannot answer this by itself: `ProviderRuntimePolicy` carries no
12140    /// transport bit for `Acp`/`Inline` (see that method's own doc), only
12141    /// the session's own recorded `TransportKind` does, and that lives on
12142    /// the live snapshot, not the binding.
12143    fn require_acp_session(
12144        &self,
12145        address: &SessionAddress,
12146    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
12147        let snapshot = self.internal_session_snapshot(address)?;
12148        if snapshot.transport != TransportKind::Acp {
12149            return Err(failure(
12150                NodeFailureCode::UnsupportedTransport,
12151                "this verb requires a session spawned over the ACP transport",
12152            ));
12153        }
12154        Ok(snapshot)
12155    }
12156
12157    async fn dispatch_history_bounded(
12158        &self,
12159        address: &SessionAddress,
12160        command: ControlCommand,
12161        operation: HistoryOperation,
12162    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
12163        self.validate_address(address)?;
12164        let started_after = self.current_sequence();
12165        let command_id = self
12166            .dispatch_bounded(
12167                command,
12168                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12169            )
12170            .await?;
12171        let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
12172        let mut scan_after = started_after;
12173        let mut accepted_after = None;
12174        loop {
12175            let events = self
12176                .history
12177                .lock()
12178                .unwrap_or_else(|poisoned| poisoned.into_inner())
12179                .events
12180                .iter()
12181                .filter(|envelope| envelope.sequence > scan_after)
12182                .cloned()
12183                .collect::<Vec<_>>();
12184            for envelope in events {
12185                scan_after = scan_after.max(envelope.sequence);
12186                let NodeEvent::Control {
12187                    address: event_address,
12188                    event,
12189                } = envelope.event
12190                else {
12191                    continue;
12192                };
12193                if event_address != *address {
12194                    continue;
12195                }
12196                if event.command_id == Some(command_id) {
12197                    match &event.event {
12198                        ControlEventKind::CommandRejected { .. } => {
12199                            return Err(failure(
12200                                NodeFailureCode::InvalidRequest,
12201                                "history request was rejected",
12202                            ));
12203                        }
12204                        ControlEventKind::HistoryRequested {
12205                            operation: requested,
12206                            ..
12207                        } if requested == &operation => {
12208                            accepted_after = Some(envelope.sequence);
12209                            continue;
12210                        }
12211                        _ => {}
12212                    }
12213                }
12214                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12215                    match (&operation, &event.event) {
12216                        (
12217                            HistoryOperation::Discover { .. },
12218                            ControlEventKind::HistoryDiscovered { .. },
12219                        )
12220                        | (
12221                            HistoryOperation::Load { .. },
12222                            ControlEventKind::HistoryLoaded { .. },
12223                        ) => return self.internal_session_snapshot(address),
12224                        (_, ControlEventKind::HistoryFailed { .. }) => {
12225                            return Err(failure(
12226                                NodeFailureCode::BackendOperationFailed,
12227                                "history operation failed",
12228                            ));
12229                        }
12230                        _ => {}
12231                    }
12232                }
12233            }
12234            if Instant::now() >= deadline {
12235                return Err(failure(
12236                    NodeFailureCode::BackendBusy,
12237                    if accepted_after.is_some() {
12238                        "history operation did not settle before the bounded deadline"
12239                    } else {
12240                        "history operation was not accepted before the bounded deadline"
12241                    },
12242                ));
12243            }
12244            sleep(Duration::from_millis(2)).await;
12245        }
12246    }
12247
12248    async fn discover_history(
12249        &self,
12250        address: &SessionAddress,
12251        limit: u16,
12252    ) -> Result<Vec<HistoryCandidateSummary>, NodeFailure> {
12253        let query = HistoryQuery {
12254            working_directory: Some(self.workspace_root(&address.workspace_id)?),
12255            limit,
12256        };
12257        query
12258            .validate()
12259            .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history query"))?;
12260        let snapshot = self
12261            .dispatch_history_bounded(
12262                address,
12263                ControlCommand::DiscoverHistory {
12264                    instance_id: address.session.instance_id,
12265                    query: query.clone(),
12266                },
12267                HistoryOperation::Discover { query },
12268            )
12269            .await?;
12270        if snapshot.history.pending.is_some() || snapshot.history.loaded.is_some() {
12271            return Err(failure(
12272                NodeFailureCode::BackendOperationFailed,
12273                "history discovery settled into an invalid state",
12274            ));
12275        }
12276        Ok(snapshot.history.candidates)
12277    }
12278
12279    async fn load_history(
12280        &self,
12281        address: &SessionAddress,
12282        candidate_id: String,
12283    ) -> Result<HistorySessionRecord, NodeFailure> {
12284        validate_candidate_id(&candidate_id)
12285            .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history candidate"))?;
12286        let snapshot = self
12287            .dispatch_history_bounded(
12288                address,
12289                ControlCommand::LoadHistory {
12290                    instance_id: address.session.instance_id,
12291                    candidate_id: candidate_id.clone(),
12292                },
12293                HistoryOperation::Load {
12294                    candidate_id: candidate_id.clone(),
12295                },
12296            )
12297            .await?;
12298        if snapshot.history.pending.is_some()
12299            || snapshot.history.loaded_candidate_id.as_ref() != Some(&candidate_id)
12300        {
12301            return Err(failure(
12302                NodeFailureCode::BackendOperationFailed,
12303                "history load settled into an invalid state",
12304            ));
12305        }
12306        snapshot.history.loaded.ok_or_else(|| {
12307            failure(
12308                NodeFailureCode::BackendOperationFailed,
12309                "history load produced no bounded session",
12310            )
12311        })
12312    }
12313
12314    /// `allow_clean_detachment`: `false` for the on-demand
12315    /// `NodeRequest::ExportContextPackForSessionRecord` API path (the
12316    /// managed record must stay untouched, still live, for the whole call);
12317    /// `true` only for the reactive auto-export-at-exit path (`§2.1`), which
12318    /// reacts to the very clean-exit event that detaches the record out from
12319    /// under this same call — see `session_record_export_target_matches`.
12320    async fn export_context_pack_for_session_record(
12321        &self,
12322        record_id: &SessionRecordId,
12323        session: &SessionAddress,
12324        allow_clean_detachment: bool,
12325    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12326        // C2 replaces a relayed failure's prose with a canned per-code
12327        // string (`C2NodeFailure`'s own `From`), so this path's dozen
12328        // distinct refusals are indistinguishable to anyone downstream --
12329        // and a refusal here expires a continuation, which keeps no field
12330        // for why. The prose stays on the node, where it was written.
12331        let outcome = self
12332            .export_context_pack_for_session_record_inner(record_id, session, allow_clean_detachment)
12333            .await;
12334        if let Err(failure) = &outcome {
12335            tracing::warn!(
12336                record = %record_id,
12337                code = ?failure.code,
12338                reason = %failure.message,
12339                "session-record context-pack export refused",
12340            );
12341        }
12342        outcome
12343    }
12344
12345    async fn export_context_pack_for_session_record_inner(
12346        &self,
12347        record_id: &SessionRecordId,
12348        session: &SessionAddress,
12349        allow_clean_detachment: bool,
12350    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12351        // Several steps here refuse with the same BackendOperationFailed
12352        // code and no prose of their own, so which step failed is
12353        // otherwise unrecoverable -- and a refusal here expires a
12354        // continuation.
12355        let step = |name: &'static str| move |failure: NodeFailure| {
12356            tracing::warn!(step = name, code = ?failure.code, "context-pack export step refused");
12357            failure
12358        };
12359        let record = self.record(record_id).map_err(step("record-lookup"))?;
12360        // `provider_session` is `Some` only for a provider whose resolved
12361        // runtime policy admits live identity verification (raw PTY plus a
12362        // semantic PTY adapter -- `provider_runtime::policy_from_capability_flags`).
12363        // The PTY-sidecar-observation shape (no fleet provider today, but
12364        // capability-keyed rather than vendor-locked) hardcodes
12365        // `provider_session_identity: false` in `provider_runtime::evaluate`,
12366        // so its records go straight to `Live` with `provider_session: None`
12367        // and stay that way for the record's entire life -- that is this
12368        // shape's permanent resting state, not a race with observation, and
12369        // `discover_history`/`load_history` below do not need an identity to
12370        // do their job for it. `IdentityPending` is the actual race (an
12371        // identity-admitted provider whose observation has not landed yet):
12372        // it keeps refusing here rather than falling back, because that
12373        // provider's own history the fallback would otherwise trust is not
12374        // guaranteed to be exact without the identity it is still waiting on.
12375        let identity = match record.provider_session.clone() {
12376            Some(identity) => {
12377                if identity.key != ProviderSessionKey::SessionId {
12378                    return Err(step("provider-identity-key")(failure(
12379                        NodeFailureCode::BackendOperationFailed,
12380                        "managed session provider identity cannot bind history exactly",
12381                    )));
12382                }
12383                Some(identity)
12384            }
12385            None if record.state == ManagedSessionState::IdentityPending => {
12386                return Err(step("provider-session-identity")(failure(
12387                    NodeFailureCode::BackendOperationFailed,
12388                    "managed session has not yet observed its provider session identity",
12389                )));
12390            }
12391            None => None,
12392        };
12393        self.revalidate_session_record_context_export(
12394            &record,
12395            identity.as_ref(),
12396            session,
12397            allow_clean_detachment,
12398        ).map_err(step("revalidate-before-discovery"))?;
12399        let candidates = self
12400            .discover_history(session, HISTORY_DISCOVERY_LIMIT_MAX)
12401            .await
12402            .map_err(step("discover-history"))?;
12403        // With no verified identity to filter by, the discovered set must
12404        // still resolve to exactly one candidate: the same ambiguity refusal
12405        // an identity filter would otherwise produce is the only safety
12406        // property left available for this shape.
12407        let candidate_id = select_history_candidate(
12408            &candidates,
12409            identity.as_ref().map(|identity| identity.id.as_str()),
12410        ).map_err(step("unique-candidate"))?;
12411        let loaded = self.load_history(session, candidate_id.clone()).await
12412            .map_err(step("load-history"))?;
12413        if let Some(identity) = identity.as_ref() {
12414            if loaded.session_id != identity.id {
12415                return Err(failure(
12416                    NodeFailureCode::SessionRecordConflict,
12417                    "loaded history identity does not match the managed session record",
12418                ));
12419            }
12420        }
12421        self.revalidate_session_record_context_export(
12422            &record,
12423            identity.as_ref(),
12424            session,
12425            allow_clean_detachment,
12426        )?;
12427        self.revalidate_loaded_history(session, &candidate_id, &loaded)
12428            .map_err(step("revalidate-loaded-history"))?;
12429        let pack = self.materialize_context_pack(session).await
12430            .map_err(step("materialize-context-pack"))?;
12431        self.commit_context_pack_for_session_record(
12432            &record,
12433            identity.as_ref(),
12434            session,
12435            &candidate_id,
12436            &loaded,
12437            pack,
12438            allow_clean_detachment,
12439        )
12440    }
12441
12442    async fn export_context_pack(
12443        &self,
12444        address: &SessionAddress,
12445    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12446        let pack = self.materialize_context_pack(address).await?;
12447        self.commit_context_pack(pack)
12448    }
12449
12450    async fn materialize_context_pack(
12451        &self,
12452        address: &SessionAddress,
12453    ) -> Result<NodeContextPack, NodeFailure> {
12454        let snapshot = self.internal_session_snapshot(address)?;
12455        if !context_pack_source_status_is_usable(&snapshot.status) {
12456            return Err(failure(
12457                NodeFailureCode::InvalidRequest,
12458                "context export requires a running or successfully exited source session",
12459            ));
12460        }
12461        if snapshot.history.pending.is_some()
12462            || snapshot.history.loaded_candidate_id.is_none()
12463        {
12464            return Err(failure(
12465                NodeFailureCode::InvalidRequest,
12466                "context export requires settled loaded history",
12467            ));
12468        }
12469        let loaded = snapshot.history.loaded.clone().ok_or_else(|| {
12470            failure(
12471                NodeFailureCode::InvalidRequest,
12472                "context export requires settled loaded history",
12473            )
12474        })?;
12475        let source_provider = snapshot.agent_id.clone();
12476        let loaded_candidate_id = snapshot.history.loaded_candidate_id.clone();
12477        let repository = self
12478            .context_pack_repository(&address.workspace_id)
12479            .await?;
12480        let current = self.internal_session_snapshot(address)?;
12481        if !context_pack_source_status_is_usable(&current.status)
12482            || current.agent_id != source_provider
12483            || current.history.pending.is_some()
12484            || current.history.loaded_candidate_id != loaded_candidate_id
12485            || current.history.loaded.as_ref() != Some(&loaded)
12486        {
12487            return Err(failure(
12488                NodeFailureCode::ContextPackMaterializationFailed,
12489                "context source changed during bounded repository capture",
12490            ));
12491        }
12492        NodeContextPack::export_with_repository(
12493            ContextPackLineageReceipt {
12494                source_node_id: self.node_id.clone(),
12495                source_session: address.clone(),
12496                source_provider,
12497            },
12498            &loaded,
12499            Some(repository),
12500        )
12501        .map_err(|_| {
12502            failure(
12503                NodeFailureCode::ContextPackMaterializationFailed,
12504                "bounded context pack export failed",
12505            )
12506        })
12507    }
12508
12509    /// Write-through to the durable [`ContextPackStore`], if one is
12510    /// configured (`state_path` is `Some`). A node with no durable state
12511    /// path — fixtures, tests, ephemeral runs — has no `ContextPackStore`
12512    /// open at all and this is a no-op, matching `persist_state`'s own
12513    /// "durability optional, degrade gracefully when unconfigured" idiom.
12514    fn commit_context_pack_durably(&self, pack: &NodeContextPack) -> Result<(), NodeFailure> {
12515        let mut store = self
12516            .context_pack_store
12517            .lock()
12518            .unwrap_or_else(|poisoned| poisoned.into_inner());
12519        let Some(store) = store.as_mut() else {
12520            return Ok(());
12521        };
12522        store.commit(pack).map_err(|_| {
12523            failure(
12524                NodeFailureCode::ContextPackMaterializationFailed,
12525                "durable context pack commit failed",
12526            )
12527        })
12528    }
12529
12530    fn commit_context_pack(
12531        &self,
12532        pack: NodeContextPack,
12533    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12534        self.commit_context_pack_durably(&pack)?;
12535        self.context_catalog
12536            .write()
12537            .unwrap_or_else(|poisoned| poisoned.into_inner())
12538            .insert(pack)
12539            .map_err(|_| {
12540                failure(
12541                    NodeFailureCode::ContextPackMaterializationFailed,
12542                    "context pack catalog is full",
12543                )
12544            })
12545    }
12546
12547    fn commit_context_pack_for_session_record(
12548        &self,
12549        expected: &ManagedSessionRecord,
12550        identity: Option<&ProviderSessionIdentity>,
12551        session: &SessionAddress,
12552        candidate_id: &str,
12553        loaded: &HistorySessionRecord,
12554        pack: NodeContextPack,
12555        allow_clean_detachment: bool,
12556    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12557        let current_root = self.workspace_root(&expected.workspace_id)?;
12558        let _transaction = self
12559            .state_transaction
12560            .lock()
12561            .unwrap_or_else(|poisoned| poisoned.into_inner());
12562        let records = self
12563            .session_records
12564            .lock()
12565            .unwrap_or_else(|poisoned| poisoned.into_inner());
12566        let current = records.records.get(&expected.record_id).ok_or_else(|| {
12567            failure(
12568                NodeFailureCode::SessionRecordConflict,
12569                "managed session record disappeared during context export",
12570            )
12571        })?;
12572        if !session_record_export_target_matches(expected, current, session, allow_clean_detachment) {
12573            return Err(failure(
12574                NodeFailureCode::SessionRecordConflict,
12575                "managed session record changed during context export",
12576            ));
12577        }
12578        let bindings = self
12579            .session_bindings
12580            .lock()
12581            .unwrap_or_else(|poisoned| poisoned.into_inner());
12582        let binding = bindings.get(&session.session.instance_id).ok_or_else(|| {
12583            failure(
12584                NodeFailureCode::SessionRecordConflict,
12585                "managed session runtime binding disappeared during context export",
12586            )
12587        })?;
12588        let runtime = self
12589            .handle
12590            .snapshot()
12591            .sessions
12592            .iter()
12593            .find(|snapshot| {
12594                snapshot.instance_id == session.session.instance_id
12595                    && snapshot.generation == session.session.generation
12596            })
12597            .cloned()
12598            .ok_or_else(|| {
12599                failure(
12600                    NodeFailureCode::SessionRecordConflict,
12601                    "managed session runtime disappeared during context export",
12602                )
12603            })?;
12604        let receipt = pack.receipt();
12605        if !session_record_context_export_binding_is_exact(
12606            expected,
12607            current,
12608            identity,
12609            session,
12610            binding,
12611            &runtime.agent_id,
12612            &current_root,
12613            allow_clean_detachment,
12614        ) || !session_record_context_export_source_is_usable(
12615            current,
12616            &runtime.status,
12617            allow_clean_detachment,
12618        ) || runtime.history.pending.is_some()
12619            || runtime.history.loaded_candidate_id.as_deref() != Some(candidate_id)
12620            || runtime.history.loaded.as_ref() != Some(loaded)
12621            || receipt.lineage.source_node_id != self.node_id
12622            || receipt.lineage.source_session != *session
12623            || receipt.lineage.source_provider != current.provider
12624            || receipt.source_message_count != loaded.message_count
12625        {
12626            return Err(failure(
12627                NodeFailureCode::SessionRecordConflict,
12628                "managed session source changed before context catalog commit",
12629            ));
12630        }
12631        self.commit_context_pack_durably(&pack)?;
12632        self.context_catalog
12633            .write()
12634            .unwrap_or_else(|poisoned| poisoned.into_inner())
12635            .insert(pack)
12636            .map_err(|_| {
12637                failure(
12638                    NodeFailureCode::ContextPackMaterializationFailed,
12639                    "context pack catalog is full",
12640                )
12641            })
12642    }
12643
12644    async fn context_pack_repository(
12645        &self,
12646        workspace_id: &WorkspaceId,
12647    ) -> Result<ContextPackRepository, NodeFailure> {
12648        let first = self
12649            .context_pack_repository_observation(workspace_id)
12650            .await?;
12651        let second = self
12652            .context_pack_repository_observation(workspace_id)
12653            .await?;
12654        stable_context_pack_repository(first, second)
12655    }
12656
12657    async fn context_pack_repository_observation(
12658        &self,
12659        workspace_id: &WorkspaceId,
12660    ) -> Result<ContextPackRepositoryObservation, NodeFailure> {
12661        let git = self.inspect_workspace(workspace_id.clone()).await?.git;
12662        let head = if git.is_repository {
12663            let root = self.workspace_root(workspace_id)?;
12664            match resolve_base_commit_with_timeout(
12665                &root,
12666                "HEAD",
12667                GIT_COMMAND_TIMEOUT_MS,
12668            )
12669            .await
12670            {
12671                Ok(commit) => ContextPackRepositoryHead::Commit(commit),
12672                Err(error) if error.kind == GitWorktreeErrorKind::Conflict => {
12673                    ContextPackRepositoryHead::Unborn
12674                }
12675                Err(_) => {
12676                    return Err(failure(
12677                        NodeFailureCode::ContextPackMaterializationFailed,
12678                        "context pack HEAD observation failed",
12679                    ));
12680                }
12681            }
12682        } else {
12683            ContextPackRepositoryHead::NotRepository
12684        };
12685        let mut files = Vec::with_capacity(CONTEXT_PACK_SELECTED_FILES.len());
12686        for selected_path in CONTEXT_PACK_SELECTED_FILES {
12687            let path = RepositoryPath::utf8(selected_path.to_owned()).map_err(|_| {
12688                failure(
12689                    NodeFailureCode::InvalidRepositoryPath,
12690                    "context pack selected file path is invalid",
12691                )
12692            })?;
12693            match self.read_workspace_file(workspace_id.clone(), path).await {
12694                Ok(WorkspaceFileRead {
12695                    content: WorkspaceFileContent::Utf8 { text, byte_len },
12696                    ..
12697                }) => files.push(ContextPackRepositoryFileSource::utf8(
12698                    selected_path,
12699                    text,
12700                    byte_len,
12701                )),
12702                Ok(WorkspaceFileRead {
12703                    content: WorkspaceFileContent::NonUtf8 { byte_len },
12704                    ..
12705                }) => files.push(ContextPackRepositoryFileSource::skipped(
12706                    selected_path,
12707                    ContextPackSelectedFileSkipReason::NonUtf8,
12708                    Some(byte_len),
12709                )),
12710                Ok(WorkspaceFileRead {
12711                    content: WorkspaceFileContent::TooLarge { .. },
12712                    ..
12713                }) => files.push(ContextPackRepositoryFileSource::skipped(
12714                    selected_path,
12715                    ContextPackSelectedFileSkipReason::TooLarge,
12716                    None,
12717                )),
12718                Err(error) => {
12719                    let reason = if matches!(
12720                        error.code,
12721                        NodeFailureCode::InvalidRepositoryPath
12722                            | NodeFailureCode::RepositoryFileNotRegular
12723                            | NodeFailureCode::RepositoryPathUnsafe
12724                    ) {
12725                        ContextPackSelectedFileSkipReason::Unsafe
12726                    } else {
12727                        ContextPackSelectedFileSkipReason::Unavailable
12728                    };
12729                    files.push(ContextPackRepositoryFileSource::skipped(
12730                        selected_path,
12731                        reason,
12732                        None,
12733                    ));
12734                }
12735            }
12736        }
12737        Ok(ContextPackRepositoryObservation { head, git, files })
12738    }
12739
12740    fn forget_context_pack(&self, context_id: &SpawnContextId) -> Result<(), NodeFailure> {
12741        let referenced_by_binding = self
12742            .session_bindings
12743            .lock()
12744            .unwrap_or_else(|poisoned| poisoned.into_inner())
12745            .values()
12746            .any(|binding| {
12747                binding.context.as_ref().map(|receipt| &receipt.id) == Some(context_id)
12748            });
12749        let referenced_by_record = self
12750            .session_records
12751            .lock()
12752            .unwrap_or_else(|poisoned| poisoned.into_inner())
12753            .records
12754            .values()
12755            .any(|record| record.context_id.as_ref() == Some(context_id));
12756        let referenced_by_materialization = self
12757            .materializations
12758            .lock()
12759            .unwrap_or_else(|poisoned| poisoned.into_inner())
12760            .values()
12761            .any(|ownership| {
12762                ownership.context().map(|receipt| &receipt.id) == Some(context_id)
12763            });
12764        if referenced_by_binding
12765            || referenced_by_record
12766            || referenced_by_materialization
12767        {
12768            return Err(failure(
12769                NodeFailureCode::ContextPackBusy,
12770                "context pack is still referenced",
12771            ));
12772        }
12773        let mut catalog = self.context_catalog
12774            .write()
12775            .unwrap_or_else(|poisoned| poisoned.into_inner());
12776        if catalog.get(context_id).is_none() {
12777            return Err(failure(
12778                NodeFailureCode::UnknownContextPack,
12779                "context pack is unavailable",
12780            ));
12781        }
12782        self.spawn_idempotency
12783            .lock()
12784            .unwrap_or_else(|poisoned| poisoned.into_inner())
12785            .entries
12786            .retain(|_, entry| !entry.value.references_context(context_id));
12787        let removed = catalog.remove(context_id);
12788        debug_assert!(removed.is_some(), "validated context pack remains present");
12789        Ok(())
12790    }
12791
12792    async fn dispatch_input_bounded(
12793        &self,
12794        address: &SessionAddress,
12795        action: InputAction,
12796    ) -> Result<(), NodeFailure> {
12797        let started_after = self.current_sequence();
12798        self.pty_input_marks
12799            .lock()
12800            .unwrap_or_else(|poisoned| poisoned.into_inner())
12801            .insert(address.session.instance_id, (Instant::now(), false));
12802        self.pty_input_wake.notify_one();
12803        let command_id = self
12804            .dispatch_bounded(
12805                ControlCommand::SendInput {
12806                    instance_id: address.session.instance_id,
12807                    action,
12808                },
12809                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12810            )
12811            .await?;
12812        // A semantic prompt may spend the provider's full readiness budget before
12813        // the runtime emits InputCompleted. Keep the node request deadline bounded,
12814        // but strictly beyond every readiness policy in this node's catalog.
12815        let deadline = Instant::now() + Duration::from_millis(self.input_settle_timeout_ms);
12816        let mut scan_after = started_after;
12817        let mut accepted_after = None;
12818        loop {
12819            let events = self
12820                .history
12821                .lock()
12822                .unwrap_or_else(|poisoned| poisoned.into_inner())
12823                .events
12824                .iter()
12825                .filter(|envelope| envelope.sequence > scan_after)
12826                .cloned()
12827                .collect::<Vec<_>>();
12828            for envelope in events {
12829                scan_after = scan_after.max(envelope.sequence);
12830                let NodeEvent::Control {
12831                    address: event_address,
12832                    event,
12833                } = envelope.event
12834                else {
12835                    continue;
12836                };
12837                if event_address != *address {
12838                    continue;
12839                }
12840                if event.command_id == Some(command_id) {
12841                    match &event.event {
12842                        ControlEventKind::CommandRejected { message } => {
12843                            return Err(failure(NodeFailureCode::InvalidRequest, message));
12844                        }
12845                        ControlEventKind::InputRequested { .. } => {
12846                            accepted_after = Some(envelope.sequence);
12847                            continue;
12848                        }
12849                        _ => {}
12850                    }
12851                }
12852                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12853                    match &event.event {
12854                        ControlEventKind::InputCompleted { .. } => return Ok(()),
12855                        ControlEventKind::InputFailed { message, .. } => {
12856                            return Err(failure(
12857                                NodeFailureCode::BackendOperationFailed,
12858                                message,
12859                            ));
12860                        }
12861                        _ => {}
12862                    }
12863                }
12864            }
12865            if Instant::now() >= deadline {
12866                return Err(failure(
12867                    NodeFailureCode::BackendBusy,
12868                    if accepted_after.is_some() {
12869                        "input was accepted but did not settle before the bounded deadline"
12870                    } else {
12871                        "input was not accepted before the bounded deadline"
12872                    },
12873                ));
12874            }
12875            sleep(Duration::from_millis(2)).await;
12876        }
12877    }
12878
12879    async fn dispatch_resize_bounded(
12880        &self,
12881        address: &SessionAddress,
12882        size: gate4agent_types::TerminalSize,
12883    ) -> Result<(), NodeFailure> {
12884        let started_after = self.current_sequence();
12885        let command_id = self
12886            .dispatch_bounded(
12887                ControlCommand::Resize {
12888                    instance_id: address.session.instance_id,
12889                    size,
12890                },
12891                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12892            )
12893            .await?;
12894        let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
12895        let mut scan_after = started_after;
12896        let mut accepted_after = None;
12897        loop {
12898            let events = self
12899                .history
12900                .lock()
12901                .unwrap_or_else(|poisoned| poisoned.into_inner())
12902                .events
12903                .iter()
12904                .filter(|envelope| envelope.sequence > scan_after)
12905                .cloned()
12906                .collect::<Vec<_>>();
12907            for envelope in events {
12908                scan_after = scan_after.max(envelope.sequence);
12909                let NodeEvent::Control {
12910                    address: event_address,
12911                    event,
12912                } = envelope.event
12913                else {
12914                    continue;
12915                };
12916                if event_address != *address {
12917                    continue;
12918                }
12919                if event.command_id == Some(command_id) {
12920                    match &event.event {
12921                        ControlEventKind::CommandRejected { message } => {
12922                            return Err(failure(NodeFailureCode::InvalidRequest, message));
12923                        }
12924                        ControlEventKind::ResizeRequested {
12925                            size: requested_size,
12926                            ..
12927                        } if *requested_size == size => {
12928                            accepted_after = Some(envelope.sequence);
12929                            continue;
12930                        }
12931                        _ => {}
12932                    }
12933                }
12934                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12935                    match &event.event {
12936                        ControlEventKind::Resized { size: settled_size }
12937                            if *settled_size == size =>
12938                        {
12939                            return Ok(());
12940                        }
12941                        ControlEventKind::ResizeFailed { message } => {
12942                            return Err(failure(
12943                                NodeFailureCode::BackendOperationFailed,
12944                                message,
12945                            ));
12946                        }
12947                        _ => {}
12948                    }
12949                }
12950            }
12951            if Instant::now() >= deadline {
12952                return Err(failure(
12953                    NodeFailureCode::BackendBusy,
12954                    if accepted_after.is_some() {
12955                        "resize was accepted but did not settle before the bounded deadline"
12956                    } else {
12957                        "resize was not accepted before the bounded deadline"
12958                    },
12959                ));
12960            }
12961            sleep(Duration::from_millis(2)).await;
12962        }
12963    }
12964
12965    async fn spawn_session(
12966        &self,
12967        workspace_id: WorkspaceId,
12968        provider: AgentId,
12969        mode: SessionMode,
12970        terminal_size: gate4agent_types::TerminalSize,
12971        initial_prompt: Option<String>,
12972    ) -> Result<SessionAddress, NodeFailure> {
12973        if self.managed_worktrees.lock()
12974            .unwrap_or_else(|poisoned| poisoned.into_inner())
12975            .lease_for_workspace(&workspace_id)
12976            .is_some()
12977        {
12978            return Err(failure(
12979                NodeFailureCode::ManagedWorktreeOwnershipConflict,
12980                "managed worktree targets may only be spawned through their lease request",
12981            ));
12982        }
12983        self
12984            .spawn_session_with_deadline(
12985                workspace_id,
12986                provider.clone(),
12987                mode,
12988                terminal_size,
12989                initial_prompt,
12990                None,
12991                None,
12992                None,
12993                None,
12994                None,
12995                SpawnRecordPolicy::ProviderIdentityOnly,
12996                None,
12997                &[],
12998                None,
12999                // `NodeRequest::Spawn` (this method's only caller) carries no
13000                // approval-level selection of its own -- see
13001                // `HarnessOperatorRequestV1::SpawnSession`'s own field for
13002                // the one operator-wire path that can choose something other
13003                // than the axis default. Auto-permit may lower it where the agent
13004                // cannot offer its full-auto mode (claude ACP under root).
13005                crate::auto_permit::spawn_approval_level(provider.as_str(), mode).unwrap_or_default(),
13006                None,
13007                None,
13008            )
13009            .await
13010            .map(|(session, _runtime_policy)| session)
13011    }
13012
13013    async fn spawn_session_with_deadline(
13014        &self,
13015        workspace_id: WorkspaceId,
13016        provider: AgentId,
13017        mode: SessionMode,
13018        terminal_size: gate4agent_types::TerminalSize,
13019        initial_prompt: Option<String>,
13020        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
13021        bundle: Option<ResolvedBundleReceipt>,
13022        context: Option<ResolvedContextPackReceipt>,
13023        managed_authority: Option<ManagedWorktreeLeaseId>,
13024        admitted_runtime_policy: Option<ProviderRuntimePolicy>,
13025        record_policy: SpawnRecordPolicy,
13026        deadline: Option<Instant>,
13027        required_capabilities: &[ProviderRuntimeCapability],
13028        harness_mcp: Option<&PreparedHarnessMcpSpawn>,
13029        approval_level: ApprovalLevel,
13030        network_allowlist: Option<crate::protocol::SpawnNetworkAllowlistId>,
13031        // Opaque dig2browser station profile id (ids only). Feature
13032        // `dig2-station-probe`: after probe at resolve, spawn takes an
13033        // exclusive node-local lease; cleared on session end. Never
13034        // ImportSession / cookies on C2. Feature off: ignored (stub echo
13035        // already happened on the receipt path).
13036        browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
13037    ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
13038        let reserved_lease = self.managed_worktrees.lock()
13039            .unwrap_or_else(|poisoned| poisoned.into_inner())
13040            .lease_for_workspace(&workspace_id);
13041        match (reserved_lease.as_ref(), managed_authority.as_ref()) {
13042            (Some(expected), Some(provided)) if expected == provided => {}
13043            (None, None) => {}
13044            _ => {
13045                return Err(failure(
13046                    NodeFailureCode::ManagedWorktreeOwnershipConflict,
13047                    "spawn lacks exact managed worktree lease authority",
13048                ));
13049            }
13050        }
13051        // Refuse concurrent distinct environment profiles (incl. ProviderHome
13052        // bindings) on the same workspace cwd. Parallel path: managed worktree
13053        // spawn so each profile gets its own workspace_id.
13054        self.ensure_workspace_environment_profile_compatible(
13055            &workspace_id,
13056            environment_profile.as_ref(),
13057        )?;
13058        let runtime_requirement = match (mode, initial_prompt.is_some()) {
13059            (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
13060            (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
13061            (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
13062            (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
13063        };
13064        // This session's own transport -- computed once, here, from `mode`,
13065        // and reused both to admit spawn (below) and, further down, to
13066        // compute the final `runtime_policy` this session actually operates
13067        // under (`provider_runtime::policy_for_transport`). Registration
13068        // reuses this exact value rather than recomputing it a second time.
13069        let transport = match mode {
13070            SessionMode::Pty => TransportKind::Pty,
13071            SessionMode::Inline => TransportKind::Pipe,
13072            SessionMode::Acp => TransportKind::Acp,
13073        };
13074        let pipe_transport = self.provider_supports_pipe_transport(&provider);
13075        let acp_transport = self.provider_supports_acp_transport(&provider);
13076        let runtime_policy = if let Some(runtime_policy) = admitted_runtime_policy {
13077            require_policy(runtime_policy, pipe_transport, acp_transport, runtime_requirement).map_err(|_| failure(
13078                NodeFailureCode::UnsupportedSpawnCapability,
13079                "pre-admitted provider runtime no longer satisfies spawn requirements",
13080            ))?;
13081            runtime_policy
13082        } else if let Some(deadline) = deadline {
13083            let remaining = spawn_deadline_remaining(deadline)?;
13084            timeout(
13085                remaining,
13086                self.admit_provider_runtime(&provider, runtime_requirement),
13087            )
13088            .await
13089            .map_err(|_| {
13090                failure(
13091                    NodeFailureCode::SpawnDeadlineExceeded,
13092                    "provider admission exceeded the spawn deadline",
13093                )
13094            })??
13095        } else {
13096            self.admit_provider_runtime(&provider, runtime_requirement).await?
13097        };
13098        let runtime_policy = self.effective_spawn_runtime_policy(runtime_policy);
13099        // The single point where this session's `runtime_policy` becomes
13100        // transport-correct: `admit_provider_runtime` (and the pre-admitted
13101        // branch above) only ever compute a PTY-terminal-verification-shaped
13102        // value, sound outright only for `TransportKind::Pty` (Pipe still
13103        // relies on that same value downstream -- see `policy_for_transport`'s
13104        // own doc for why). `policy_for_transport` is the explicit,
13105        // exhaustive match Item 1 asks for -- this is the ONE place its
13106        // output becomes the session's `runtime_policy`, so every later
13107        // reader (`ControlCommand::Start`, `session_bindings`, and every
13108        // engine gate that reads `state.runtime_policy`) sees the same,
13109        // correctly-computed value.
13110        let runtime_policy =
13111            crate::provider_runtime::policy_for_transport(transport, runtime_policy);
13112        if required_capabilities
13113            .iter()
13114            .any(|capability| !runtime_policy.admits(*capability))
13115        {
13116            return Err(failure(
13117                NodeFailureCode::UnsupportedSpawnCapability,
13118                "provider runtime does not admit a required spawn capability",
13119            ));
13120        }
13121        if let Some(deadline) = deadline {
13122            spawn_deadline_remaining(deadline)?;
13123        }
13124        self.ensure_binding_capacity()?;
13125        let mut working_directory = self.workspace_root(&workspace_id)?;
13126        let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
13127        let session = SessionKey {
13128            instance_id,
13129            generation: SessionGeneration(1),
13130        };
13131        let address = SessionAddress {
13132            workspace_id: workspace_id.clone(),
13133            session,
13134        };
13135        // This is the request-accepted checkpoint: admission (managed
13136        // worktree authority, runtime capability, dispatch deadline) already
13137        // passed and a concrete session identity is minted. It is a distinct
13138        // state from the process actually running (`ControlEventKind::Running`,
13139        // logged in `publish_control`) or the terminal outcome (`Exited`/
13140        // `Failed`, logged there too) -- this line exists so that gap is no
13141        // longer silent.
13142        tracing::info!(
13143            node_id = %self.node_id,
13144            workspace_id = %workspace_id,
13145            provider = %provider,
13146            environment_profile = ?environment_profile.as_ref().map(|receipt| &receipt.profile_id),
13147            session = ?address.session,
13148            "session spawn request accepted",
13149        );
13150        let mut prepared_spawn = SpawnPreparer::prepare(
13151            self,
13152            SpawnPrepareRequest {
13153                instance_id,
13154                address: &address,
13155                provider: &provider,
13156                mode,
13157                environment_profile: environment_profile.as_ref(),
13158                bundle: bundle.as_ref(),
13159                context: context.as_ref(),
13160                managed_authority: managed_authority.as_ref(),
13161                harness_mcp,
13162                approval_level,
13163                network_allowlist: network_allowlist.as_ref(),
13164                browser_profile_id,
13165            },
13166        )?;
13167        let materialization_id = prepared_spawn.materialization_id.take();
13168        let dispatch_timeout = spawn_dispatch_timeout(deadline)?;
13169        let record_id = self.bind_spawn_session_with_materialization(
13170            &address,
13171            provider.clone(),
13172            mode,
13173            runtime_policy,
13174            record_policy,
13175            environment_profile,
13176            bundle,
13177            context,
13178            materialization_id,
13179        )?;
13180        prepared_spawn.retain_session_guards();
13181        // `transport` was already computed above, alongside this session's
13182        // `runtime_policy`; reused here for `Register` rather than derived a
13183        // second time.
13184        let session_provider = provider.clone();
13185        let agent_id = provider;
13186        let register_command_id = match self.dispatch_bounded(
13187            ControlCommand::Register {
13188                instance_id,
13189                agent_id,
13190                transport,
13191            },
13192            dispatch_timeout,
13193        )
13194        .await {
13195            Ok(command_id) => command_id,
13196            Err(error) => {
13197                if let Some(binding) = self.remove_binding(&address) {
13198                    SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
13199                }
13200                if let Some(record_id) = record_id.as_ref() {
13201                    self.discard_record(record_id)?;
13202                }
13203                return Err(spawn_dispatch_error(error, deadline));
13204            }
13205        };
13206        let start_timeout = match spawn_dispatch_timeout(deadline) {
13207            Ok(timeout) => timeout,
13208            Err(error) => {
13209                let recovery = self
13210                    .rollback_spawn(
13211                        &address,
13212                        Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13213                    )
13214                    .await;
13215                return match recovery {
13216                    Ok(()) => {
13217                        if let Some(record_id) = record_id.as_ref() {
13218                            self.discard_record(record_id)?;
13219                        }
13220                        Err(error)
13221                    }
13222                    Err(recovery_error) => {
13223                        if let Some(record_id) = record_id.as_ref() {
13224                            self.mark_record_error(
13225                                record_id,
13226                                "spawn deadline elapsed and registration recovery failed",
13227                            )?;
13228                        }
13229                        Err(recovery_error)
13230                    }
13231                };
13232            }
13233        };
13234        if let Err(start_error) = self.verify_harness_mcp_before_start(harness_mcp) {
13235            if let Err(recovery_error) = self
13236                .rollback_spawn(&address, Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS))
13237                .await
13238            {
13239                return Err(recovery_error);
13240            }
13241            if let Some(record_id) = record_id.as_ref() {
13242                self.discard_record(record_id)?;
13243            }
13244            return Err(start_error);
13245        }
13246        // A session with its own m4a account runs in its own folder inside the workspace.
13247        if let Some(dir) = self.session_workdir(std::path::Path::new(&working_directory), instance_id, &session_provider) {
13248            working_directory = dir.to_string_lossy().into_owned();
13249        }
13250        let start_result = self
13251            .dispatch_bounded(
13252                ControlCommand::Start {
13253                    instance_id,
13254                    runtime_policy,
13255                    request: StartRequest {
13256                        working_directory,
13257                        terminal_size,
13258                        initial_prompt,
13259                        session_options: SPAWN_SESSION_OPTIONS.try_with(|o| o.clone()).ok().flatten(),
13260                        approval_level,
13261                    },
13262                },
13263                start_timeout,
13264            )
13265            .await;
13266        let start_command_id = match start_result {
13267            Ok(command_id) => command_id,
13268            Err(start_error) => {
13269                let start_error = spawn_dispatch_error(start_error, deadline);
13270                let recovery = self
13271                    .rollback_spawn(
13272                        &address,
13273                        Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13274                    )
13275                    .await;
13276                return match recovery {
13277                    Ok(()) => {
13278                        if let Some(record_id) = record_id.as_ref() {
13279                            self.discard_record(record_id)?;
13280                        }
13281                        Err(start_error)
13282                    }
13283                    Err(recovery_error) => {
13284                        let diagnostic = format!(
13285                            "start failed and registration recovery failed: {}",
13286                            recovery_error.message,
13287                        );
13288                        if let Some(record_id) = record_id.as_ref() {
13289                            self.mark_record_error(record_id, &diagnostic)?;
13290                        }
13291                        Err(failure(recovery_error.code, &diagnostic))
13292                    }
13293                };
13294            }
13295        };
13296
13297        let commit_deadline = deadline.unwrap_or_else(|| Instant::now() + dispatch_timeout);
13298        loop {
13299            // `Register`/`Start` only fail synchronously for a port-level
13300            // ingress problem (see `dispatch_bounded`); the kernel refusing
13301            // either command's content -- an unsupported transport, an
13302            // unknown agent -- lands one drive-loop tick later, and no
13303            // session ever appears in `self.handle.snapshot()` for it to
13304            // notice below. Check for that named reason before falling
13305            // through to the blind presence poll, so a rejected spawn
13306            // reports why instead of just timing out.
13307            if let Some(reason) = self.take_rejected_command_reason(register_command_id)
13308                .or_else(|| self.take_rejected_command_reason(start_command_id))
13309            {
13310                self.rollback_spawn(
13311                    &address,
13312                    Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13313                ).await.map_err(|error| {
13314                        failure(
13315                            error.code,
13316                            &format!(
13317                                "spawn was rejected ({}) and registration recovery failed: {}",
13318                                reason.message, error.message
13319                            ),
13320                        )
13321                    })?;
13322                if let Some(record_id) = record_id.as_ref() {
13323                    self.discard_record(record_id)?;
13324                }
13325                let code = if reason.unsupported_transport {
13326                    NodeFailureCode::UnsupportedTransport
13327                } else {
13328                    NodeFailureCode::BackendOperationFailed
13329                };
13330                return Err(failure(code, &reason.message));
13331            }
13332            if let Some(current) = self
13333                .handle
13334                .snapshot()
13335                .sessions
13336                .iter()
13337                .find(|current| current.instance_id == instance_id)
13338            {
13339                if current.generation != session.generation {
13340                    let divergence = failure(
13341                        NodeFailureCode::StaleGeneration,
13342                        "spawn generation diverged",
13343                    );
13344                    return match self
13345                        .rollback_spawn(
13346                            &address,
13347                            Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13348                        )
13349                        .await
13350                    {
13351                        Ok(()) => {
13352                            if let Some(record_id) = record_id.as_ref() {
13353                                self.discard_record(record_id)?;
13354                            }
13355                            Err(divergence)
13356                        }
13357                        Err(recovery_error) => {
13358                            if let Some(record_id) = record_id.as_ref() {
13359                                self.mark_record_error(
13360                                    record_id,
13361                                    "spawn generation diverged and recovery failed",
13362                                )?;
13363                            }
13364                            Err(recovery_error)
13365                        }
13366                    };
13367                }
13368                if !matches!(current.status, gate4agent_types::SessionStatus::Registered) {
13369                    prepared_spawn.retain_launch_guards();
13370                    return Ok((address, runtime_policy));
13371                }
13372            }
13373            if Instant::now() >= commit_deadline {
13374                self.rollback_spawn(
13375                    &address,
13376                    Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13377                ).await.map_err(|error| {
13378                        failure(
13379                            error.code,
13380                            &format!(
13381                                "start did not commit and registration recovery failed: {}",
13382                                error.message
13383                            ),
13384                        )
13385                    })?;
13386                if let Some(record_id) = record_id.as_ref() {
13387                    self.discard_record(record_id)?;
13388                }
13389                return Err(failure(
13390                    if deadline.is_some() {
13391                        NodeFailureCode::SpawnDeadlineExceeded
13392                    } else {
13393                        NodeFailureCode::BackendBusy
13394                    },
13395                    "start did not commit before the bounded deadline; registration was removed",
13396                ));
13397            }
13398            sleep(Duration::from_millis(2)).await;
13399        }
13400    }
13401
13402    fn effective_spawn_runtime_policy(
13403        &self,
13404        runtime_policy: ProviderRuntimePolicy,
13405    ) -> ProviderRuntimePolicy {
13406        #[cfg(feature = "fixture")]
13407        let runtime_policy = if self.fixture_semantic_hook_policy {
13408            ProviderRuntimePolicy::new(true, true, true, true, false, true)
13409                .expect("monitoring Hook fixture policy is internally valid")
13410        } else {
13411            runtime_policy
13412        };
13413        runtime_policy
13414    }
13415
13416    fn verify_harness_mcp_before_start(
13417        &self,
13418        prepared: Option<&PreparedHarnessMcpSpawn>,
13419    ) -> Result<(), NodeFailure> {
13420        prepared
13421            .map(PreparedHarnessMcpSpawn::verify_helper_program)
13422            .transpose()
13423            .map(|_| ())
13424            .map_err(harness_mcp_failure)
13425    }
13426
13427    async fn rollback_spawn(
13428        &self,
13429        address: &SessionAddress,
13430        commit_timeout: Duration,
13431    ) -> Result<(), NodeFailure> {
13432        self.dispatch_bounded(
13433            ControlCommand::Remove {
13434                instance_id: address.session.instance_id,
13435            },
13436            commit_timeout,
13437        )
13438        .await?;
13439        self.wait_until_removed(address, commit_timeout).await?;
13440        if let Some(binding) = self.remove_binding(address) {
13441            SessionLifecycleHook::cleanup_session_materialization(self, address, &binding)?;
13442        }
13443        Ok(())
13444    }
13445
13446    async fn remove_session(&self, address: &SessionAddress) -> Result<(), NodeFailure> {
13447        let commit_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
13448        self.dispatch_bounded(
13449            ControlCommand::Remove {
13450                instance_id: address.session.instance_id,
13451            },
13452            commit_timeout,
13453        )
13454            .await?;
13455        self.wait_until_removed(address, commit_timeout).await?;
13456        let removed = self.remove_binding(address);
13457        if let Some(binding) = removed.as_ref() {
13458            SessionLifecycleHook::cleanup_session_materialization(self, address, binding)?;
13459        }
13460        if let Some(lease_id) = removed.and_then(|binding| binding.managed_worktree_lease_id) {
13461            let snapshot = self.managed_worktrees.lock()
13462                .unwrap_or_else(|poisoned| poisoned.into_inner())
13463                .release_session(
13464                    &lease_id,
13465                    self.incarnation_id,
13466                    address.session.instance_id,
13467                    address.session.generation,
13468                    unix_time_ms(),
13469                );
13470            if self.persist_state().is_err() {
13471                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
13472            }
13473            if let Some(snapshot) = snapshot {
13474                self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
13475            }
13476            let _ = self.cleanup_managed_worktree(&lease_id, false).await;
13477        }
13478        Ok(())
13479    }
13480
13481    /// Polls until `address`'s session leaves the engine's own session map,
13482    /// or the bounded deadline expires.
13483    ///
13484    /// A `Remove` sent while the session is not yet in a terminal status
13485    /// (e.g. still `Stopping`, mid-effect right after a `Stop`) is rejected
13486    /// once by the engine (`SessionStatus::allows_remove`) and
13487    /// then discarded -- nothing re-issues it on its own, so the removal
13488    /// that would satisfy this wait never arrives and the caller was
13489    /// guaranteed to time out even though the session settles microseconds
13490    /// later. So this re-dispatches on every poll tick: a session that
13491    /// settles mid-wait -- the common case right after a `Stop` -- still
13492    /// gets removed inside the same bounded deadline.
13493    ///
13494    /// It re-dispatches only once the status will ACCEPT the command. A
13495    /// rejected re-dispatch is not the harmless no-op this loop first
13496    /// assumed: every rejection publishes a `ControlEventKind::Command
13497    /// Rejected` to every subscriber and writes a WARN line. Ungated, one
13498    /// ordinary teardown produced 241 of them in 1.6 seconds -- the whole
13499    /// `Stopping` window at one known-doomed dispatch per 2ms tick, fanned
13500    /// out to the harness and every attached client. Gated, the same
13501    /// teardown sends exactly one `Remove`, at the first tick the engine
13502    /// can act on it.
13503    ///
13504    /// The status is read from the snapshot this loop already takes, so
13505    /// the gate costs nothing extra. It races -- the status can move
13506    /// between the read and the dispatch -- and that is fine: a rare lost
13507    /// race is one rejection, and the next tick retries.
13508    ///
13509    /// Re-dispatch is separately rate-limited to
13510    /// [`REMOVE_REDISPATCH_INTERVAL_MS`], because the status gate alone
13511    /// leaves a second burst behind it. Once the session settles, every
13512    /// 2ms tick sent another `Remove` while the accepted one was still in
13513    /// flight, and each of those landed on an instance the engine had
13514    /// already dropped -- `agent instance … is not registered`, observed
13515    /// live as a tail of rejections immediately after the `session
13516    /// removed` line. The presence poll stays at 2ms so the wait still
13517    /// exits promptly; only the sending slows down.
13518    ///
13519    /// Rate-limited rather than once-only on purpose: the status gate
13520    /// covers the status precondition, but `Gate4AgentEngine::remove` has
13521    /// others (a pending operation, an in-flight capability probe or
13522    /// history request) that clear on their own. A single shot would turn
13523    /// those into a guaranteed `BackendBusy` timeout; at 10Hz they still
13524    /// get their retries, just twenty of them across the deadline instead
13525    /// of a thousand.
13526    async fn wait_until_removed(
13527        &self,
13528        address: &SessionAddress,
13529        commit_timeout: Duration,
13530    ) -> Result<(), NodeFailure> {
13531        let deadline = Instant::now() + commit_timeout;
13532        let mut next_dispatch = Instant::now();
13533        loop {
13534            let snapshot = self.handle.snapshot();
13535            let status = snapshot
13536                .sessions
13537                .iter()
13538                .find(|session| session.instance_id == address.session.instance_id)
13539                .map(|session| session.status.clone());
13540            let Some(status) = status else {
13541                return Ok(());
13542            };
13543            let now = Instant::now();
13544            if now >= deadline {
13545                return Err(failure(
13546                    NodeFailureCode::BackendBusy,
13547                    "remove did not commit before the bounded deadline; session binding was retained",
13548                ));
13549            }
13550            if status.allows_remove() && now >= next_dispatch {
13551                let _ = self.dispatch(ControlCommand::Remove {
13552                    instance_id: address.session.instance_id,
13553                });
13554                next_dispatch = now + Duration::from_millis(REMOVE_REDISPATCH_INTERVAL_MS);
13555            }
13556            sleep(Duration::from_millis(2)).await;
13557        }
13558    }
13559
13560    fn resync(&self, after_sequence: u64) -> NodeResponse {
13561        let history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
13562        let event_sequence = history.last_sequence;
13563        let oldest_available_sequence = history.replay_floor_sequence;
13564        let events = history
13565            .events
13566            .iter()
13567            .filter(|event| event.sequence > after_sequence)
13568            .cloned()
13569            .collect();
13570        drop(history);
13571        NodeResponse::Resync {
13572            event_sequence,
13573            oldest_available_sequence,
13574            snapshot: self.snapshot(),
13575            events,
13576        }
13577    }
13578}
13579
13580/// Coarse label for one `AgentStreamChunkKindV1`, used only by
13581/// `NodeShared::publish_agent_stream_chunk`'s own debug log -- mirrors
13582/// `hatchery-harness-service`'s `agent_stream_event_kind_label` (same
13583/// "name what crossed this boundary" reasoning, one layer upstream of it).
13584fn agent_stream_chunk_kind_label(kind: &AgentStreamChunkKindV1) -> &'static str {
13585    match kind {
13586        AgentStreamChunkKindV1::Text { .. } => "text",
13587        AgentStreamChunkKindV1::Thinking { .. } => "thinking",
13588        AgentStreamChunkKindV1::InteractionPrompt { .. } => "interaction-prompt",
13589        AgentStreamChunkKindV1::ModeCatalog { .. } => "mode-catalog",
13590        AgentStreamChunkKindV1::ConfigOptions { .. } => "config-options",
13591        AgentStreamChunkKindV1::ModelCatalog { .. } => "model-catalog",
13592        AgentStreamChunkKindV1::Blocked { .. } => "blocked",
13593    }
13594}
13595
13596/// Serves this wire on the local endpoint, and -- when `call_home` names a
13597/// relay -- over an outbound connection to it as well.
13598///
13599/// The two live in one future rather than a fourth arm of `run`'s own
13600/// `select!` because they are one concern: this is where the node's wire
13601/// is served, and the direction a given connection was opened in does not
13602/// change what happens on it. It also means the existing graceful drain
13603/// below covers both without being duplicated.
13604///
13605/// Either side finishing ends the pair, which is correct: the listener
13606/// only returns on shutdown or a bind failure, and `call_home_loop` only
13607/// returns on shutdown -- a failed relay connection is retried inside it,
13608/// never propagated out.
13609async fn accept_connections(
13610    endpoint: &str,
13611    call_home: Option<std::net::SocketAddr>,
13612    identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13613    shared: Arc<NodeShared>,
13614) -> Result<(), NodeServerError> {
13615    let mut connections = JoinSet::new();
13616    let result = match call_home {
13617        None => accept_connections_inner(endpoint, shared, &mut connections).await,
13618        Some(relay) => {
13619            let listener =
13620                accept_connections_inner(endpoint, Arc::clone(&shared), &mut connections);
13621            let dialler = call_home_loop(relay, identity, Arc::clone(&shared));
13622            tokio::pin!(listener);
13623            tokio::pin!(dialler);
13624            tokio::select! {
13625                result = &mut listener => result,
13626                result = &mut dialler => result,
13627            }
13628        }
13629    };
13630    let graceful_deadline = Instant::now() + Duration::from_millis(CONNECTION_SHUTDOWN_GRACE_MS);
13631    while !connections.is_empty() {
13632        let remaining = graceful_deadline.saturating_duration_since(Instant::now());
13633        if remaining.is_zero()
13634            || timeout(remaining, connections.join_next()).await.is_err()
13635        {
13636            break;
13637        }
13638    }
13639    connections.abort_all();
13640    while connections.join_next().await.is_some() {}
13641    result
13642}
13643
13644/// Backoff ladder for a relay that will not answer, capped rather than
13645/// unbounded: a node whose relay is down is not broken and should keep
13646/// trying forever, but it should not spend a core doing it.
13647const CALL_HOME_BACKOFFS_MS: [u64; 5] = [500, 1_000, 2_000, 4_000, 8_000];
13648
13649/// Dials the relay, serves this wire over the connection, and does it
13650/// again when the connection ends.
13651///
13652/// The node is the SERVER on the socket it just opened. That inversion is
13653/// the whole point: `serve_connection` is the same function the local
13654/// listener feeds, and it neither knows nor cares which end placed the
13655/// call. The one thing this direction adds is the preface -- the relay
13656/// accepted a socket and cannot know whose it is, so the node says its
13657/// name before the handshake it would otherwise begin with.
13658///
13659/// Never returns an error. A relay that is down, refusing, or speaking a
13660/// different protocol is a condition to retry, not a reason to take the
13661/// node's local endpoint down with it; the only way out is shutdown.
13662async fn call_home_loop(
13663    relay: std::net::SocketAddr,
13664    identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13665    shared: Arc<NodeShared>,
13666) -> Result<(), NodeServerError> {
13667    let mut failures = 0_usize;
13668    loop {
13669        if shared.shutdown.load(Ordering::Acquire) {
13670            return Ok(());
13671        }
13672        let permit = tokio::select! {
13673            permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13674                permit.map_err(|_| NodeServerError::PreauthClosed)?
13675            }
13676            _ = shared.shutdown_notify.notified() => return Ok(()),
13677        };
13678        match dial_relay_and_serve(relay, identity.as_deref(), Arc::clone(&shared), permit).await {
13679            Ok(()) => {
13680                // A clean end is the relay hanging up, which it does on
13681                // its own restart. Reconnect immediately rather than
13682                // serving the backoff a peer that is plainly there.
13683                failures = 0;
13684                tracing::info!(node_id = %shared.node_id, %relay, "call-home connection closed; redialling");
13685            }
13686            Err(error) => {
13687                failures = failures.saturating_add(1);
13688                tracing::warn!(
13689                    node_id = %shared.node_id,
13690                    %relay,
13691                    cause = %error,
13692                    attempt = failures,
13693                    "call-home connection failed",
13694                );
13695                let backoff = call_home_backoff(failures);
13696                tokio::select! {
13697                    _ = sleep(backoff) => {}
13698                    _ = shared.shutdown_notify.notified() => return Ok(()),
13699                }
13700            }
13701        }
13702    }
13703}
13704
13705/// Idle time before the first keepalive probe on a call-home socket.
13706const CALL_HOME_KEEPALIVE_IDLE: Duration = Duration::from_secs(15);
13707/// Interval between keepalive probes once the link has gone quiet.
13708const CALL_HOME_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(5);
13709
13710fn call_home_keepalive() -> socket2::TcpKeepalive {
13711    let keepalive = socket2::TcpKeepalive::new()
13712        .with_time(CALL_HOME_KEEPALIVE_IDLE)
13713        .with_interval(CALL_HOME_KEEPALIVE_INTERVAL);
13714    #[cfg(not(windows))]
13715    let keepalive = keepalive.with_retries(3);
13716    keepalive
13717}
13718
13719fn enable_call_home_keepalive(stream: &tokio::net::TcpStream) -> std::io::Result<()> {
13720    socket2::SockRef::from(stream).set_tcp_keepalive(&call_home_keepalive())
13721}
13722
13723/// Same keepalive profile for any node->relay socket (restream uplink).
13724pub(crate) fn enable_tcp_keepalive(stream: &std::net::TcpStream) -> std::io::Result<()> {
13725    socket2::SockRef::from(stream).set_tcp_keepalive(&call_home_keepalive())
13726}
13727
13728/// Backoff for the `attempt`-th consecutive failure (1-based), capped.
13729fn call_home_backoff(attempt: usize) -> Duration {
13730    Duration::from_millis(
13731        CALL_HOME_BACKOFFS_MS[attempt.saturating_sub(1).min(CALL_HOME_BACKOFFS_MS.len() - 1)],
13732    )
13733}
13734
13735
13736/// Dial the call-home relay. Tunnel addresses go through the in-process
13737/// WireGuard dialer when the userspace session is up.
13738async fn dial_call_home(relay: std::net::SocketAddr) -> Result<tokio::net::TcpStream, NodeServerError> {
13739    #[cfg(feature = "wireguard")]
13740    {
13741        let std_stream = tokio::task::spawn_blocking(move || {
13742            gate4agent_node_core::kit_wireguard::tcp_connect(relay)
13743        })
13744        .await
13745        .map_err(|e| NodeServerError::CallHomeTransport(std::io::Error::other(e.to_string())))?
13746        .map_err(NodeServerError::CallHomeTransport)?;
13747        std_stream
13748            .set_nonblocking(true)
13749            .map_err(NodeServerError::CallHomeTransport)?;
13750        return tokio::net::TcpStream::from_std(std_stream).map_err(NodeServerError::CallHomeTransport);
13751    }
13752    #[cfg(not(feature = "wireguard"))]
13753    {
13754        tokio::net::TcpStream::connect(relay)
13755            .await
13756            .map_err(NodeServerError::CallHomeTransport)
13757    }
13758}
13759
13760/// One call-home attempt: connect, name this node, then serve.
13761async fn dial_relay_and_serve(
13762    relay: std::net::SocketAddr,
13763    identity: Option<&hatchery_enroll::NodeIdentity>,
13764    shared: Arc<NodeShared>,
13765    preauth_permit: OwnedSemaphorePermit,
13766) -> Result<(), NodeServerError> {
13767    let mut stream = dial_call_home(relay).await?;
13768    // Nagle off for the same reason the rest of this wire wants it off:
13769    // the traffic is small request/response frames, and coalescing them
13770    // adds latency to every one.
13771    let _ = stream.set_nodelay(true);
13772    // A relay that vanishes without a FIN/RST reaching us (tunnel drop,
13773    // relay-side socket kill whose RST was lost) would otherwise leave this
13774    // socket ESTABLISHED forever: the node mostly reads, so nothing ever
13775    // fails and the reconnect loop never runs. Kernel keepalive turns that
13776    // half-open link into a read error within a bounded time.
13777    if let Err(error) = enable_call_home_keepalive(&stream) {
13778        tracing::warn!(cause = %error, "could not enable TCP keepalive on the call-home socket");
13779    }
13780    gate4agent_node_wire::write_call_home_announce(&mut stream, &shared.node_id)
13781        .await
13782        .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13783    tracing::info!(node_id = %shared.node_id, %relay, "announced this node to the relay");
13784    let mut session_token = None;
13785    if let Some(identity) = identity {
13786        // An enrolled relay answers the announce with a signed-call-home challenge; a
13787        // token-mode relay sends its wire hello instead. Peek to tell them apart
13788        // without consuming either.
13789        let mut magic = [0_u8; 4];
13790        let peeked = timeout(Duration::from_secs(10), stream.peek(&mut magic))
13791            .await
13792            .map_err(|_| NodeServerError::Handshake("relay sent nothing after the announce".to_owned()))?
13793            .map_err(NodeServerError::CallHomeTransport)?;
13794        if peeked == 4 && &magic == hatchery_enroll::CHALLENGE_MAGIC {
13795            let mut frame = [0_u8; hatchery_enroll::CHALLENGE_LEN];
13796            timeout(Duration::from_secs(5), tokio::io::AsyncReadExt::read_exact(&mut stream, &mut frame))
13797                .await
13798                .map_err(|_| NodeServerError::Handshake("relay challenge timed out".to_owned()))?
13799                .map_err(NodeServerError::CallHomeTransport)?;
13800            let (answer, token) = identity
13801                .answer_challenge(shared.node_id.as_str(), &frame)
13802                .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13803            tokio::io::AsyncWriteExt::write_all(&mut stream, &answer).await.map_err(NodeServerError::CallHomeTransport)?;
13804            tracing::info!(node_id = %shared.node_id, "answered the relay's signed call-home challenge");
13805            session_token = Some(token);
13806        }
13807    }
13808    serve_connection(stream, shared, session_token, preauth_permit).await
13809}
13810
13811async fn accept_connections_inner(
13812    endpoint: &str,
13813    shared: Arc<NodeShared>,
13814    connections: &mut JoinSet<Result<(), NodeServerError>>,
13815) -> Result<(), NodeServerError> {
13816    let mut listener = OwnerOnlyLocalListener::bind(endpoint).await?;
13817    loop {
13818        while connections.try_join_next().is_some() {}
13819        if shared.shutdown.load(Ordering::Acquire) {
13820            return Ok(());
13821        }
13822        let preauth_permit = tokio::select! {
13823            permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13824                permit.map_err(|_| NodeServerError::PreauthClosed)?
13825            }
13826            _ = shared.shutdown_notify.notified() => return Ok(()),
13827        };
13828        let server = tokio::select! {
13829            result = listener.accept() => result?,
13830            _ = shared.shutdown_notify.notified() => return Ok(()),
13831        };
13832        let connection_shared = Arc::clone(&shared);
13833        connections.spawn(async move {
13834            serve_connection(server, connection_shared, None, preauth_permit).await
13835        });
13836        if shared.shutdown.load(Ordering::Acquire) {
13837            return Ok(());
13838        }
13839    }
13840}
13841
13842/// Serves one connection, whichever end opened it.
13843///
13844/// Generic over the stream rather than typed to `LocalServerStream`
13845/// because a call-home connection arrives on a `TcpStream` the node itself
13846/// dialled. Nothing in the body ever depended on the concrete type -- it
13847/// reads and writes framed JSON and then `tokio::io::split`s -- so this is
13848/// the whole of what welding "who dials" to "who serves" was costing.
13849async fn serve_connection<S>(
13850    mut pipe: S,
13851    shared: Arc<NodeShared>,
13852    session_token: Option<String>,
13853    preauth_permit: OwnedSemaphorePermit,
13854) -> Result<(), NodeServerError>
13855where
13856    S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
13857{
13858    // A signed call-home connection authenticates with its own per-connection token.
13859    let auth_token: &[u8] = session_token.as_deref().map_or(shared.access_token.as_bytes(), str::as_bytes);
13860    let hello_frame = timeout(
13861        Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13862        read_json_frame_limited_body_timeout(
13863            &mut pipe,
13864            MAX_NODE_HELLO_FRAME_BYTES,
13865            Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13866        ),
13867    )
13868    .await
13869    .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13870    let ClientFrame::Hello(hello) = hello_frame else {
13871        return Err(NodeServerError::Handshake("first frame must be hello".to_owned()));
13872    };
13873    if hello.build_stamp != BUILD_STAMP {
13874        return Err(NodeServerError::Handshake(format!(
13875            "build stamp mismatch: local={BUILD_STAMP} remote={}",
13876            hello.build_stamp,
13877        )));
13878    }
13879    let compatibility = match hello.compatibility.as_ref() {
13880        Some(offer) => {
13881            let mut selected = node_compatibility_support(&shared)?
13882                .negotiate(offer)
13883                .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13884            project_negotiated_provider_ids(&mut selected);
13885            Some(selected)
13886        }
13887        None => None,
13888    };
13889    let server_nonce = random_nonce().map_err(NodeServerError::Authentication)?;
13890    let server_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13891        (Some(offer), Some(selected)) => negotiated_auth_proof(
13892            auth_token,
13893            AuthDirection::Server,
13894            hello.role,
13895            &hello.client_nonce,
13896            &server_nonce,
13897            offer,
13898            selected,
13899        ),
13900        (None, None) => auth_proof(
13901            auth_token,
13902            AuthDirection::Server,
13903            hello.role,
13904            &hello.client_nonce,
13905            &server_nonce,
13906        ),
13907        _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13908    }
13909    .map_err(NodeServerError::Authentication)?;
13910    write_json_frame_limited(
13911        &mut pipe,
13912        &ServerFrame::Challenge(ServerChallenge {
13913            build_stamp: BUILD_STAMP.to_owned(),
13914            server_nonce,
13915            server_proof,
13916            compatibility: compatibility.clone(),
13917        }),
13918        MAX_NODE_HELLO_FRAME_BYTES,
13919    )
13920    .await?;
13921    let authentication = timeout(
13922        Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13923        read_json_frame_limited_body_timeout(
13924            &mut pipe,
13925            MAX_NODE_HELLO_FRAME_BYTES,
13926            Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13927        ),
13928    )
13929    .await
13930    .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13931    let ClientFrame::Authenticate(authentication) = authentication else {
13932        return Err(NodeServerError::Handshake("second frame must authenticate the challenge".to_owned()));
13933    };
13934    let expected_client_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13935        (Some(offer), Some(selected)) => negotiated_auth_proof(
13936            auth_token,
13937            AuthDirection::Client,
13938            hello.role,
13939            &hello.client_nonce,
13940            &server_nonce,
13941            offer,
13942            selected,
13943        ),
13944        (None, None) => auth_proof(
13945            auth_token,
13946            AuthDirection::Client,
13947            hello.role,
13948            &hello.client_nonce,
13949            &server_nonce,
13950        ),
13951        _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13952    }
13953    .map_err(NodeServerError::Authentication)?;
13954    if !proofs_match(&authentication.client_proof, &expected_client_proof) {
13955        return Err(NodeServerError::Handshake("access denied".to_owned()));
13956    }
13957    let selected_capabilities = compatibility
13958        .as_ref()
13959        .map(|selected| selected.capabilities.clone())
13960        .unwrap_or_default();
13961    let include_provider_runtime_status = selected_capabilities.iter().any(|capability| {
13962        capability.as_str() == NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY
13963    });
13964    let include_open_provider_ids = selected_capabilities.iter().any(|capability| {
13965        capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
13966    });
13967    let include_terminal_frame_events = selected_capabilities.iter().any(|capability| {
13968        capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
13969    });
13970    let include_agent_stream_events = selected_capabilities.iter().any(|capability| {
13971        capability.as_str() == NODE_AGENT_STREAM_EVENTS_CAPABILITY
13972    });
13973    let include_spawn_profiles = selected_capabilities.iter().any(|capability| {
13974        capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
13975    });
13976    let include_managed_worktrees = selected_capabilities.iter().any(|capability| {
13977        capability.as_str() == NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY
13978    }) && selected_capabilities.iter().any(|capability| {
13979        capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
13980    });
13981    let include_child_environment_profiles = selected_capabilities.iter().any(|capability| {
13982        capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
13983    });
13984    let include_session_bundles = selected_capabilities.iter().any(|capability| {
13985        capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
13986    });
13987    let include_history_context_packs = selected_capabilities.iter().any(|capability| {
13988        capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
13989    });
13990    let include_agent_progress = selected_capabilities.iter().any(|capability| {
13991        capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
13992    });
13993    let include_session_task_correlation = selected_capabilities.iter().any(|capability| {
13994        capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
13995    });
13996    let include_harness_mcp_proxy = selected_capabilities.iter().any(|capability| {
13997        capability.as_str() == NODE_HARNESS_MCP_READ_PROXY_CAPABILITY
13998    });
13999    let authenticated_permit = Arc::clone(&shared.authenticated_slots)
14000        .try_acquire_owned()
14001        .map_err(|_| NodeServerError::AuthenticatedConnectionLimit)?;
14002    let connection_id = shared.next_connection_id.fetch_add(1, Ordering::AcqRel);
14003    let _release_guard = ControllerReleaseGuard {
14004        shared: Arc::clone(&shared),
14005        connection_id,
14006    };
14007    drop(preauth_permit);
14008    let _authenticated_permit = authenticated_permit;
14009    let mut event_rx = shared.event_tx.subscribe();
14010    let mut terminal_event_rx = terminal_event_subscription(
14011        &shared,
14012        include_terminal_frame_events,
14013    );
14014    let mut agent_stream_event_rx = agent_stream_event_subscription(
14015        &shared,
14016        include_agent_stream_events,
14017    );
14018    let mut harness_mcp_proxy_event_rx = harness_mcp_proxy_event_subscription(
14019        &shared,
14020        include_harness_mcp_proxy,
14021    );
14022    write_json_frame(
14023        &mut pipe,
14024        &ServerFrame::Hello(NodeHello {
14025            build_stamp: BUILD_STAMP.to_owned(),
14026            incarnation_id: shared.incarnation_id,
14027            connection_id,
14028            role: hello.role,
14029            event_sequence: shared.current_sequence(),
14030            controller: shared.controller_state(),
14031            snapshot: snapshot_for_wire(
14032                &shared,
14033                include_provider_runtime_status,
14034                include_open_provider_ids,
14035                include_spawn_profiles,
14036                include_managed_worktrees,
14037                include_child_environment_profiles,
14038                include_session_bundles,
14039                include_history_context_packs,
14040                include_agent_progress,
14041                include_session_task_correlation,
14042            ),
14043            compatibility,
14044        }),
14045    )
14046    .await?;
14047    let (mut reader, mut writer) = tokio::io::split(pipe);
14048    let (frame_tx, mut frame_rx) = mpsc::channel(16);
14049    let reader_task = tokio::spawn(async move {
14050        loop {
14051            let frame = read_json_frame_limited_body_timeout::<_, ClientFrame>(
14052                &mut reader,
14053                MAX_NODE_CLIENT_FRAME_BYTES,
14054                Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
14055            )
14056            .await;
14057            let terminal = frame.is_err();
14058            if frame_tx.send(frame).await.is_err() || terminal {
14059                break;
14060            }
14061        }
14062    });
14063    let _reader_abort = AbortTaskOnDrop(reader_task.abort_handle());
14064    let mut pending_events = Vec::new();
14065    let mut resync_required = false;
14066    let mut discard_events_through = 0_u64;
14067    loop {
14068        // One tick of the serve loop, whether or not it had anything to do.
14069        // Against `connection_events_sent` this separates "busy" from
14070        // "spinning": a loop turning over thousands of times a second while
14071        // sending nothing is not serving, it is burning a core.
14072        shared.connection_loop_iterations.fetch_add(1, Ordering::Relaxed);
14073        let durable_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
14074            .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
14075        for _ in 0..durable_drain_budget {
14076            match event_rx.try_recv() {
14077                Ok(event) => queue_connection_event(
14078                    &mut pending_events,
14079                    event,
14080                    discard_events_through,
14081                ),
14082                Err(broadcast::error::TryRecvError::Empty) => break,
14083                Err(broadcast::error::TryRecvError::Lagged(_)) => {
14084                    resync_required = true;
14085                    break;
14086                }
14087                Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14088            }
14089        }
14090        if let Some(receiver) = terminal_event_rx.as_mut() {
14091            match receiver.try_recv() {
14092                Ok(events) => queue_connection_event_batch(
14093                    &mut pending_events,
14094                    &events,
14095                    discard_events_through,
14096                ),
14097                Err(broadcast::error::TryRecvError::Empty) => {}
14098                Err(broadcast::error::TryRecvError::Lagged(_)) => {
14099                    resync_required = true;
14100                }
14101                Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14102            }
14103        }
14104        if let Some(receiver) = agent_stream_event_rx.as_mut() {
14105            // Loops up to the same remaining-room budget the durable
14106            // `event_rx` drain above uses, rather than one `try_recv` per
14107            // tick: each successful receive here is itself only ONE
14108            // `NodeEventEnvelope` batch (`publish_agent_stream_chunk` always
14109            // sends `vec![envelope]`, never more), so a single-shot drain
14110            // caps this connection's agent-stream throughput at one chunk
14111            // per outer-loop tick regardless of how many are already queued
14112            // -- exactly the kind of self-inflicted stall this crate's own
14113            // durable-event budget exists to avoid.
14114            let agent_stream_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
14115                .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
14116            for _ in 0..agent_stream_drain_budget {
14117                match receiver.try_recv() {
14118                    Ok(events) => queue_connection_event_batch(
14119                        &mut pending_events,
14120                        &events,
14121                        discard_events_through,
14122                    ),
14123                    Err(broadcast::error::TryRecvError::Empty) => break,
14124                    Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
14125                        tracing::warn!(
14126                            connection_id,
14127                            dropped,
14128                            "agent stream event subscription lagged; forcing a resync",
14129                        );
14130                        resync_required = true;
14131                        break;
14132                    }
14133                    Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14134                }
14135            }
14136        }
14137        if let Some(receiver) = harness_mcp_proxy_event_rx.as_mut() {
14138            // Mirrors the agent-stream drain immediately above: each
14139            // successful receive is one single-envelope batch
14140            // (`publish_transient` always sends `vec![envelope]`), so this
14141            // loops up to the same remaining-room budget rather than
14142            // stopping after one `try_recv`.
14143            let harness_mcp_proxy_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
14144                .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
14145            for _ in 0..harness_mcp_proxy_drain_budget {
14146                match receiver.try_recv() {
14147                    Ok(events) => queue_connection_event_batch(
14148                        &mut pending_events,
14149                        &events,
14150                        discard_events_through,
14151                    ),
14152                    Err(broadcast::error::TryRecvError::Empty) => break,
14153                    Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
14154                        tracing::warn!(
14155                            connection_id,
14156                            dropped,
14157                            "harness MCP proxy event subscription lagged; forcing a resync",
14158                        );
14159                        resync_required = true;
14160                        break;
14161                    }
14162                    Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14163                }
14164            }
14165        }
14166        if resync_required {
14167            let event = resync_required_event(&shared);
14168            apply_connection_resync_watermark(
14169                &mut pending_events,
14170                &mut discard_events_through,
14171                event.sequence,
14172            );
14173            write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
14174            resync_required = false;
14175            continue;
14176        }
14177        if !pending_events.is_empty() {
14178            pending_events.sort_unstable_by_key(|event| event.sequence);
14179            let burst_len = connection_event_burst_len(pending_events.len());
14180            shared
14181                .connection_events_sent
14182                .fetch_add(burst_len as u64, Ordering::Relaxed);
14183            for event in pending_events.drain(..burst_len) {
14184                let event = if include_managed_worktrees {
14185                    Some(event)
14186                } else {
14187                    project_event_without_managed_worktrees(event)
14188                };
14189                let event = event.and_then(|event| if include_open_provider_ids {
14190                    Some(event)
14191                } else {
14192                    project_event_legacy_provider_ids(&shared, event)
14193                });
14194                let event = event.map(|event| if include_child_environment_profiles {
14195                    event
14196                } else {
14197                    project_event_without_child_environment_profile(event)
14198                });
14199                let event = event.map(|event| if include_session_bundles {
14200                    event
14201                } else {
14202                    project_event_without_session_bundle(event)
14203                });
14204                let event = event.map(|event| {
14205                    project_event_history_for_wire(event, include_history_context_packs)
14206                });
14207                let event = event.map(|event| if include_history_context_packs {
14208                    event
14209                } else {
14210                    project_event_without_context_pack(event)
14211                });
14212                let event = event.map(|event| if include_session_task_correlation {
14213                    event
14214                } else {
14215                    project_event_without_session_task_binding(event)
14216                });
14217                let exact_harness_controller = hello.role == ClientRole::Operator
14218                    && shared.controller_state().is_some_and(|controller| {
14219                        controller.connection_id == connection_id
14220                    });
14221                let event = event.and_then(|event| {
14222                    if include_harness_mcp_proxy && exact_harness_controller {
14223                        Some(event)
14224                    } else {
14225                        project_event_without_harness_mcp_proxy(event)
14226                    }
14227                });
14228                if let Some(event) = event {
14229                    write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
14230                }
14231            }
14232        }
14233        tokio::select! {
14234            biased;
14235            frame = frame_rx.recv() => {
14236                let frame = match frame {
14237                    Some(frame) => frame,
14238                    None => break,
14239                };
14240                let frame = match frame {
14241                    Ok(frame) => frame,
14242                    Err(FrameError::Io(error)) if matches!(error.kind(), io::ErrorKind::BrokenPipe | io::ErrorKind::UnexpectedEof) => break,
14243                    Err(error) => return Err(error.into()),
14244                };
14245                let ClientFrame::Request(request) = frame else {
14246                    return Err(NodeServerError::Handshake("hello may only be sent once".to_owned()));
14247                };
14248                #[cfg(feature = "fixture")]
14249                let probe_spawn_managed_worktree_v2 = matches!(
14250                    &request.request,
14251                    NodeRequest::SpawnManagedWorktreeV2 { .. }
14252                );
14253                let requires_open_provider_ids =
14254                    request_requires_open_provider_ids(&shared, &request.request);
14255                let requires_child_environment_profile =
14256                    request_requires_child_environment_profile(&shared, &request.request);
14257                let requires_session_bundle =
14258                    request_requires_session_bundle(&shared, &request.request);
14259                let requires_history_context_pack =
14260                    request_requires_history_context_pack(&shared, &request.request);
14261                let mut reply = if requires_open_provider_ids && !include_open_provider_ids {
14262                    ResponseEnvelope {
14263                        request_id: request.request_id,
14264                        result: Err(failure(
14265                            NodeFailureCode::UnsupportedCapability,
14266                            "open provider IDs were not negotiated",
14267                        )),
14268                    }
14269                } else if requires_child_environment_profile
14270                    && !include_child_environment_profiles
14271                {
14272                    ResponseEnvelope {
14273                        request_id: request.request_id,
14274                        result: Err(failure(
14275                            NodeFailureCode::UnsupportedCapability,
14276                            "child environment profiles were not negotiated",
14277                        )),
14278                    }
14279                } else if requires_session_bundle && !include_session_bundles {
14280                    ResponseEnvelope {
14281                        request_id: request.request_id,
14282                        result: Err(failure(
14283                            NodeFailureCode::UnsupportedCapability,
14284                            "session bundles were not negotiated",
14285                        )),
14286                    }
14287                } else if requires_history_context_pack && !include_history_context_packs {
14288                    ResponseEnvelope {
14289                        request_id: request.request_id,
14290                        result: Err(failure(
14291                            NodeFailureCode::UnsupportedCapability,
14292                            "history context packs were not negotiated",
14293                        )),
14294                    }
14295                } else if request_uses_unnegotiated_capability(
14296                    &request.request,
14297                    &selected_capabilities,
14298                )
14299                {
14300                    ResponseEnvelope {
14301                        request_id: request.request_id,
14302                        result: Err(failure(
14303                            NodeFailureCode::UnsupportedCapability,
14304                            "request capability was not negotiated",
14305                        )),
14306                    }
14307                } else {
14308                    process_request(&shared, connection_id, hello.role, request).await
14309                };
14310                #[cfg(feature = "fixture")]
14311                if probe_spawn_managed_worktree_v2 {
14312                    shared
14313                        .fixture_spawn_managed_worktree_v2_failure_probe
14314                        .record(reply.result.as_ref().err().map(|failure| failure.code));
14315                }
14316                if !include_provider_runtime_status {
14317                    clear_response_provider_runtime_status(&mut reply);
14318                }
14319                if !include_managed_worktrees {
14320                    project_response_without_managed_worktrees(&mut reply);
14321                }
14322                if !include_open_provider_ids {
14323                    project_response_legacy_provider_ids(&shared, &mut reply);
14324                }
14325                if !include_child_environment_profiles {
14326                    project_response_without_child_environment_profile(&mut reply);
14327                }
14328                if !include_session_bundles {
14329                    project_response_without_session_bundle(&mut reply);
14330                }
14331                project_response_history_for_wire(
14332                    &mut reply,
14333                    include_history_context_packs,
14334                );
14335                if !include_history_context_packs {
14336                    project_response_without_context_pack(&mut reply);
14337                }
14338                if !include_agent_progress {
14339                    project_response_without_agent_progress(&mut reply);
14340                }
14341                if !include_session_task_correlation {
14342                    project_response_without_session_task_binding(&mut reply);
14343                }
14344                write_json_frame(&mut writer, &ServerFrame::Reply(reply)).await?;
14345            }
14346            _ = tokio::task::yield_now(), if !pending_events.is_empty() => {}
14347            event = event_rx.recv() => {
14348                match event {
14349                    Ok(event) => queue_connection_event(
14350                        &mut pending_events,
14351                        event,
14352                        discard_events_through,
14353                    ),
14354                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14355                    Err(broadcast::error::RecvError::Closed) => break,
14356                }
14357            }
14358            events = receive_terminal_event_batch(&mut terminal_event_rx) => {
14359                match events {
14360                    Ok(events) => queue_connection_event_batch(
14361                        &mut pending_events,
14362                        &events,
14363                        discard_events_through,
14364                    ),
14365                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14366                    Err(broadcast::error::RecvError::Closed) => break,
14367                }
14368            }
14369            events = receive_terminal_event_batch(&mut agent_stream_event_rx) => {
14370                match events {
14371                    Ok(events) => queue_connection_event_batch(
14372                        &mut pending_events,
14373                        &events,
14374                        discard_events_through,
14375                    ),
14376                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14377                    Err(broadcast::error::RecvError::Closed) => break,
14378                }
14379            }
14380            events = receive_terminal_event_batch(&mut harness_mcp_proxy_event_rx) => {
14381                match events {
14382                    Ok(events) => queue_connection_event_batch(
14383                        &mut pending_events,
14384                        &events,
14385                        discard_events_through,
14386                    ),
14387                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14388                    Err(broadcast::error::RecvError::Closed) => break,
14389                }
14390            }
14391            _ = shared.shutdown_notify.notified() => break,
14392        }
14393    }
14394    Ok(())
14395}
14396
14397async fn receive_terminal_event_batch(
14398    receiver: &mut Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>>,
14399) -> Result<Arc<Vec<NodeEventEnvelope>>, broadcast::error::RecvError> {
14400    match receiver {
14401        Some(receiver) => receiver.recv().await,
14402        None => std::future::pending().await,
14403    }
14404}
14405
14406fn terminal_event_subscription(
14407    shared: &NodeShared,
14408    enabled: bool,
14409) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14410    enabled.then(|| shared.terminal_event_tx.subscribe())
14411}
14412
14413/// Mirrors `terminal_event_subscription` exactly, over the agent stream's
14414/// own channel -- see `NodeShared::agent_stream_event_tx`'s own doc for why
14415/// this needs a dedicated channel rather than a flag on the terminal one.
14416fn agent_stream_event_subscription(
14417    shared: &NodeShared,
14418    enabled: bool,
14419) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14420    enabled.then(|| shared.agent_stream_event_tx.subscribe())
14421}
14422
14423/// Mirrors `agent_stream_event_subscription` exactly, over the harness MCP
14424/// proxy's own channel -- see `NodeShared::harness_mcp_proxy_event_tx`'s
14425/// own doc for why this needs a dedicated channel rather than a ride on
14426/// the durable `event_tx`.
14427fn harness_mcp_proxy_event_subscription(
14428    shared: &NodeShared,
14429    enabled: bool,
14430) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14431    enabled.then(|| shared.harness_mcp_proxy_event_tx.subscribe())
14432}
14433
14434fn queue_connection_event(
14435    pending: &mut Vec<NodeEventEnvelope>,
14436    event: NodeEventEnvelope,
14437    discard_through: u64,
14438) {
14439    if event.sequence <= discard_through
14440        && !event.event.requires_harness_mcp_proxy_capability()
14441    {
14442        return;
14443    }
14444    if let NodeEvent::TerminalFrame { address, .. } = &event.event {
14445        if let Some(index) = pending.iter().position(|current| {
14446            matches!(
14447                &current.event,
14448                NodeEvent::TerminalFrame {
14449                    address: current_address,
14450                    ..
14451                } if current_address == address
14452            )
14453        }) {
14454            pending.remove(index);
14455        }
14456    }
14457    pending.push(event);
14458}
14459
14460fn project_event_without_harness_mcp_proxy(
14461    envelope: NodeEventEnvelope,
14462) -> Option<NodeEventEnvelope> {
14463    (!envelope.event.requires_harness_mcp_proxy_capability()).then_some(envelope)
14464}
14465
14466fn queue_connection_event_batch(
14467    pending: &mut Vec<NodeEventEnvelope>,
14468    events: &[NodeEventEnvelope],
14469    discard_through: u64,
14470) {
14471    for event in events {
14472        queue_connection_event(pending, event.clone(), discard_through);
14473    }
14474}
14475
14476fn apply_connection_resync_watermark(
14477    pending: &mut Vec<NodeEventEnvelope>,
14478    discard_through: &mut u64,
14479    marker_sequence: u64,
14480) {
14481    *discard_through = (*discard_through).max(marker_sequence);
14482    pending.retain(|event| event.sequence > *discard_through);
14483}
14484
14485fn connection_event_burst_len(pending_len: usize) -> usize {
14486    pending_len.min(NODE_CONNECTION_EVENT_BURST_MAX)
14487}
14488
14489fn resync_required_event(shared: &NodeShared) -> NodeEventEnvelope {
14490    let history = shared
14491        .history
14492        .lock()
14493        .unwrap_or_else(|poisoned| poisoned.into_inner());
14494    let sequence = history.last_sequence;
14495    let oldest_available_sequence = history.replay_floor_sequence;
14496    NodeEventEnvelope {
14497        sequence,
14498        event: NodeEvent::ResyncRequired {
14499            oldest_available_sequence,
14500        },
14501    }
14502}
14503
14504fn node_compatibility_support(
14505    shared: &NodeShared,
14506) -> Result<NodeCompatibilitySupport, NodeServerError> {
14507    let mut support = node_compatibility_support_for_manifest(
14508        &shared.provider_contracts,
14509        &shared.provider_adapter_contracts,
14510    )?;
14511    if shared
14512        .delivery_store
14513        .lock()
14514        .unwrap_or_else(|poisoned| poisoned.into_inner())
14515        .is_some()
14516    {
14517        support.capabilities.push(
14518            CapabilityId::new(NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY)
14519                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14520        );
14521    }
14522    if shared.harness_mcp_registry.is_some() {
14523        support.capabilities.push(
14524            CapabilityId::new(NODE_HARNESS_MCP_READ_PROXY_CAPABILITY)
14525                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14526        );
14527    }
14528    Ok(support)
14529}
14530
14531fn node_compatibility_support_for_manifest(
14532    provider_contracts: &[ProviderContractSupport],
14533    provider_adapter_contracts: &[ProviderAdapterContractSupport],
14534) -> Result<NodeCompatibilitySupport, NodeServerError> {
14535    Ok(NodeCompatibilitySupport {
14536        build_stamp: BUILD_STAMP.to_owned(),
14537        capabilities: baseline_capabilities()?,
14538        host: platform::host_descriptor().map_err(NodeServerError::Handshake)?,
14539        path_semantics: platform::path_semantics(),
14540        local_transport: platform::local_transport(),
14541        state_schema: StateSchemaSupport {
14542            versions: ProtocolRange::new(NODE_STATE_SCHEMA_V1, NODE_STATE_SCHEMA_V10)
14543                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14544        },
14545        provider_contracts: provider_contracts.to_vec(),
14546        provider_adapter_contracts: provider_adapter_contracts.to_vec(),
14547    })
14548}
14549
14550fn baseline_capabilities() -> Result<Vec<CapabilityId>, NodeServerError> {
14551    let capabilities = [
14552        NODE_COMPATIBILITY_METADATA_CAPABILITY,
14553        CAPABILITY_HOST_DIRECTORY_BROWSE_V1,
14554        NODE_REPOSITORY_PATH_CAPABILITY,
14555        NODE_WORKSPACE_FILE_READ_CAPABILITY,
14556        NODE_WORKSPACE_FILE_WRITE_CAPABILITY,
14557        NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY,
14558        NODE_GIT_READ_CAPABILITY,
14559        NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY,
14560        NODE_PROVIDER_ID_OPEN_CAPABILITY,
14561        NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
14562        NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY,
14563        NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
14564        NODE_AGENT_STREAM_EVENTS_CAPABILITY,
14565        NODE_ACP_CONTROL_CAPABILITY,
14566        NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
14567        NODE_SPAWN_PROFILE_REVISION_CAPABILITY,
14568        NODE_WORKTREE_SELECTION_CAPABILITY,
14569        NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
14570        NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
14571        NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
14572        NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
14573        NODE_HISTORY_CONTEXT_PACK_CAPABILITY,
14574        NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
14575        NODE_NATIVE_SESSION_CATALOG_CAPABILITY,
14576        NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
14577        NODE_NATIVE_SESSION_INDEX_CAPABILITY,
14578        NODE_NATIVE_SESSION_PREVIEW_CAPABILITY,
14579        NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
14580        NODE_SESSION_TASK_CORRELATION_CAPABILITY,
14581    ]
14582        .into_iter()
14583        .map(|capability| {
14584            CapabilityId::new(capability)
14585                .map_err(|error| NodeServerError::Handshake(error.to_string()))
14586        })
14587        .collect::<Result<Vec<_>, _>>()?;
14588    #[cfg(windows)]
14589    let capabilities = {
14590        let mut capabilities = capabilities;
14591        capabilities.push(
14592            CapabilityId::new(NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY)
14593                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14594        );
14595        capabilities
14596    };
14597    Ok(capabilities)
14598}
14599
14600fn request_uses_unnegotiated_capability(
14601    request: &NodeRequest,
14602    selected_capabilities: &[CapabilityId],
14603) -> bool {
14604    let selected = |required: &str| {
14605        selected_capabilities
14606            .iter()
14607            .any(|capability| capability.as_str() == required)
14608    };
14609    request
14610        .required_capability()
14611        .is_some_and(|required| !selected(required))
14612        || (request.requires_worktree_selection_capability()
14613            && !selected(NODE_WORKTREE_SELECTION_CAPABILITY))
14614        || (request.requires_spawn_spec_defaults_overrides_capability()
14615            && !selected(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY))
14616        || (request.requires_spawn_profile_revision_capability()
14617            && !selected(NODE_SPAWN_PROFILE_REVISION_CAPABILITY))
14618        || (request.requires_child_environment_profile_capability()
14619            && !selected(NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY))
14620        || (request.requires_session_bundle_materialization_capability()
14621            && !selected(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY))
14622        || (request.requires_history_context_pack_capability()
14623            && !selected(NODE_HISTORY_CONTEXT_PACK_CAPABILITY))
14624}
14625
14626fn snapshot_for_wire(
14627    shared: &NodeShared,
14628    include_provider_runtime_status: bool,
14629    include_open_provider_ids: bool,
14630    include_spawn_profiles: bool,
14631    include_managed_worktrees: bool,
14632    include_child_environment_profiles: bool,
14633    include_session_bundles: bool,
14634    include_history_context_packs: bool,
14635    include_agent_progress: bool,
14636    include_session_task_correlation: bool,
14637) -> NodeSnapshot {
14638    let mut snapshot = shared.snapshot();
14639    if !include_provider_runtime_status {
14640        snapshot.provider_runtime_statuses.clear();
14641    }
14642    if !include_open_provider_ids {
14643        project_snapshot_legacy_provider_ids(&mut snapshot);
14644    }
14645    if !include_managed_worktrees {
14646        snapshot.managed_worktrees.clear();
14647        for workspace in &mut snapshot.workspaces {
14648            workspace.managed_worktree_profiles = None;
14649        }
14650    }
14651    if let Some(inventory) = snapshot.launch_inventory.as_mut() {
14652        if !include_spawn_profiles {
14653            inventory.spawn_profiles = None;
14654        }
14655        if !include_session_bundles {
14656            inventory.bundles = None;
14657        }
14658        if inventory.spawn_profiles.is_none()
14659            && inventory.bundles.is_none()
14660            && inventory.network_allowlists.is_none()
14661        {
14662            snapshot.launch_inventory = None;
14663        }
14664    }
14665    if !include_child_environment_profiles {
14666        clear_snapshot_child_environment_profiles(&mut snapshot);
14667    }
14668    if !include_session_bundles {
14669        clear_snapshot_session_bundles(&mut snapshot);
14670    }
14671    project_snapshot_history_for_wire(&mut snapshot, include_history_context_packs);
14672    if !include_history_context_packs {
14673        clear_snapshot_context_packs(&mut snapshot);
14674    }
14675    if !include_agent_progress {
14676        snapshot.agent_progress.clear();
14677    }
14678    if !include_session_task_correlation {
14679        clear_snapshot_session_task_bindings(&mut snapshot);
14680    }
14681    snapshot
14682}
14683
14684fn clear_snapshot_session_task_bindings(snapshot: &mut NodeSnapshot) {
14685    for record in &mut snapshot.session_records {
14686        record.task_binding = None;
14687    }
14688}
14689
14690fn project_event_without_session_task_binding(
14691    mut envelope: NodeEventEnvelope,
14692) -> NodeEventEnvelope {
14693    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14694        record.task_binding = None;
14695    }
14696    envelope
14697}
14698
14699fn project_response_without_session_task_binding(reply: &mut ResponseEnvelope) {
14700    let Ok(response) = reply.result.as_mut() else { return };
14701    match response {
14702        NodeResponse::Snapshot { snapshot, .. } => {
14703            clear_snapshot_session_task_bindings(snapshot);
14704        }
14705        NodeResponse::Resync { snapshot, events, .. } => {
14706            clear_snapshot_session_task_bindings(snapshot);
14707            for event in events {
14708                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14709                    record.task_binding = None;
14710                }
14711            }
14712        }
14713        NodeResponse::SessionRecordUpdated { record }
14714        | NodeResponse::ProviderSessionIndexed { record }
14715        | NodeResponse::NativeSessionIndexed { record, .. }
14716        | NodeResponse::SessionRecordResumed { record, .. } => {
14717            record.task_binding = None;
14718        }
14719        _ => {}
14720    }
14721}
14722
14723fn project_response_without_agent_progress(reply: &mut ResponseEnvelope) {
14724    let Ok(response) = reply.result.as_mut() else { return };
14725    match response {
14726        NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
14727            snapshot.agent_progress.clear();
14728        }
14729        _ => {}
14730    }
14731}
14732
14733fn project_event_without_managed_worktrees(
14734    mut envelope: NodeEventEnvelope,
14735) -> Option<NodeEventEnvelope> {
14736    if matches!(
14737        envelope.event,
14738        NodeEvent::ManagedWorktreeUpserted { .. }
14739            | NodeEvent::ManagedWorktreeRemoved { .. }
14740    ) {
14741        return None;
14742    }
14743    if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
14744        workspace.managed_worktree_profiles = None;
14745    }
14746    Some(envelope)
14747}
14748
14749fn project_response_without_managed_worktrees(reply: &mut ResponseEnvelope) {
14750    if matches!(
14751        reply.result,
14752        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { .. })
14753            | Ok(NodeResponse::ManagedWorktreeCleanup { .. })
14754    ) {
14755        reply.result = Err(failure(
14756            NodeFailureCode::UnsupportedCapability,
14757            "managed worktree capability was not negotiated",
14758        ));
14759        return;
14760    }
14761    let Ok(response) = reply.result.as_mut() else { return };
14762    match response {
14763        NodeResponse::Snapshot { snapshot, .. } => {
14764            snapshot.managed_worktrees.clear();
14765            for workspace in &mut snapshot.workspaces {
14766                workspace.managed_worktree_profiles = None;
14767            }
14768        }
14769        NodeResponse::Resync { snapshot, events, .. } => {
14770            snapshot.managed_worktrees.clear();
14771            for workspace in &mut snapshot.workspaces {
14772                workspace.managed_worktree_profiles = None;
14773            }
14774            *events = std::mem::take(events)
14775                .into_iter()
14776                .filter_map(project_event_without_managed_worktrees)
14777                .collect();
14778        }
14779        NodeResponse::WorkspaceRegistered { workspace }
14780        | NodeResponse::StandaloneWorkspaceCreated { workspace }
14781        | NodeResponse::WorktreeCreated { workspace, .. } => {
14782            workspace.managed_worktree_profiles = None;
14783        }
14784        NodeResponse::WorkspaceInspected { inspection } => {
14785            inspection.git.managed_worktree = None;
14786        }
14787        _ => {}
14788    }
14789}
14790
14791fn project_negotiated_provider_ids(compatibility: &mut NegotiatedNodeCompatibility) {
14792    let includes_open_provider_ids = compatibility.capabilities.iter().any(|capability| {
14793        capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
14794    });
14795    if includes_open_provider_ids {
14796        return;
14797    }
14798    compatibility
14799        .provider_contracts
14800        .retain(|contract| provider_id_is_legacy(&contract.provider));
14801    compatibility
14802        .provider_adapter_contracts
14803        .retain(|contract| provider_id_is_legacy(&contract.provider));
14804}
14805
14806fn request_requires_open_provider_ids(shared: &NodeShared, request: &NodeRequest) -> bool {
14807    if let Some(spec) = match request {
14808        NodeRequest::SpawnSpec { spec }
14809        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14810        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14811        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14812        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14813        _ => None,
14814    } {
14815        return shared
14816            .resolve_spawn_spec(spec)
14817            .is_ok_and(|resolved| !provider_id_is_legacy(&resolved.provider));
14818    }
14819    request_requires_open_provider_ids_with(
14820        request,
14821        |session| shared.validate_address(session).ok(),
14822        |record_id| shared.record(record_id).ok().map(|record| record.provider),
14823    )
14824}
14825
14826fn request_requires_child_environment_profile(
14827    shared: &NodeShared,
14828    request: &NodeRequest,
14829) -> bool {
14830    if let Some(spec) = match request {
14831        NodeRequest::SpawnSpec { spec }
14832        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14833        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14834        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14835        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14836        _ => None,
14837    } {
14838        return shared
14839            .resolve_spawn_spec(spec)
14840            .is_ok_and(|resolved| resolved.environment_profile_id.is_some());
14841    }
14842    match request {
14843        NodeRequest::Resume { session, .. }
14844        | NodeRequest::Prompt { session, .. }
14845        | NodeRequest::Paste { session, .. }
14846        | NodeRequest::Input { session, .. }
14847        | NodeRequest::TerminalBytes { session, .. }
14848        | NodeRequest::TerminalControl { session, .. }
14849        | NodeRequest::Resize { session, .. }
14850        | NodeRequest::Interrupt { session }
14851        | NodeRequest::Stop { session, .. }
14852        | NodeRequest::Remove { session }
14853        | NodeRequest::ResolveInteraction { session, .. }
14854        | NodeRequest::SetSessionMode { session, .. }
14855        | NodeRequest::SetSessionConfigOption { session, .. }
14856        | NodeRequest::SetSessionModel { session, .. }
14857        | NodeRequest::DiscoverHistory { session, .. }
14858        | NodeRequest::LoadHistory { session, .. }
14859        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14860        | NodeRequest::ExportContextPack { session } => shared
14861            .session_bindings
14862            .lock()
14863            .unwrap_or_else(|poisoned| poisoned.into_inner())
14864            .get(&session.session.instance_id)
14865            .filter(|binding| {
14866                binding.workspace_id == session.workspace_id
14867                    && binding.generation == session.session.generation
14868            })
14869            .map_or(true, |binding| binding.environment_profile.is_some()),
14870        NodeRequest::RenameSessionRecord { record_id, .. }
14871        | NodeRequest::SetSessionTask { record_id, .. }
14872        | NodeRequest::ResumeSessionRecord { record_id, .. }
14873        | NodeRequest::ForgetSessionRecord { record_id } => shared
14874            .record(record_id)
14875            .map_or(true, |record| record.environment_profile.is_some()),
14876        NodeRequest::ArmHarnessMcpReservation { .. }
14877        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14878        | NodeRequest::ActivateHarnessMcpReservation { .. }
14879        | NodeRequest::AbortHarnessMcpReservation { .. }
14880        | NodeRequest::PutHarnessMcpReplyChunk { .. }
14881        | NodeRequest::RejectHarnessMcpCall { .. }
14882        | NodeRequest::Snapshot
14883        | NodeRequest::Resync { .. }
14884        | NodeRequest::BeginDeliveryStage { .. }
14885        | NodeRequest::PutDeliveryBlobChunk { .. }
14886        | NodeRequest::CommitDeliveryStage { .. }
14887        | NodeRequest::AbortDeliveryStage { .. }
14888        | NodeRequest::BrowseHostDirectories { .. }
14889        | NodeRequest::InspectWorkspace { .. }
14890        | NodeRequest::ReadWorkspaceFile { .. }
14891        | NodeRequest::WriteWorkspaceFile { .. }
14892        | NodeRequest::CreateWorkspaceFile { .. }
14893        | NodeRequest::CreateWorkspaceDirectory { .. }
14894        | NodeRequest::ReadGitHistory { .. }
14895        | NodeRequest::ReadGitDiff { .. }
14896        | NodeRequest::AcquireController { .. }
14897        | NodeRequest::ReleaseController
14898        | NodeRequest::Spawn { .. }
14899        | NodeRequest::SpawnWithOptions { .. }
14900        | NodeRequest::SpawnSpec { .. }
14901        | NodeRequest::RegisterWorkspace { .. }
14902        | NodeRequest::CreateStandaloneWorkspace { .. }
14903        | NodeRequest::UnregisterWorkspace { .. }
14904        | NodeRequest::CreateWorktree { .. }
14905        | NodeRequest::RemoveWorktree { .. }
14906        | NodeRequest::SpawnManagedWorktree { .. }
14907        | NodeRequest::SpawnManagedWorktreeV2 { .. }
14908        | NodeRequest::CleanupManagedWorktree { .. }
14909        | NodeRequest::IndexProviderSession { .. }
14910        | NodeRequest::IndexNativeSession { .. }
14911        | NodeRequest::CatalogNativeSessions { .. }
14912        | NodeRequest::PageNativeSessions { .. }
14913        | NodeRequest::PreviewNativeSession { .. }
14914        | NodeRequest::PreviewSessionRecord { .. }
14915        | NodeRequest::ForgetContextPack { .. }
14916        | NodeRequest::ResolveDurableContextPack { .. }
14917        | NodeRequest::ReadContextPack { .. }
14918        | NodeRequest::Shutdown => false,
14919    }
14920}
14921
14922fn request_requires_session_bundle(shared: &NodeShared, request: &NodeRequest) -> bool {
14923    if let Some(spec) = match request {
14924        NodeRequest::SpawnSpec { spec }
14925        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14926        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14927        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14928        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14929        _ => None,
14930    } {
14931        return shared
14932            .resolve_spawn_spec(spec)
14933            .is_ok_and(|resolved| resolved.bundle_id.is_some());
14934    }
14935    match request {
14936        NodeRequest::Resume { session, .. }
14937        | NodeRequest::Prompt { session, .. }
14938        | NodeRequest::Paste { session, .. }
14939        | NodeRequest::Input { session, .. }
14940        | NodeRequest::TerminalBytes { session, .. }
14941        | NodeRequest::TerminalControl { session, .. }
14942        | NodeRequest::Resize { session, .. }
14943        | NodeRequest::Interrupt { session }
14944        | NodeRequest::Stop { session, .. }
14945        | NodeRequest::Remove { session }
14946        | NodeRequest::ResolveInteraction { session, .. }
14947        | NodeRequest::SetSessionMode { session, .. }
14948        | NodeRequest::SetSessionConfigOption { session, .. }
14949        | NodeRequest::SetSessionModel { session, .. }
14950        | NodeRequest::DiscoverHistory { session, .. }
14951        | NodeRequest::LoadHistory { session, .. }
14952        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14953        | NodeRequest::ExportContextPack { session } => shared
14954            .session_bindings
14955            .lock()
14956            .unwrap_or_else(|poisoned| poisoned.into_inner())
14957            .get(&session.session.instance_id)
14958            .filter(|binding| {
14959                binding.workspace_id == session.workspace_id
14960                    && binding.generation == session.session.generation
14961            })
14962            .map_or(true, |binding| binding.bundle.is_some()),
14963        NodeRequest::RenameSessionRecord { record_id, .. }
14964        | NodeRequest::SetSessionTask { record_id, .. }
14965        | NodeRequest::ResumeSessionRecord { record_id, .. }
14966        | NodeRequest::ForgetSessionRecord { record_id } => shared
14967            .record(record_id)
14968            .map_or(true, |record| record.bundle.is_some()),
14969        NodeRequest::ArmHarnessMcpReservation { .. }
14970        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14971        | NodeRequest::ActivateHarnessMcpReservation { .. }
14972        | NodeRequest::AbortHarnessMcpReservation { .. }
14973        | NodeRequest::PutHarnessMcpReplyChunk { .. }
14974        | NodeRequest::RejectHarnessMcpCall { .. }
14975        | NodeRequest::Snapshot
14976        | NodeRequest::Resync { .. }
14977        | NodeRequest::BeginDeliveryStage { .. }
14978        | NodeRequest::PutDeliveryBlobChunk { .. }
14979        | NodeRequest::CommitDeliveryStage { .. }
14980        | NodeRequest::AbortDeliveryStage { .. }
14981        | NodeRequest::BrowseHostDirectories { .. }
14982        | NodeRequest::InspectWorkspace { .. }
14983        | NodeRequest::ReadWorkspaceFile { .. }
14984        | NodeRequest::WriteWorkspaceFile { .. }
14985        | NodeRequest::CreateWorkspaceFile { .. }
14986        | NodeRequest::CreateWorkspaceDirectory { .. }
14987        | NodeRequest::ReadGitHistory { .. }
14988        | NodeRequest::ReadGitDiff { .. }
14989        | NodeRequest::AcquireController { .. }
14990        | NodeRequest::ReleaseController
14991        | NodeRequest::Spawn { .. }
14992        | NodeRequest::SpawnWithOptions { .. }
14993        | NodeRequest::SpawnSpec { .. }
14994        | NodeRequest::RegisterWorkspace { .. }
14995        | NodeRequest::CreateStandaloneWorkspace { .. }
14996        | NodeRequest::UnregisterWorkspace { .. }
14997        | NodeRequest::CreateWorktree { .. }
14998        | NodeRequest::RemoveWorktree { .. }
14999        | NodeRequest::SpawnManagedWorktree { .. }
15000        | NodeRequest::SpawnManagedWorktreeV2 { .. }
15001        | NodeRequest::CleanupManagedWorktree { .. }
15002        | NodeRequest::IndexProviderSession { .. }
15003        | NodeRequest::IndexNativeSession { .. }
15004        | NodeRequest::CatalogNativeSessions { .. }
15005        | NodeRequest::PageNativeSessions { .. }
15006        | NodeRequest::PreviewNativeSession { .. }
15007        | NodeRequest::PreviewSessionRecord { .. }
15008        | NodeRequest::ForgetContextPack { .. }
15009        | NodeRequest::ResolveDurableContextPack { .. }
15010        | NodeRequest::ReadContextPack { .. }
15011        | NodeRequest::Shutdown => false,
15012    }
15013}
15014
15015fn request_requires_history_context_pack(shared: &NodeShared, request: &NodeRequest) -> bool {
15016    if request.requires_history_context_pack_capability() {
15017        return true;
15018    }
15019    if let Some(spec) = match request {
15020        NodeRequest::SpawnSpec { spec }
15021        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
15022        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
15023        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
15024        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
15025        _ => None,
15026    } {
15027        return shared
15028            .resolve_spawn_spec(spec)
15029            .map_or(true, |resolved| resolved.context_id.is_some());
15030    }
15031    match request {
15032        NodeRequest::Resume { session, .. }
15033        | NodeRequest::Prompt { session, .. }
15034        | NodeRequest::Paste { session, .. }
15035        | NodeRequest::Input { session, .. }
15036        | NodeRequest::TerminalBytes { session, .. }
15037        | NodeRequest::TerminalControl { session, .. }
15038        | NodeRequest::Resize { session, .. }
15039        | NodeRequest::Interrupt { session }
15040        | NodeRequest::Stop { session, .. }
15041        | NodeRequest::Remove { session }
15042        | NodeRequest::ResolveInteraction { session, .. }
15043        | NodeRequest::SetSessionMode { session, .. }
15044        | NodeRequest::SetSessionConfigOption { session, .. }
15045        | NodeRequest::SetSessionModel { session, .. } => shared
15046            .session_bindings
15047            .lock()
15048            .unwrap_or_else(|poisoned| poisoned.into_inner())
15049            .get(&session.session.instance_id)
15050            .filter(|binding| {
15051                binding.workspace_id == session.workspace_id
15052                    && binding.generation == session.session.generation
15053            })
15054            .map_or(true, |binding| binding.context.is_some()),
15055        NodeRequest::RenameSessionRecord { record_id, .. }
15056        | NodeRequest::SetSessionTask { record_id, .. }
15057        | NodeRequest::ResumeSessionRecord { record_id, .. }
15058        | NodeRequest::ForgetSessionRecord { record_id } => shared
15059            .record(record_id)
15060            .map_or(true, |record| record.context.is_some()),
15061        NodeRequest::ArmHarnessMcpReservation { .. }
15062        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
15063        | NodeRequest::ActivateHarnessMcpReservation { .. }
15064        | NodeRequest::AbortHarnessMcpReservation { .. }
15065        | NodeRequest::PutHarnessMcpReplyChunk { .. }
15066        | NodeRequest::RejectHarnessMcpCall { .. }
15067        | NodeRequest::Snapshot
15068        | NodeRequest::Resync { .. }
15069        | NodeRequest::BeginDeliveryStage { .. }
15070        | NodeRequest::PutDeliveryBlobChunk { .. }
15071        | NodeRequest::CommitDeliveryStage { .. }
15072        | NodeRequest::AbortDeliveryStage { .. }
15073        | NodeRequest::BrowseHostDirectories { .. }
15074        | NodeRequest::InspectWorkspace { .. }
15075        | NodeRequest::ReadWorkspaceFile { .. }
15076        | NodeRequest::WriteWorkspaceFile { .. }
15077        | NodeRequest::CreateWorkspaceFile { .. }
15078        | NodeRequest::CreateWorkspaceDirectory { .. }
15079        | NodeRequest::ReadGitHistory { .. }
15080        | NodeRequest::ReadGitDiff { .. }
15081        | NodeRequest::AcquireController { .. }
15082        | NodeRequest::ReleaseController
15083        | NodeRequest::Spawn { .. }
15084        | NodeRequest::SpawnWithOptions { .. }
15085        | NodeRequest::SpawnSpec { .. }
15086        | NodeRequest::RegisterWorkspace { .. }
15087        | NodeRequest::CreateStandaloneWorkspace { .. }
15088        | NodeRequest::UnregisterWorkspace { .. }
15089        | NodeRequest::CreateWorktree { .. }
15090        | NodeRequest::RemoveWorktree { .. }
15091        | NodeRequest::SpawnManagedWorktree { .. }
15092        | NodeRequest::SpawnManagedWorktreeV2 { .. }
15093        | NodeRequest::CleanupManagedWorktree { .. }
15094        | NodeRequest::IndexProviderSession { .. }
15095        | NodeRequest::IndexNativeSession { .. }
15096        | NodeRequest::CatalogNativeSessions { .. }
15097        | NodeRequest::PageNativeSessions { .. }
15098        | NodeRequest::PreviewNativeSession { .. }
15099        | NodeRequest::PreviewSessionRecord { .. }
15100        | NodeRequest::DiscoverHistory { .. }
15101        | NodeRequest::LoadHistory { .. }
15102        | NodeRequest::ExportContextPackForSessionRecord { .. }
15103        | NodeRequest::ExportContextPack { .. }
15104        | NodeRequest::ForgetContextPack { .. }
15105        | NodeRequest::ResolveDurableContextPack { .. }
15106        | NodeRequest::ReadContextPack { .. }
15107        | NodeRequest::Shutdown => false,
15108    }
15109}
15110
15111fn request_requires_open_provider_ids_with(
15112    request: &NodeRequest,
15113    provider_for_session: impl Fn(&SessionAddress) -> Option<AgentId>,
15114    provider_for_record: impl Fn(&SessionRecordId) -> Option<AgentId>,
15115) -> bool {
15116    match request {
15117        NodeRequest::Spawn { provider, .. } | NodeRequest::SpawnWithOptions { provider, .. } => !provider_id_is_legacy(provider),
15118        NodeRequest::IndexProviderSession { provider, .. } => !provider_id_is_legacy(provider),
15119        NodeRequest::CatalogNativeSessions { route, .. }
15120        | NodeRequest::PageNativeSessions { route, .. } => {
15121            !provider_id_is_legacy(&route.provider)
15122        }
15123        NodeRequest::PreviewNativeSession { selection, .. }
15124        | NodeRequest::IndexNativeSession { selection, .. } => {
15125            !provider_id_is_legacy(&selection.route.provider)
15126        }
15127        NodeRequest::SpawnSpec { .. }
15128        | NodeRequest::SpawnSpecWithHarnessMcp { .. } => false,
15129        NodeRequest::SpawnManagedWorktree { .. }
15130        | NodeRequest::SpawnManagedWorktreeV2 { .. } => false,
15131        NodeRequest::Resume { session, .. }
15132        | NodeRequest::Prompt { session, .. }
15133        | NodeRequest::Paste { session, .. }
15134        | NodeRequest::Input { session, .. }
15135        | NodeRequest::TerminalBytes { session, .. }
15136        | NodeRequest::TerminalControl { session, .. }
15137        | NodeRequest::Resize { session, .. }
15138        | NodeRequest::Interrupt { session }
15139        | NodeRequest::Stop { session, .. }
15140        | NodeRequest::Remove { session }
15141        | NodeRequest::ResolveInteraction { session, .. }
15142        | NodeRequest::SetSessionMode { session, .. }
15143        | NodeRequest::SetSessionConfigOption { session, .. }
15144        | NodeRequest::SetSessionModel { session, .. }
15145        | NodeRequest::DiscoverHistory { session, .. }
15146        | NodeRequest::LoadHistory { session, .. }
15147        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
15148        | NodeRequest::ExportContextPack { session } => provider_for_session(session)
15149            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
15150        NodeRequest::RenameSessionRecord { record_id, .. }
15151        | NodeRequest::SetSessionTask { record_id, .. }
15152        | NodeRequest::ResumeSessionRecord { record_id, .. }
15153        | NodeRequest::ForgetSessionRecord { record_id } => provider_for_record(record_id)
15154            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
15155        NodeRequest::PreviewSessionRecord { record_id, .. } => provider_for_record(record_id)
15156            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
15157        NodeRequest::ArmHarnessMcpReservation { .. }
15158        | NodeRequest::ActivateHarnessMcpReservation { .. }
15159        | NodeRequest::AbortHarnessMcpReservation { .. }
15160        | NodeRequest::PutHarnessMcpReplyChunk { .. }
15161        | NodeRequest::RejectHarnessMcpCall { .. }
15162        | NodeRequest::Snapshot
15163        | NodeRequest::Resync { .. }
15164        | NodeRequest::BeginDeliveryStage { .. }
15165        | NodeRequest::PutDeliveryBlobChunk { .. }
15166        | NodeRequest::CommitDeliveryStage { .. }
15167        | NodeRequest::AbortDeliveryStage { .. }
15168        | NodeRequest::BrowseHostDirectories { .. }
15169        | NodeRequest::InspectWorkspace { .. }
15170        | NodeRequest::ReadWorkspaceFile { .. }
15171        | NodeRequest::WriteWorkspaceFile { .. }
15172        | NodeRequest::CreateWorkspaceFile { .. }
15173        | NodeRequest::CreateWorkspaceDirectory { .. }
15174        | NodeRequest::ReadGitHistory { .. }
15175        | NodeRequest::ReadGitDiff { .. }
15176        | NodeRequest::AcquireController { .. }
15177        | NodeRequest::ReleaseController
15178        | NodeRequest::RegisterWorkspace { .. }
15179        | NodeRequest::CreateStandaloneWorkspace { .. }
15180        | NodeRequest::UnregisterWorkspace { .. }
15181        | NodeRequest::CreateWorktree { .. }
15182        | NodeRequest::RemoveWorktree { .. }
15183        | NodeRequest::CleanupManagedWorktree { .. }
15184        | NodeRequest::Shutdown => false,
15185        NodeRequest::ForgetContextPack { .. } => true,
15186        // No session/record to cheaply resolve a provider from here either;
15187        // the resolved receipt can carry an open-id provider, so require the
15188        // same capability as ForgetContextPack unconditionally. `ReadContextPack`
15189        // is the same digest-keyed lookup one step further into the same
15190        // catalog, so it inherits the same conservative answer.
15191        NodeRequest::ResolveDurableContextPack { .. }
15192        | NodeRequest::ReadContextPack { .. } => true,
15193    }
15194}
15195
15196fn project_snapshot_legacy_provider_ids(snapshot: &mut NodeSnapshot) {
15197    snapshot.enabled_providers.retain(provider_id_is_legacy);
15198    snapshot.provider_runtime_statuses = ProviderRuntimeStatuses::new(
15199        snapshot
15200            .provider_runtime_statuses
15201            .as_slice()
15202            .iter()
15203            .filter(|status| provider_id_is_legacy(status.provider()))
15204            .cloned(),
15205    )
15206    .expect("a filtered provider runtime projection remains valid");
15207    snapshot
15208        .session_records
15209        .retain(|record| provider_id_is_legacy(&record.provider));
15210    for workspace in &mut snapshot.workspaces {
15211        workspace
15212            .sessions
15213            .retain(|session| provider_id_is_legacy(&session.agent_id));
15214    }
15215}
15216
15217fn clear_snapshot_child_environment_profiles(snapshot: &mut NodeSnapshot) {
15218    for record in &mut snapshot.session_records {
15219        record.environment_profile = None;
15220    }
15221    if let Some(profiles) = snapshot
15222        .launch_inventory
15223        .as_mut()
15224        .and_then(|inventory| inventory.spawn_profiles.as_mut())
15225    {
15226        for profile in profiles {
15227            profile.environment_profile = None;
15228        }
15229    }
15230}
15231
15232fn project_event_without_child_environment_profile(
15233    mut envelope: NodeEventEnvelope,
15234) -> NodeEventEnvelope {
15235    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15236        record.environment_profile = None;
15237    }
15238    envelope
15239}
15240
15241fn project_response_without_child_environment_profile(reply: &mut ResponseEnvelope) {
15242    let contains_environment_profile = match reply.result.as_ref() {
15243        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
15244            receipt.environment_profile.is_some()
15245        }
15246        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15247            receipt.spawn.environment_profile.is_some()
15248        }
15249        _ => false,
15250    };
15251    if contains_environment_profile {
15252        reply.result = Err(failure(
15253            NodeFailureCode::UnsupportedCapability,
15254            "child environment profiles were not negotiated",
15255        ));
15256        return;
15257    }
15258    let Ok(response) = reply.result.as_mut() else {
15259        return;
15260    };
15261    match response {
15262        NodeResponse::Snapshot { snapshot, .. } => {
15263            clear_snapshot_child_environment_profiles(snapshot);
15264        }
15265        NodeResponse::Resync { snapshot, events, .. } => {
15266            clear_snapshot_child_environment_profiles(snapshot);
15267            for event in events {
15268                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15269                    record.environment_profile = None;
15270                }
15271            }
15272        }
15273        NodeResponse::SessionRecordUpdated { record }
15274        | NodeResponse::ProviderSessionIndexed { record }
15275        | NodeResponse::NativeSessionIndexed { record, .. }
15276        | NodeResponse::SessionRecordResumed { record, .. } => {
15277            record.environment_profile = None;
15278        }
15279        NodeResponse::Armed { .. }
15280        | NodeResponse::Spawned { .. }
15281        | NodeResponse::Activated { .. }
15282        | NodeResponse::Aborted { .. }
15283        | NodeResponse::ReplyChunkAccepted { .. }
15284        | NodeResponse::CallRejected { .. }
15285        | NodeResponse::DeliveryStageBegun { .. }
15286        | NodeResponse::DeliveryBlobChunkAccepted { .. }
15287        | NodeResponse::DeliveryCommitted { .. }
15288        | NodeResponse::DeliveryStageAborted { .. }
15289        | NodeResponse::WorkspaceInspected { .. }
15290        | NodeResponse::HostDirectoriesBrowsed { .. }
15291        | NodeResponse::WorkspaceFileRead { .. }
15292        | NodeResponse::WorkspaceFileWritten { .. }
15293        | NodeResponse::WorkspaceFileCreated { .. }
15294        | NodeResponse::WorkspaceDirectoryCreated { .. }
15295        | NodeResponse::GitHistoryRead { .. }
15296        | NodeResponse::GitDiffRead { .. }
15297        | NodeResponse::Controller { .. }
15298        | NodeResponse::SpawnAccepted { .. }
15299        | NodeResponse::SpawnSpecAccepted { .. }
15300        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15301        | NodeResponse::ManagedWorktreeCleanup { .. }
15302        | NodeResponse::NativeSessionsCataloged { .. }
15303        | NodeResponse::NativeSessionsPaged { .. }
15304        | NodeResponse::NativeSessionPreviewed { .. }
15305        | NodeResponse::SessionRecordPreviewed { .. }
15306        | NodeResponse::HistoryDiscovered { .. }
15307        | NodeResponse::HistoryLoaded { .. }
15308        | NodeResponse::ContextPackForSessionRecordExported { .. }
15309        | NodeResponse::ContextPackExported { .. }
15310        | NodeResponse::ContextPackForgotten { .. }
15311        | NodeResponse::DurableContextPackResolved { .. }
15312        | NodeResponse::ContextPackBytesRead { .. }
15313        | NodeResponse::SessionRecordForgotten { .. }
15314        | NodeResponse::WorkspaceRegistered { .. }
15315        | NodeResponse::StandaloneWorkspaceCreated { .. }
15316        | NodeResponse::WorkspaceUnregistered { .. }
15317        | NodeResponse::WorktreeCreated { .. }
15318        | NodeResponse::WorktreeRemoved { .. }
15319        | NodeResponse::Accepted
15320        | NodeResponse::ShuttingDown => {}
15321    }
15322}
15323
15324fn clear_snapshot_session_bundles(snapshot: &mut NodeSnapshot) {
15325    for record in &mut snapshot.session_records {
15326        record.bundle = None;
15327    }
15328}
15329
15330fn project_event_without_session_bundle(
15331    mut envelope: NodeEventEnvelope,
15332) -> NodeEventEnvelope {
15333    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15334        record.bundle = None;
15335    }
15336    envelope
15337}
15338
15339fn project_response_without_session_bundle(reply: &mut ResponseEnvelope) {
15340    let contains_bundle = match reply.result.as_ref() {
15341        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.bundle.is_some(),
15342        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15343            receipt.spawn.bundle.is_some()
15344        }
15345        _ => false,
15346    };
15347    if contains_bundle {
15348        reply.result = Err(failure(
15349            NodeFailureCode::UnsupportedCapability,
15350            "session bundles were not negotiated",
15351        ));
15352        return;
15353    }
15354    let Ok(response) = reply.result.as_mut() else {
15355        return;
15356    };
15357    match response {
15358        NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_session_bundles(snapshot),
15359        NodeResponse::Resync { snapshot, events, .. } => {
15360            clear_snapshot_session_bundles(snapshot);
15361            for event in events {
15362                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15363                    record.bundle = None;
15364                }
15365            }
15366        }
15367        NodeResponse::SessionRecordUpdated { record }
15368        | NodeResponse::ProviderSessionIndexed { record }
15369        | NodeResponse::NativeSessionIndexed { record, .. }
15370        | NodeResponse::SessionRecordResumed { record, .. } => record.bundle = None,
15371        NodeResponse::Armed { .. }
15372        | NodeResponse::Spawned { .. }
15373        | NodeResponse::Activated { .. }
15374        | NodeResponse::Aborted { .. }
15375        | NodeResponse::ReplyChunkAccepted { .. }
15376        | NodeResponse::CallRejected { .. }
15377        | NodeResponse::DeliveryStageBegun { .. }
15378        | NodeResponse::DeliveryBlobChunkAccepted { .. }
15379        | NodeResponse::DeliveryCommitted { .. }
15380        | NodeResponse::DeliveryStageAborted { .. }
15381        | NodeResponse::WorkspaceInspected { .. }
15382        | NodeResponse::HostDirectoriesBrowsed { .. }
15383        | NodeResponse::WorkspaceFileRead { .. }
15384        | NodeResponse::WorkspaceFileWritten { .. }
15385        | NodeResponse::WorkspaceFileCreated { .. }
15386        | NodeResponse::WorkspaceDirectoryCreated { .. }
15387        | NodeResponse::GitHistoryRead { .. }
15388        | NodeResponse::GitDiffRead { .. }
15389        | NodeResponse::Controller { .. }
15390        | NodeResponse::SpawnAccepted { .. }
15391        | NodeResponse::SpawnSpecAccepted { .. }
15392        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15393        | NodeResponse::ManagedWorktreeCleanup { .. }
15394        | NodeResponse::NativeSessionsCataloged { .. }
15395        | NodeResponse::NativeSessionsPaged { .. }
15396        | NodeResponse::NativeSessionPreviewed { .. }
15397        | NodeResponse::SessionRecordPreviewed { .. }
15398        | NodeResponse::HistoryDiscovered { .. }
15399        | NodeResponse::HistoryLoaded { .. }
15400        | NodeResponse::ContextPackForSessionRecordExported { .. }
15401        | NodeResponse::ContextPackExported { .. }
15402        | NodeResponse::ContextPackForgotten { .. }
15403        | NodeResponse::DurableContextPackResolved { .. }
15404        | NodeResponse::ContextPackBytesRead { .. }
15405        | NodeResponse::SessionRecordForgotten { .. }
15406        | NodeResponse::WorkspaceRegistered { .. }
15407        | NodeResponse::StandaloneWorkspaceCreated { .. }
15408        | NodeResponse::WorkspaceUnregistered { .. }
15409        | NodeResponse::WorktreeCreated { .. }
15410        | NodeResponse::WorktreeRemoved { .. }
15411        | NodeResponse::Accepted
15412        | NodeResponse::ShuttingDown => {}
15413    }
15414}
15415
15416fn project_snapshot_history_for_wire(
15417    snapshot: &mut NodeSnapshot,
15418    include_history_metadata: bool,
15419) {
15420    for workspace in &mut snapshot.workspaces {
15421        for session in &mut workspace.sessions {
15422            if include_history_metadata {
15423                session.history.loaded = None;
15424            } else {
15425                session.history = Default::default();
15426            }
15427        }
15428    }
15429}
15430
15431fn project_event_history_for_wire(
15432    mut envelope: NodeEventEnvelope,
15433    include_history_metadata: bool,
15434) -> NodeEventEnvelope {
15435    if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
15436        for session in &mut workspace.sessions {
15437            if include_history_metadata {
15438                session.history.loaded = None;
15439            } else {
15440                session.history = Default::default();
15441            }
15442        }
15443    }
15444    envelope
15445}
15446
15447fn project_response_history_for_wire(
15448    reply: &mut ResponseEnvelope,
15449    include_history_metadata: bool,
15450) {
15451    let Ok(response) = reply.result.as_mut() else {
15452        return;
15453    };
15454    match response {
15455        NodeResponse::Snapshot { snapshot, .. } => {
15456            project_snapshot_history_for_wire(snapshot, include_history_metadata);
15457        }
15458        NodeResponse::Resync {
15459            snapshot, events, ..
15460        } => {
15461            project_snapshot_history_for_wire(snapshot, include_history_metadata);
15462            for event in events {
15463                *event = project_event_history_for_wire(
15464                    event.clone(),
15465                    include_history_metadata,
15466                );
15467            }
15468        }
15469        NodeResponse::WorkspaceRegistered { workspace }
15470        | NodeResponse::StandaloneWorkspaceCreated { workspace }
15471        | NodeResponse::WorktreeCreated { workspace, .. } => {
15472            for session in &mut workspace.sessions {
15473                if include_history_metadata {
15474                    session.history.loaded = None;
15475                } else {
15476                    session.history = Default::default();
15477                }
15478            }
15479        }
15480        _ => {}
15481    }
15482}
15483
15484fn clear_snapshot_context_packs(snapshot: &mut NodeSnapshot) {
15485    for record in &mut snapshot.session_records {
15486        record.context_id = None;
15487        record.context = None;
15488        record.exported_context = None;
15489    }
15490}
15491
15492fn project_event_without_context_pack(
15493    mut envelope: NodeEventEnvelope,
15494) -> NodeEventEnvelope {
15495    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15496        record.context_id = None;
15497        record.context = None;
15498        record.exported_context = None;
15499    }
15500    envelope
15501}
15502
15503fn project_response_without_context_pack(reply: &mut ResponseEnvelope) {
15504    let contains_context = match reply.result.as_ref() {
15505        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.context.is_some(),
15506        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15507            receipt.spawn.context.is_some()
15508        }
15509        Ok(NodeResponse::HistoryDiscovered { .. })
15510        | Ok(NodeResponse::HistoryLoaded { .. })
15511        | Ok(NodeResponse::ContextPackForSessionRecordExported { .. })
15512        | Ok(NodeResponse::ContextPackExported { .. })
15513        | Ok(NodeResponse::ContextPackForgotten { .. })
15514        | Ok(NodeResponse::DurableContextPackResolved { .. }) => true,
15515        _ => false,
15516    };
15517    if contains_context {
15518        reply.result = Err(failure(
15519            NodeFailureCode::UnsupportedCapability,
15520            "history context packs were not negotiated",
15521        ));
15522        return;
15523    }
15524    let Ok(response) = reply.result.as_mut() else {
15525        return;
15526    };
15527    match response {
15528        NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_context_packs(snapshot),
15529        NodeResponse::Resync {
15530            snapshot, events, ..
15531        } => {
15532            clear_snapshot_context_packs(snapshot);
15533            for event in events {
15534                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15535                    record.context_id = None;
15536                    record.context = None;
15537                    record.exported_context = None;
15538                }
15539            }
15540        }
15541        NodeResponse::SessionRecordUpdated { record }
15542        | NodeResponse::ProviderSessionIndexed { record }
15543        | NodeResponse::NativeSessionIndexed { record, .. }
15544        | NodeResponse::SessionRecordResumed { record, .. } => {
15545            record.context_id = None;
15546            record.context = None;
15547            record.exported_context = None;
15548        }
15549        _ => {}
15550    }
15551}
15552
15553fn project_response_legacy_provider_ids(shared: &NodeShared, reply: &mut ResponseEnvelope) {
15554    let contains_open_record = match reply.result.as_ref() {
15555        Ok(NodeResponse::SessionRecordUpdated { record })
15556        | Ok(NodeResponse::ProviderSessionIndexed { record })
15557        | Ok(NodeResponse::SessionRecordResumed { record, .. }) => {
15558            !provider_id_is_legacy(&record.provider)
15559        }
15560        Ok(NodeResponse::NativeSessionIndexed { selection, record }) => {
15561            !provider_id_is_legacy(&selection.route.provider)
15562                || !provider_id_is_legacy(&record.provider)
15563        }
15564        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
15565            !provider_id_is_legacy(&receipt.provider)
15566        }
15567        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15568            !provider_id_is_legacy(&receipt.spawn.provider)
15569        }
15570        Ok(NodeResponse::NativeSessionsCataloged { route, .. })
15571        | Ok(NodeResponse::NativeSessionsPaged { route, .. }) => {
15572            !provider_id_is_legacy(&route.provider)
15573        }
15574        Ok(NodeResponse::NativeSessionPreviewed { selection, .. }) => {
15575            !provider_id_is_legacy(&selection.route.provider)
15576        }
15577        _ => false,
15578    };
15579    if contains_open_record {
15580        reply.result = Err(failure(
15581            NodeFailureCode::UnsupportedCapability,
15582            "open provider IDs were not negotiated",
15583        ));
15584        return;
15585    }
15586    let Ok(response) = reply.result.as_mut() else {
15587        return;
15588    };
15589    match response {
15590        NodeResponse::Snapshot { snapshot, .. } => {
15591            project_snapshot_legacy_provider_ids(snapshot);
15592        }
15593        NodeResponse::Resync { snapshot, events, .. } => {
15594            project_snapshot_legacy_provider_ids(snapshot);
15595            *events = std::mem::take(events)
15596                .into_iter()
15597                .filter_map(|event| project_event_legacy_provider_ids(shared, event))
15598                .collect();
15599        }
15600        NodeResponse::WorkspaceRegistered { workspace }
15601        | NodeResponse::StandaloneWorkspaceCreated { workspace }
15602        | NodeResponse::WorktreeCreated { workspace, .. } => {
15603            workspace
15604                .sessions
15605                .retain(|session| provider_id_is_legacy(&session.agent_id));
15606        }
15607        NodeResponse::Armed { .. }
15608        | NodeResponse::Spawned { .. }
15609        | NodeResponse::Activated { .. }
15610        | NodeResponse::Aborted { .. }
15611        | NodeResponse::ReplyChunkAccepted { .. }
15612        | NodeResponse::CallRejected { .. }
15613        | NodeResponse::DeliveryStageBegun { .. }
15614        | NodeResponse::DeliveryBlobChunkAccepted { .. }
15615        | NodeResponse::DeliveryCommitted { .. }
15616        | NodeResponse::DeliveryStageAborted { .. }
15617        | NodeResponse::WorkspaceInspected { .. }
15618        | NodeResponse::HostDirectoriesBrowsed { .. }
15619        | NodeResponse::WorkspaceFileRead { .. }
15620        | NodeResponse::WorkspaceFileWritten { .. }
15621        | NodeResponse::WorkspaceFileCreated { .. }
15622        | NodeResponse::WorkspaceDirectoryCreated { .. }
15623        | NodeResponse::GitHistoryRead { .. }
15624        | NodeResponse::GitDiffRead { .. }
15625        | NodeResponse::Controller { .. }
15626        | NodeResponse::SpawnAccepted { .. }
15627        | NodeResponse::SpawnSpecAccepted { .. }
15628        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15629        | NodeResponse::ManagedWorktreeCleanup { .. }
15630        | NodeResponse::NativeSessionsCataloged { .. }
15631        | NodeResponse::NativeSessionsPaged { .. }
15632        | NodeResponse::NativeSessionPreviewed { .. }
15633        | NodeResponse::SessionRecordPreviewed { .. }
15634        | NodeResponse::HistoryDiscovered { .. }
15635        | NodeResponse::HistoryLoaded { .. }
15636        | NodeResponse::ContextPackForSessionRecordExported { .. }
15637        | NodeResponse::ContextPackExported { .. }
15638        | NodeResponse::ContextPackForgotten { .. }
15639        | NodeResponse::DurableContextPackResolved { .. }
15640        | NodeResponse::ContextPackBytesRead { .. }
15641        | NodeResponse::SessionRecordUpdated { .. }
15642        | NodeResponse::ProviderSessionIndexed { .. }
15643        | NodeResponse::NativeSessionIndexed { .. }
15644        | NodeResponse::SessionRecordResumed { .. }
15645        | NodeResponse::SessionRecordForgotten { .. }
15646        | NodeResponse::WorkspaceUnregistered { .. }
15647        | NodeResponse::WorktreeRemoved { .. }
15648        | NodeResponse::Accepted
15649        | NodeResponse::ShuttingDown => {}
15650    }
15651}
15652
15653fn project_event_legacy_provider_ids(
15654    shared: &NodeShared,
15655    mut envelope: NodeEventEnvelope,
15656) -> Option<NodeEventEnvelope> {
15657    let include = match &mut envelope.event {
15658        NodeEvent::HarnessMcpReadCall { .. } => true,
15659        NodeEvent::Control { address, .. }
15660        | NodeEvent::AgentStream { address, .. }
15661        | NodeEvent::TerminalFrame { address, .. } => shared
15662            .validate_address(address)
15663            .is_ok_and(|provider| provider_id_is_legacy(&provider)),
15664        NodeEvent::WorkspaceAdded { workspace } => {
15665            workspace
15666                .sessions
15667                .retain(|session| provider_id_is_legacy(&session.agent_id));
15668            true
15669        }
15670        NodeEvent::SessionRecordUpserted { record } => {
15671            provider_id_is_legacy(&record.provider)
15672        }
15673        NodeEvent::SessionRecordHistorySummarized { .. } => false,
15674        NodeEvent::SessionRecordRemoved { .. } => shared
15675            .history
15676            .lock()
15677            .unwrap_or_else(|poisoned| poisoned.into_inner())
15678            .removed_record_providers
15679            .get(&envelope.sequence)
15680            .is_some_and(provider_id_is_legacy),
15681        NodeEvent::ControllerChanged { .. }
15682        | NodeEvent::ManagedWorktreeUpserted { .. }
15683        | NodeEvent::ManagedWorktreeRemoved { .. }
15684        | NodeEvent::WorkspaceRemoved { .. }
15685        | NodeEvent::ResyncRequired { .. } => true,
15686    };
15687    include.then_some(envelope)
15688}
15689
15690fn clear_response_provider_runtime_status(reply: &mut ResponseEnvelope) {
15691    let Ok(response) = reply.result.as_mut() else {
15692        return;
15693    };
15694    match response {
15695        NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
15696            snapshot.provider_runtime_statuses.clear();
15697        }
15698        NodeResponse::Armed { .. }
15699        | NodeResponse::Spawned { .. }
15700        | NodeResponse::Activated { .. }
15701        | NodeResponse::Aborted { .. }
15702        | NodeResponse::ReplyChunkAccepted { .. }
15703        | NodeResponse::CallRejected { .. }
15704        | NodeResponse::DeliveryStageBegun { .. }
15705        | NodeResponse::DeliveryBlobChunkAccepted { .. }
15706        | NodeResponse::DeliveryCommitted { .. }
15707        | NodeResponse::DeliveryStageAborted { .. }
15708        | NodeResponse::WorkspaceInspected { .. }
15709        | NodeResponse::HostDirectoriesBrowsed { .. }
15710        | NodeResponse::WorkspaceFileRead { .. }
15711        | NodeResponse::WorkspaceFileWritten { .. }
15712        | NodeResponse::WorkspaceFileCreated { .. }
15713        | NodeResponse::WorkspaceDirectoryCreated { .. }
15714        | NodeResponse::GitHistoryRead { .. }
15715        | NodeResponse::GitDiffRead { .. }
15716        | NodeResponse::Controller { .. }
15717        | NodeResponse::SpawnAccepted { .. }
15718        | NodeResponse::SpawnSpecAccepted { .. }
15719        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15720        | NodeResponse::ManagedWorktreeCleanup { .. }
15721        | NodeResponse::NativeSessionsCataloged { .. }
15722        | NodeResponse::NativeSessionsPaged { .. }
15723        | NodeResponse::NativeSessionPreviewed { .. }
15724        | NodeResponse::SessionRecordPreviewed { .. }
15725        | NodeResponse::HistoryDiscovered { .. }
15726        | NodeResponse::HistoryLoaded { .. }
15727        | NodeResponse::ContextPackForSessionRecordExported { .. }
15728        | NodeResponse::ContextPackExported { .. }
15729        | NodeResponse::ContextPackForgotten { .. }
15730        | NodeResponse::DurableContextPackResolved { .. }
15731        | NodeResponse::ContextPackBytesRead { .. }
15732        | NodeResponse::SessionRecordUpdated { .. }
15733        | NodeResponse::ProviderSessionIndexed { .. }
15734        | NodeResponse::NativeSessionIndexed { .. }
15735        | NodeResponse::SessionRecordResumed { .. }
15736        | NodeResponse::SessionRecordForgotten { .. }
15737        | NodeResponse::WorkspaceRegistered { .. }
15738        | NodeResponse::StandaloneWorkspaceCreated { .. }
15739        | NodeResponse::WorkspaceUnregistered { .. }
15740        | NodeResponse::WorktreeCreated { .. }
15741        | NodeResponse::WorktreeRemoved { .. }
15742        | NodeResponse::Accepted
15743        | NodeResponse::ShuttingDown => {}
15744    }
15745}
15746
15747struct ControllerReleaseGuard {
15748    shared: Arc<NodeShared>,
15749    connection_id: u64,
15750}
15751
15752impl Drop for ControllerReleaseGuard {
15753    fn drop(&mut self) {
15754        self.shared.release_controller(self.connection_id);
15755    }
15756}
15757
15758struct AbortTaskOnDrop(AbortHandle);
15759
15760impl Drop for AbortTaskOnDrop {
15761    fn drop(&mut self) {
15762        self.0.abort();
15763    }
15764}
15765
15766/// Reads an unsigned env-config override, following the pattern of every
15767/// other bounded knob in this module: missing/unparsable/zero falls back to
15768/// `default`, and the resolved value is always clamped to `max` so a bad
15769/// override cannot reopen a bound this budget exists to enforce.
15770fn env_config_bounded(name: &str, default: u64, max: u64) -> u64 {
15771    std::env::var(name)
15772        .ok()
15773        .and_then(|value| value.parse::<u64>().ok())
15774        .filter(|value| *value > 0)
15775        .unwrap_or(default)
15776        .min(max)
15777}
15778
15779fn workspace_inspection_time_budget_ms() -> u64 {
15780    env_config_bounded(
15781        "GATE4AGENT_NODE_WORKSPACE_INSPECTION_BUDGET_MS",
15782        WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT,
15783        WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX,
15784    )
15785}
15786
15787fn workspace_inspection_entry_cap() -> usize {
15788    env_config_bounded(
15789        "GATE4AGENT_NODE_WORKSPACE_INSPECTION_ENTRY_CAP",
15790        WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT as u64,
15791        WORKSPACE_INSPECTION_ENTRY_CAP_MAX as u64,
15792    ) as usize
15793}
15794
15795/// Tracks the shared walk-side budget across the whole recursive walk: a
15796/// wall-clock deadline (shared with the git phase that runs after the walk)
15797/// and a cap on directory entries visited, whether or not they were pushed
15798/// to the response. Cheap to check per entry — one `Instant::now()` and one
15799/// integer compare.
15800struct WorkspaceWalkBudget {
15801    deadline: Instant,
15802    entry_cap: usize,
15803    entries_visited: u64,
15804    time_budget_exceeded: bool,
15805    entry_cap_exceeded: bool,
15806}
15807
15808impl WorkspaceWalkBudget {
15809    fn new(deadline: Instant, entry_cap: usize) -> Self {
15810        Self {
15811            deadline,
15812            entry_cap,
15813            entries_visited: 0,
15814            time_budget_exceeded: false,
15815            entry_cap_exceeded: false,
15816        }
15817    }
15818
15819    fn is_exhausted(&self) -> bool {
15820        self.time_budget_exceeded || self.entry_cap_exceeded
15821    }
15822
15823    /// Records one visited directory entry (pushed to the response or not)
15824    /// and reports whether the walk must stop now.
15825    fn record_visit(&mut self) -> bool {
15826        self.entries_visited += 1;
15827        if self.entries_visited as usize >= self.entry_cap {
15828            self.entry_cap_exceeded = true;
15829        }
15830        if Instant::now() >= self.deadline {
15831            self.time_budget_exceeded = true;
15832        }
15833        self.is_exhausted()
15834    }
15835}
15836
15837fn collect_workspace_entries(
15838    root: &Path,
15839    deadline: Instant,
15840    entry_cap: usize,
15841) -> (Vec<WorkspaceEntry>, bool, WorkspaceWalkBudget) {
15842    let mut entries = Vec::new();
15843    let mut truncated = false;
15844    let mut budget = WorkspaceWalkBudget::new(deadline, entry_cap);
15845    walk_workspace_directory(root, None, 0, &mut entries, &mut truncated, &mut budget);
15846    // DFS pre-order emits `x/child` between prefix-named siblings `x` and
15847    // `x-y` ('-' sorts below '/'), while the bounded operator projections
15848    // require strict global byte order over relative paths.
15849    entries.sort_by(|left, right| left.relative_path.as_utf8().cmp(&right.relative_path.as_utf8()));
15850    (entries, truncated, budget)
15851}
15852
15853/// Builds the additive `WorkspaceInspectionTruncationV1` for an
15854/// `inspect_workspace` response — `None` once the walk and git phase both
15855/// completed inside their shared budget.
15856fn workspace_inspection_truncation(
15857    walk_budget: &WorkspaceWalkBudget,
15858    git_time_budget_exceeded: bool,
15859    elapsed: Duration,
15860) -> Option<WorkspaceInspectionTruncationV1> {
15861    if !walk_budget.time_budget_exceeded
15862        && !walk_budget.entry_cap_exceeded
15863        && !git_time_budget_exceeded
15864    {
15865        return None;
15866    }
15867    Some(WorkspaceInspectionTruncationV1 {
15868        walk_time_budget_exceeded: walk_budget.time_budget_exceeded,
15869        walk_entry_cap_exceeded: walk_budget.entry_cap_exceeded,
15870        git_time_budget_exceeded,
15871        entries_visited: walk_budget.entries_visited,
15872        elapsed_ms: u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX),
15873    })
15874}
15875
15876fn walk_workspace_directory(
15877    directory: &Path,
15878    relative_directory: Option<&RepositoryPath>,
15879    depth: usize,
15880    entries: &mut Vec<WorkspaceEntry>,
15881    truncated: &mut bool,
15882    budget: &mut WorkspaceWalkBudget,
15883) {
15884    if budget.is_exhausted() {
15885        *truncated = true;
15886        return;
15887    }
15888    let read_dir = match std::fs::read_dir(directory) {
15889        Ok(read_dir) => read_dir,
15890        Err(_) => {
15891            *truncated = true;
15892            return;
15893        }
15894    };
15895    let mut children = Vec::new();
15896    for child in read_dir {
15897        match child {
15898            Ok(child) => children.push(child),
15899            Err(_) => *truncated = true,
15900        }
15901    }
15902    children.sort_by_key(|child| child.file_name());
15903    for child in children {
15904        if budget.record_visit() {
15905            *truncated = true;
15906            return;
15907        }
15908        if entries.len() >= WORKSPACE_TREE_MAX_ENTRIES {
15909            *truncated = true;
15910            return;
15911        }
15912        let file_type = match child.file_type() {
15913            Ok(file_type) => file_type,
15914            Err(_) => {
15915                *truncated = true;
15916                continue;
15917            }
15918        };
15919        if file_type.is_symlink() {
15920            continue;
15921        }
15922        let name = match child.file_name().into_string() {
15923            Ok(name) => name,
15924            Err(_) => {
15925                *truncated = true;
15926                continue;
15927            }
15928        };
15929        if file_type.is_dir() && is_skipped_workspace_directory(&name, &child.path()) {
15930            continue;
15931        }
15932        let relative_path = match relative_directory {
15933            None => windows_repository_path(name),
15934            Some(parent) => parent
15935                .as_utf8()
15936                .and_then(|parent| windows_repository_path(format!("{parent}/{name}"))),
15937        };
15938        let Some(relative_path) = relative_path else {
15939            *truncated = true;
15940            continue;
15941        };
15942        let kind = if file_type.is_dir() {
15943            WorkspaceEntryKind::Directory
15944        } else if file_type.is_file() {
15945            WorkspaceEntryKind::File
15946        } else {
15947            continue;
15948        };
15949        entries.push(WorkspaceEntry {
15950            relative_path: relative_path.clone(),
15951            kind,
15952        });
15953        if file_type.is_dir() {
15954            if depth + 1 < WORKSPACE_TREE_MAX_DEPTH {
15955                walk_workspace_directory(
15956                    &child.path(),
15957                    Some(&relative_path),
15958                    depth + 1,
15959                    entries,
15960                    truncated,
15961                    budget,
15962                );
15963            } else {
15964                *truncated = true;
15965            }
15966        }
15967    }
15968}
15969
15970fn windows_repository_path(value: String) -> Option<RepositoryPath> {
15971    if value.contains('\\') {
15972        return None;
15973    }
15974    RepositoryPath::utf8(value).ok()
15975}
15976
15977fn windows_repository_path_from_bytes(value: &[u8]) -> Option<RepositoryPath> {
15978    windows_repository_path(std::str::from_utf8(value).ok()?.to_owned())
15979}
15980
15981/// Excludes a directory from the workspace tree walk. The three literal
15982/// names are the common convention-based cases; the `CACHEDIR.TAG` check
15983/// beneath them catches every build-output directory regardless of name
15984/// (cargo writes `CACHEDIR.TAG` into every `--target-dir` it creates, so
15985/// this covers `target-a2`, `target-a2-tui`, and any future renamed build
15986/// directory without maintaining a name heuristic list). The name check
15987/// runs first and returns early so a directory rejected by name never pays
15988/// for the extra `CACHEDIR.TAG` stat.
15989fn is_skipped_workspace_directory(name: &str, path: &Path) -> bool {
15990    if name.eq_ignore_ascii_case(".git")
15991        || name.eq_ignore_ascii_case("target")
15992        || name.eq_ignore_ascii_case("node_modules")
15993    {
15994        return true;
15995    }
15996    path.join("CACHEDIR.TAG").is_file()
15997}
15998
15999fn context_pack_source_status_is_usable(status: &SessionStatus) -> bool {
16000    matches!(
16001        status,
16002        SessionStatus::Running | SessionStatus::Exited { exit_code: Some(0) }
16003    )
16004}
16005
16006#[derive(Clone, Debug, Eq, PartialEq)]
16007enum ContextPackRepositoryHead {
16008    NotRepository,
16009    Unborn,
16010    Commit(String),
16011}
16012
16013#[derive(Clone, Debug, Eq, PartialEq)]
16014struct ContextPackRepositoryObservation {
16015    head: ContextPackRepositoryHead,
16016    git: GitSnapshot,
16017    files: Vec<ContextPackRepositoryFileSource>,
16018}
16019
16020fn stable_context_pack_repository(
16021    first: ContextPackRepositoryObservation,
16022    second: ContextPackRepositoryObservation,
16023) -> Result<ContextPackRepository, NodeFailure> {
16024    if first != second {
16025        return Err(failure(
16026            NodeFailureCode::ContextPackMaterializationFailed,
16027            "context repository changed during bounded capture",
16028        ));
16029    }
16030    Ok(ContextPackRepository::from_sources(
16031        &second.git,
16032        second.files,
16033    ))
16034}
16035
16036/// Appends a "skipped: budget exhausted" diagnostic and returns `true` once
16037/// (and only once) the shared walk+git `deadline` has already passed. Every
16038/// remaining phase in `inspect_git_workspace` checks this immediately
16039/// before issuing its own git call, so a budget spent by the walk (or by an
16040/// earlier git call) skips the rest of the chain instead of paying for it
16041/// anyway.
16042fn git_budget_exhausted(snapshot: &mut GitSnapshot, deadline: Instant, stage: &str) -> bool {
16043    if Instant::now() < deadline {
16044        return false;
16045    }
16046    append_git_diagnostic(
16047        snapshot,
16048        &format!("{stage} skipped: workspace inspection time budget exhausted"),
16049    );
16050    true
16051}
16052
16053/// Deadline-aware sibling of `run_git_bounded`, used only by
16054/// `inspect_git_workspace`'s six-call chain: caps the per-call timeout to
16055/// whatever remains of the shared `deadline` (never more than
16056/// `GIT_COMMAND_TIMEOUT_MS`), so a slow walk phase that already spent most
16057/// of the budget cannot still hand a full per-call timeout to every git
16058/// probe behind it. `inspect_git_workspace` already guards each call site
16059/// with `git_budget_exhausted`, so `deadline` is guaranteed not to have
16060/// passed yet here.
16061async fn run_git_bounded_with_deadline(
16062    root: &str,
16063    arguments: &[&str],
16064    output_limit: usize,
16065    deadline: Instant,
16066) -> io::Result<GitCommandOutput> {
16067    let remaining = deadline.checked_duration_since(Instant::now());
16068    let timeout_ms = remaining
16069        .and_then(|remaining| u64::try_from(remaining.as_millis()).ok())
16070        .unwrap_or(0)
16071        .clamp(1, GIT_COMMAND_TIMEOUT_MS);
16072    run_git_read_bounded(root, arguments, output_limit, timeout_ms).await
16073}
16074
16075/// Runs the bounded six-call git probe chain used by `inspect_workspace`.
16076/// `deadline` is the same absolute instant the preceding directory walk was
16077/// bounded by, so time already spent walking counts against what remains
16078/// for git enrichment. Returns the resulting `GitSnapshot` plus whether the
16079/// shared budget (not an individual per-call timeout) is what cut the git
16080/// phase short.
16081async fn inspect_git_workspace(root: &str, deadline: Instant) -> (GitSnapshot, bool) {
16082    let mut snapshot = GitSnapshot {
16083        is_repository: false,
16084        branch: None,
16085        status: Vec::new(),
16086        recent_commits: Vec::new(),
16087        worktrees: Vec::new(),
16088        managed_worktree: None,
16089        truncated: false,
16090        diagnostic: None,
16091    };
16092    if git_budget_exhausted(&mut snapshot, deadline, "git repository probe") {
16093        return (snapshot, true);
16094    }
16095    let repository = match run_git_bounded_with_deadline(
16096        root,
16097        &["rev-parse", "--is-inside-work-tree"],
16098        4 * 1_024,
16099        deadline,
16100    )
16101    .await
16102    {
16103        Ok(output) => output,
16104        Err(error) => {
16105            append_git_diagnostic(
16106                &mut snapshot,
16107                &format!("git inspection unavailable: {error}"),
16108            );
16109            return (snapshot, false);
16110        }
16111    };
16112    snapshot.truncated |= repository.truncated;
16113    if repository.timed_out {
16114        append_git_diagnostic(&mut snapshot, "git repository probe timed out");
16115        return (snapshot, false);
16116    }
16117    if !repository.success
16118        || String::from_utf8_lossy(&repository.stdout).trim() != "true"
16119    {
16120        let stderr = String::from_utf8_lossy(&repository.stderr);
16121        if !stderr.to_ascii_lowercase().contains("not a git repository") {
16122            append_git_diagnostic(&mut snapshot, stderr.trim());
16123        }
16124        return (snapshot, false);
16125    }
16126    snapshot.is_repository = true;
16127
16128    if git_budget_exhausted(&mut snapshot, deadline, "git branch query") {
16129        return (snapshot, true);
16130    }
16131    match run_git_bounded_with_deadline(root, &["branch", "--show-current"], 4 * 1_024, deadline).await {
16132        Ok(output) => {
16133            snapshot.truncated |= output.truncated;
16134            if output.timed_out {
16135                append_git_diagnostic(&mut snapshot, "git branch query timed out");
16136            } else if output.success {
16137                let branch = String::from_utf8_lossy(&output.stdout).trim().to_owned();
16138                if !branch.is_empty() {
16139                    snapshot.branch = Some(truncate_git_field(
16140                        &branch,
16141                        MAX_REPOSITORY_PATH_BYTES,
16142                        &mut snapshot.truncated,
16143                    ));
16144                }
16145            }
16146        }
16147        Err(error) => append_git_diagnostic(
16148            &mut snapshot,
16149            &format!("git branch query failed: {error}"),
16150        ),
16151    }
16152    if snapshot.branch.is_none() && !git_budget_exhausted(&mut snapshot, deadline, "git detached-HEAD probe") {
16153        if let Ok(output) = run_git_bounded_with_deadline(root, &["rev-parse", "--short", "HEAD"], 4 * 1_024, deadline).await {
16154            snapshot.truncated |= output.truncated;
16155            if output.success && !output.timed_out {
16156                let head = String::from_utf8_lossy(&output.stdout).trim().to_owned();
16157                if !head.is_empty() {
16158                    snapshot.branch = Some(format!("detached:{head}"));
16159                }
16160            }
16161        }
16162    }
16163
16164    if git_budget_exhausted(&mut snapshot, deadline, "git status") {
16165        return (snapshot, true);
16166    }
16167    match run_git_bounded_with_deadline(
16168        root,
16169        &[
16170            "status",
16171            "--porcelain=v1",
16172            "-z",
16173            "--renames",
16174            "--untracked-files=normal",
16175            "--",
16176            ".",
16177        ],
16178        GIT_OUTPUT_MAX_BYTES,
16179        deadline,
16180    )
16181    .await
16182    {
16183        Ok(output) => {
16184            snapshot.truncated |= output.truncated;
16185            if output.timed_out {
16186                append_git_diagnostic(&mut snapshot, "git status timed out");
16187            } else if output.success {
16188                parse_git_status(&output.stdout, &mut snapshot);
16189            } else {
16190                append_git_diagnostic(
16191                    &mut snapshot,
16192                    String::from_utf8_lossy(&output.stderr).trim(),
16193                );
16194            }
16195        }
16196        Err(error) => append_git_diagnostic(
16197            &mut snapshot,
16198            &format!("git status failed: {error}"),
16199        ),
16200    }
16201
16202    if git_budget_exhausted(&mut snapshot, deadline, "git log") {
16203        return (snapshot, true);
16204    }
16205    let log_limit = (GIT_COMMIT_MAX_ENTRIES + 1).to_string();
16206    match run_git_bounded_with_deadline(
16207        root,
16208        &["log", "-n", &log_limit, "--pretty=format:%H%x1f%s", "--", "."],
16209        16 * 1_024,
16210        deadline,
16211    )
16212    .await
16213    {
16214        Ok(output) => {
16215            snapshot.truncated |= output.truncated;
16216            if output.timed_out {
16217                append_git_diagnostic(&mut snapshot, "git log timed out");
16218            } else if output.success {
16219                parse_git_commits(&output.stdout, &mut snapshot);
16220            } else {
16221                let stderr = String::from_utf8_lossy(&output.stderr);
16222                if !stderr.to_ascii_lowercase().contains("does not have any commits") {
16223                    append_git_diagnostic(&mut snapshot, stderr.trim());
16224                }
16225            }
16226        }
16227        Err(error) => append_git_diagnostic(
16228            &mut snapshot,
16229            &format!("git log failed: {error}"),
16230        ),
16231    }
16232
16233    if git_budget_exhausted(&mut snapshot, deadline, "git worktree list") {
16234        return (snapshot, true);
16235    }
16236    match list_git_worktrees_with_deadline(root, deadline).await {
16237        Ok(worktrees) => {
16238            snapshot.worktrees = worktrees.into_iter().map(protocol_worktree).collect()
16239        }
16240        Err(error) => append_git_diagnostic(
16241            &mut snapshot,
16242            &format!("git worktree list failed: {}", error.message),
16243        ),
16244    }
16245    (snapshot, false)
16246}
16247
16248fn parse_git_status(output: &[u8], snapshot: &mut GitSnapshot) {
16249    let mut remaining = output;
16250    while !remaining.is_empty() {
16251        if snapshot.status.len() >= GIT_STATUS_MAX_ENTRIES {
16252            snapshot.truncated = true;
16253            break;
16254        }
16255        let Some(record_end) = remaining.iter().position(|byte| *byte == 0) else {
16256            snapshot.truncated = true;
16257            break;
16258        };
16259        let record = &remaining[..record_end];
16260        remaining = &remaining[record_end + 1..];
16261        let has_previous_path = record
16262            .get(..2)
16263            .is_some_and(|status| status.iter().any(|byte| matches!(*byte, b'R' | b'C')));
16264        let previous_path_bytes = if has_previous_path {
16265            let Some(previous_end) = remaining.iter().position(|byte| *byte == 0) else {
16266                snapshot.truncated = true;
16267                break;
16268            };
16269            let previous = &remaining[..previous_end];
16270            remaining = &remaining[previous_end + 1..];
16271            Some(previous)
16272        } else {
16273            None
16274        };
16275        if record.len() < 4
16276            || record[2] != b' '
16277            || !is_git_status_code(record[0])
16278            || !is_git_status_code(record[1])
16279        {
16280            snapshot.truncated = true;
16281            continue;
16282        }
16283        let Some(path) = windows_repository_path_from_bytes(&record[3..]) else {
16284            snapshot.truncated = true;
16285            continue;
16286        };
16287        let previous_path = match previous_path_bytes {
16288            Some(previous) => {
16289                let Some(previous) = windows_repository_path_from_bytes(previous) else {
16290                    snapshot.truncated = true;
16291                    continue;
16292                };
16293                Some(previous)
16294            }
16295            None => None,
16296        };
16297        snapshot.status.push(GitStatusEntry {
16298            index_status: (record[0] as char).to_string(),
16299            worktree_status: (record[1] as char).to_string(),
16300            path,
16301            previous_path,
16302        });
16303    }
16304}
16305
16306fn is_git_status_code(byte: u8) -> bool {
16307    matches!(byte, b' ' | b'M' | b'T' | b'A' | b'D' | b'R' | b'C' | b'U' | b'?' | b'!')
16308}
16309
16310fn parse_git_commits(output: &[u8], snapshot: &mut GitSnapshot) {
16311    for line in String::from_utf8_lossy(output).lines() {
16312        let Some((id, summary)) = line.split_once('\u{1f}') else {
16313            continue;
16314        };
16315        if snapshot.recent_commits.len() >= GIT_COMMIT_MAX_ENTRIES {
16316            snapshot.truncated = true;
16317            break;
16318        }
16319        let id = truncate_git_field(id, 64, &mut snapshot.truncated);
16320        let summary = truncate_git_field(summary, 512, &mut snapshot.truncated);
16321        snapshot.recent_commits.push(GitCommitSummary { id, summary });
16322    }
16323}
16324
16325fn truncate_git_field(value: &str, max_bytes: usize, truncated: &mut bool) -> String {
16326    if value.len() <= max_bytes {
16327        return value.to_owned();
16328    }
16329    *truncated = true;
16330    let mut boundary = max_bytes;
16331    while boundary > 0 && !value.is_char_boundary(boundary) {
16332        boundary -= 1;
16333    }
16334    value[..boundary].to_owned()
16335}
16336
16337fn append_git_diagnostic(snapshot: &mut GitSnapshot, message: &str) {
16338    if message.is_empty() {
16339        return;
16340    }
16341    let normalized = message
16342        .chars()
16343        .map(|character| if character.is_control() { ' ' } else { character })
16344        .collect::<String>();
16345    let mut diagnostic = snapshot.diagnostic.take().unwrap_or_default();
16346    if !diagnostic.is_empty() {
16347        diagnostic.push_str("; ");
16348    }
16349    diagnostic.push_str(normalized.trim());
16350    snapshot.diagnostic = Some(truncate_git_field(
16351        &diagnostic,
16352        GIT_DIAGNOSTIC_MAX_BYTES,
16353        &mut snapshot.truncated,
16354    ));
16355}
16356
16357async fn run_git_bounded(
16358    root: &str,
16359    arguments: &[&str],
16360    output_limit: usize,
16361) -> io::Result<GitCommandOutput> {
16362    run_git_read_bounded(root, arguments, output_limit, GIT_COMMAND_TIMEOUT_MS).await
16363}
16364
16365async fn read_git_history_bounded(
16366    root: &str,
16367    path: Option<&RepositoryPath>,
16368    before: Option<&GitObjectId>,
16369    limit: u16,
16370) -> Result<GitHistoryPage, NodeFailure> {
16371    let path = path
16372        .map(|path| {
16373            path.as_utf8().ok_or_else(|| {
16374                failure(
16375                    NodeFailureCode::InvalidRepositoryPath,
16376                    "git history path must use UTF-8",
16377                )
16378            })
16379        })
16380        .transpose()?
16381        .unwrap_or(".");
16382    let count = (usize::from(limit) + 1).to_string();
16383    let start = before
16384        .map(|revision| format!("{}^", revision.as_str()))
16385        .unwrap_or_else(|| "HEAD".to_owned());
16386    let arguments = [
16387        "--no-pager",
16388        "log",
16389        "--no-decorate",
16390        "--date=iso-strict",
16391        "--pretty=format:%H%x1f%P%x1f%s%x1f%an%x1f%ae%x1f%aI%x1f%cn%x1f%ce%x1f%cI%x1e",
16392        "-n",
16393        count.as_str(),
16394        start.as_str(),
16395        "--",
16396        path,
16397    ];
16398    let output = run_git_bounded(root, &arguments, 256 * 1_024)
16399        .await
16400        .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git history command failed"))?;
16401    if output.timed_out {
16402        return Err(failure(NodeFailureCode::GitReadTimedOut, "git history exceeded its bounded deadline"));
16403    }
16404    if !output.success {
16405        return Err(failure(NodeFailureCode::GitReadFailed, "git history could not be read"));
16406    }
16407    let mut commits = parse_git_history(&output.stdout);
16408    let has_more = commits.len() > usize::from(limit) || output.truncated;
16409    commits.truncate(usize::from(limit));
16410    let next_before = has_more.then(|| commits.last().map(|commit| commit.id.clone())).flatten();
16411    Ok(GitHistoryPage { commits, next_before, truncated: output.truncated })
16412}
16413
16414fn parse_git_history(output: &[u8]) -> Vec<GitCommitDetails> {
16415    String::from_utf8_lossy(output)
16416        .split('\u{1e}')
16417        .filter_map(|record| {
16418            let fields = record.trim_matches(['\r', '\n']).split('\u{1f}').collect::<Vec<_>>();
16419            if fields.len() != 9 {
16420                return None;
16421            }
16422            let id = GitObjectId::new(fields[0].to_owned()).ok()?;
16423            let parents = if fields[1].is_empty() {
16424                Vec::new()
16425            } else {
16426                fields[1]
16427                    .split(' ')
16428                    .map(|value| GitObjectId::new(value.to_owned()))
16429                    .collect::<Result<Vec<_>, _>>()
16430                    .ok()?
16431            };
16432            Some(GitCommitDetails {
16433                id,
16434                parents,
16435                subject: bounded_git_text(fields[2], 512),
16436                author_name: bounded_git_text(fields[3], 256),
16437                author_email: bounded_git_text(fields[4], 320),
16438                authored_at: bounded_git_text(fields[5], 64),
16439                committer_name: bounded_git_text(fields[6], 256),
16440                committer_email: bounded_git_text(fields[7], 320),
16441                committed_at: bounded_git_text(fields[8], 64),
16442                signature_status: GitSignatureStatus::NoSignature,
16443                signer: None,
16444            })
16445        })
16446        .take(usize::from(MAX_GIT_HISTORY_COMMITS) + 1)
16447        .collect()
16448}
16449
16450fn bounded_git_text(value: &str, max_bytes: usize) -> String {
16451    let normalized = value
16452        .chars()
16453        .map(|character| if character.is_control() { ' ' } else { character })
16454        .collect::<String>();
16455    if normalized.len() <= max_bytes {
16456        return normalized;
16457    }
16458    let mut boundary = max_bytes;
16459    while boundary > 0 && !normalized.is_char_boundary(boundary) {
16460        boundary -= 1;
16461    }
16462    normalized[..boundary].to_owned()
16463}
16464
16465async fn read_git_diff_bounded(root: &str, request: GitDiffRequest) -> Result<GitDiff, NodeFailure> {
16466    let path = request.path.as_ref().map(|path| {
16467        path.as_utf8()
16468            .ok_or_else(|| failure(NodeFailureCode::InvalidRepositoryPath, "git diff path must use UTF-8"))
16469    }).transpose()?;
16470    let mut arguments = match &request.mode {
16471        GitDiffMode::Working => vec!["--no-pager", "-c", "diff.external=", "diff", "--no-ext-diff", "--no-textconv"],
16472        GitDiffMode::Staged => vec!["--no-pager", "-c", "diff.external=", "diff", "--cached", "--no-ext-diff", "--no-textconv"],
16473        GitDiffMode::Commit { .. } => vec!["--no-pager", "-c", "diff.external=", "show", "--format=fuller", "--no-ext-diff", "--no-textconv"],
16474    };
16475    if let GitDiffMode::Commit { revision } = &request.mode {
16476        arguments.push(revision.as_str());
16477    }
16478    arguments.push("--");
16479    if let Some(path) = path {
16480        arguments.push(path);
16481    }
16482    let output = run_git_bounded(root, &arguments, MAX_GIT_DIFF_BYTES)
16483        .await
16484        .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git diff command failed"))?;
16485    if output.timed_out {
16486        return Err(failure(NodeFailureCode::GitReadTimedOut, "git diff exceeded its bounded deadline"));
16487    }
16488    if !output.success {
16489        return Err(failure(NodeFailureCode::GitReadFailed, "git diff could not be read"));
16490    }
16491    Ok(GitDiff {
16492        mode: request.mode,
16493        path: request.path,
16494        text: String::from_utf8_lossy(&output.stdout).into_owned(),
16495        truncated: output.truncated,
16496    })
16497}
16498
16499async fn process_request(shared: &NodeShared, connection_id: u64, role: ClientRole, envelope: RequestEnvelope) -> ResponseEnvelope {
16500    let result = process_request_inner(shared, connection_id, role, envelope.request).await;
16501    ResponseEnvelope { request_id: envelope.request_id, result }
16502}
16503
16504async fn process_request_inner(shared: &NodeShared, connection_id: u64, role: ClientRole, request: NodeRequest) -> Result<NodeResponse, NodeFailure> {
16505    if !request.harness_mcp_contract_is_valid_at(unix_time_ms()) {
16506        return Err(failure(NodeFailureCode::InvalidRequest, "harness MCP request is invalid or stale"));
16507    }
16508    let read_only = matches!(
16509        &request,
16510        NodeRequest::Snapshot
16511            | NodeRequest::Resync { .. }
16512            | NodeRequest::BrowseHostDirectories { .. }
16513            | NodeRequest::InspectWorkspace { .. }
16514            | NodeRequest::ReadWorkspaceFile { .. }
16515            | NodeRequest::ReadGitHistory { .. }
16516            | NodeRequest::ReadGitDiff { .. }
16517            | NodeRequest::CatalogNativeSessions { .. }
16518            | NodeRequest::PageNativeSessions { .. }
16519            | NodeRequest::PreviewNativeSession { .. }
16520            | NodeRequest::PreviewSessionRecord { .. }
16521    );
16522    let _mutation_guard = if read_only {
16523        None
16524    } else {
16525        let guard = shared.mutation_gate.lock().await;
16526        if shared.shutdown.load(Ordering::Acquire) {
16527            return Err(failure(NodeFailureCode::ShuttingDown, "node shutdown has begun"));
16528        }
16529        Some(guard)
16530    };
16531    match request {
16532        NodeRequest::Snapshot => Ok(NodeResponse::Snapshot {
16533            event_sequence: shared.current_sequence(),
16534            controller: shared.controller_state(),
16535            snapshot: shared.snapshot(),
16536        }),
16537        NodeRequest::Resync { after_sequence } => Ok(shared.resync(after_sequence)),
16538        NodeRequest::ArmHarnessMcpReservation {
16539            reservation_id,
16540            activation_digest,
16541            spawn_spec,
16542            launch,
16543            expires_at_unix_ms,
16544        } => {
16545            shared.require_controller(connection_id, role)?;
16546            let registry = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16547                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16548            })?;
16549            let expires_at_unix_ms = registry
16550                .arm(reservation_id.clone(), activation_digest.clone(), spawn_spec, launch, expires_at_unix_ms)
16551                .await
16552                .map_err(harness_mcp_failure)?;
16553            Ok(NodeResponse::Armed { reservation_id, activation_digest, expires_at_unix_ms })
16554        }
16555        NodeRequest::SpawnSpecWithHarnessMcp {
16556            reservation_id,
16557            activation_digest,
16558            spec,
16559            deadline_unix_ms,
16560        } => {
16561            shared.require_controller(connection_id, role)?;
16562            let receipt = shared
16563                .spawn_from_spec_with_harness_mcp(
16564                    reservation_id.clone(), activation_digest.clone(), spec, deadline_unix_ms,
16565                )
16566                .await?;
16567            Ok(NodeResponse::Spawned { reservation_id, activation_digest, receipt })
16568        }
16569        NodeRequest::ActivateHarnessMcpReservation {
16570            reservation_id,
16571            activation_digest,
16572            record_id,
16573            session,
16574        } => {
16575            shared.require_controller(connection_id, role)?;
16576            let provider_root_pid = shared.handle.snapshot().sessions.iter().find(|current| {
16577                current.instance_id == session.session.instance_id
16578                    && current.generation == session.session.generation
16579            }).and_then(|current| current.process_id).ok_or_else(|| {
16580                failure(NodeFailureCode::BindingMismatch, "provider root process is unavailable")
16581            })?;
16582            shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16583                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16584            })?.activate(
16585                &reservation_id, &activation_digest, &record_id, &session, provider_root_pid,
16586            ).map_err(harness_mcp_failure)?;
16587            Ok(NodeResponse::Activated { reservation_id, activation_digest, record_id, session })
16588        }
16589        NodeRequest::AbortHarnessMcpReservation { reservation_id, activation_digest } => {
16590            shared.require_controller(connection_id, role)?;
16591            let instance_id = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16592                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16593            })?.abort(&reservation_id, &activation_digest).map_err(harness_mcp_failure)?;
16594            if let Some(instance_id) = instance_id {
16595                SessionLifecycleHook::on_harness_reservation_aborted(&*shared, instance_id);
16596            }
16597            Ok(NodeResponse::Aborted { reservation_id, activation_digest })
16598        }
16599        NodeRequest::PutHarnessMcpReplyChunk {
16600            reservation_id, activation_digest, record_id, session, call_id,
16601            offset, final_chunk, chunk_hex,
16602        } => {
16603            shared.require_controller(connection_id, role)?;
16604            let (next_offset, completed) = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16605                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16606            })?.put_reply_chunk(
16607                &reservation_id, &activation_digest, &record_id, &session, &call_id,
16608                offset, final_chunk, &chunk_hex,
16609            ).map_err(harness_mcp_failure)?;
16610            Ok(NodeResponse::ReplyChunkAccepted {
16611                reservation_id, activation_digest, record_id, session, call_id,
16612                next_offset, completed,
16613            })
16614        }
16615        NodeRequest::RejectHarnessMcpCall {
16616            reservation_id, activation_digest, record_id, session, call_id, reason,
16617        } => {
16618            shared.require_controller(connection_id, role)?;
16619            shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16620                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16621            })?.reject_call(
16622                &reservation_id, &activation_digest, &record_id, &session, &call_id, reason,
16623            ).map_err(harness_mcp_failure)?;
16624            Ok(NodeResponse::CallRejected {
16625                reservation_id, activation_digest, record_id, session, call_id,
16626            })
16627        }
16628        NodeRequest::BeginDeliveryStage { manifest } => {
16629            shared.require_controller(connection_id, role)?;
16630            shared.begin_delivery_stage(manifest)
16631        }
16632        NodeRequest::PutDeliveryBlobChunk {
16633            stage_id,
16634            blob_digest,
16635            offset,
16636            chunk_hex,
16637        } => {
16638            shared.require_controller(connection_id, role)?;
16639            shared.put_delivery_blob_chunk(stage_id, blob_digest, offset, chunk_hex)
16640        }
16641        NodeRequest::CommitDeliveryStage { stage_id } => {
16642            shared.require_controller(connection_id, role)?;
16643            let receipt = shared.commit_delivery_stage(stage_id)?;
16644            Ok(NodeResponse::DeliveryCommitted { receipt })
16645        }
16646        NodeRequest::AbortDeliveryStage { stage_id } => {
16647            shared.require_controller(connection_id, role)?;
16648            shared.abort_delivery_stage(&stage_id)?;
16649            Ok(NodeResponse::DeliveryStageAborted { stage_id })
16650        }
16651        NodeRequest::BrowseHostDirectories { directory, after } => {
16652            let listing = shared.browse_host_directories(directory, after).await?;
16653            Ok(NodeResponse::HostDirectoriesBrowsed { listing })
16654        }
16655        NodeRequest::InspectWorkspace { workspace_id } => {
16656            let inspection = shared.inspect_workspace(workspace_id).await?;
16657            Ok(NodeResponse::WorkspaceInspected { inspection })
16658        }
16659        NodeRequest::ReadWorkspaceFile { workspace_id, path } => {
16660            let file = shared.read_workspace_file(workspace_id, path).await?;
16661            Ok(NodeResponse::WorkspaceFileRead { file })
16662        }
16663        NodeRequest::WriteWorkspaceFile { workspace_id, path, expected_revision, text } => {
16664            shared.require_controller(connection_id, role)?;
16665            let file = shared
16666                .write_workspace_file(workspace_id, path, expected_revision, text)
16667                .await?;
16668            Ok(NodeResponse::WorkspaceFileWritten { file })
16669        }
16670        NodeRequest::CreateWorkspaceFile { workspace_id, path } => {
16671            shared.require_controller(connection_id, role)?;
16672            let file = shared.create_workspace_file(workspace_id, path).await?;
16673            Ok(NodeResponse::WorkspaceFileCreated { file })
16674        }
16675        NodeRequest::CreateWorkspaceDirectory { workspace_id, path } => {
16676            shared.require_controller(connection_id, role)?;
16677            let entry = shared
16678                .create_workspace_directory(workspace_id.clone(), path)
16679                .await?;
16680            Ok(NodeResponse::WorkspaceDirectoryCreated { workspace_id, entry })
16681        }
16682        NodeRequest::ReadGitHistory { workspace_id, path, before, limit } => {
16683            if role != ClientRole::Operator {
16684                return Err(failure(NodeFailureCode::ObserverReadOnly, "git history requires an operator connection"));
16685            }
16686            if !(1..=MAX_GIT_HISTORY_COMMITS).contains(&limit) {
16687                return Err(failure(NodeFailureCode::InvalidRequest, "git history limit is invalid"));
16688            }
16689            let page = shared
16690                .read_git_history(workspace_id.clone(), path, before, limit)
16691                .await?;
16692            Ok(NodeResponse::GitHistoryRead { workspace_id, page })
16693        }
16694        NodeRequest::ReadGitDiff { workspace_id, request } => {
16695            if role != ClientRole::Operator {
16696                return Err(failure(NodeFailureCode::ObserverReadOnly, "git diff requires an operator connection"));
16697            }
16698            let diff = shared.read_git_diff(workspace_id.clone(), request).await?;
16699            Ok(NodeResponse::GitDiffRead { workspace_id, diff })
16700        }
16701        NodeRequest::CatalogNativeSessions { route, limit } => {
16702            if role != ClientRole::Operator {
16703                return Err(failure(
16704                    NodeFailureCode::ObserverReadOnly,
16705                    "native session catalog requires an operator connection",
16706                ));
16707            }
16708            if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit) {
16709                return Err(failure(
16710                    NodeFailureCode::InvalidRequest,
16711                    "native session catalog limit is invalid",
16712                ));
16713            }
16714            let (entries, summary) = shared
16715                .catalog_native_sessions(route.clone(), limit)
16716                .await?;
16717            Ok(NodeResponse::NativeSessionsCataloged {
16718                route,
16719                entries,
16720                summary: Some(summary),
16721            })
16722        }
16723        NodeRequest::PageNativeSessions {
16724            route,
16725            window,
16726            catalog_revision,
16727            recent_cutoff_unix_ms,
16728            after_selection_id,
16729            limit,
16730        } => {
16731            if role != ClientRole::Operator {
16732                return Err(failure(
16733                    NodeFailureCode::ObserverReadOnly,
16734                    "native session catalog paging requires an operator connection",
16735                ));
16736            }
16737            if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit)
16738                || after_selection_id
16739                    .as_deref()
16740                    .is_some_and(|cursor| validate_candidate_id(cursor).is_err())
16741            {
16742                return Err(failure(
16743                    NodeFailureCode::InvalidRequest,
16744                    "native session catalog page request is invalid",
16745                ));
16746            }
16747            let page = shared
16748                .page_native_sessions(
16749                    route.clone(),
16750                    window,
16751                    catalog_revision,
16752                    recent_cutoff_unix_ms,
16753                    after_selection_id,
16754                    limit,
16755                )
16756                .await?;
16757            Ok(NodeResponse::NativeSessionsPaged {
16758                route,
16759                page,
16760            })
16761        }
16762        NodeRequest::PreviewNativeSession {
16763            selection,
16764            message_limit,
16765        } => {
16766            if role != ClientRole::Operator {
16767                return Err(failure(
16768                    NodeFailureCode::ObserverReadOnly,
16769                    "native session preview requires an operator connection",
16770                ));
16771            }
16772            if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16773                .contains(&message_limit)
16774                || selection.validate().is_err()
16775            {
16776                return Err(failure(
16777                    NodeFailureCode::InvalidRequest,
16778                    "native session preview request is invalid",
16779                ));
16780            }
16781            let preview = shared
16782                .preview_native_session(selection.clone(), message_limit)
16783                .await?;
16784            Ok(NodeResponse::NativeSessionPreviewed {
16785                selection,
16786                preview,
16787            })
16788        }
16789        NodeRequest::IndexNativeSession {
16790            selection,
16791            display_name,
16792        } => {
16793            shared.require_controller(connection_id, role)?;
16794            let record = shared
16795                .index_native_session(selection.clone(), display_name)
16796                .await?;
16797            Ok(NodeResponse::NativeSessionIndexed { selection, record })
16798        }
16799        NodeRequest::PreviewSessionRecord {
16800            record_id,
16801            message_limit,
16802        } => {
16803            if role != ClientRole::Operator {
16804                return Err(failure(
16805                    NodeFailureCode::ObserverReadOnly,
16806                    "native session preview requires an operator connection",
16807                ));
16808            }
16809            if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16810                .contains(&message_limit)
16811            {
16812                return Err(failure(
16813                    NodeFailureCode::InvalidRequest,
16814                    "native session preview request is invalid",
16815                ));
16816            }
16817            let record = shared.record(&record_id)?;
16818            let working_directory = shared.workspace_root(&record.workspace_id)?;
16819            if !platform::roots_equal(
16820                &working_directory,
16821                windows_path_text(&record.canonical_root),
16822            ) {
16823                return Err(failure(
16824                    NodeFailureCode::SessionRecordConflict,
16825                    "managed session workspace root changed; refusing to preview another directory",
16826                ));
16827            }
16828            let identity = record.provider_session.clone().ok_or_else(|| {
16829                failure(
16830                    NodeFailureCode::BackendOperationFailed,
16831                    "native session preview is unavailable for this session record",
16832                )
16833            })?;
16834            if identity.key != ProviderSessionKey::SessionId {
16835                return Err(failure(
16836                    NodeFailureCode::BackendOperationFailed,
16837                    "native session preview is unavailable for this session identity",
16838                ));
16839            }
16840            let preview = shared
16841                .preview_native_session_by_session_id(
16842                    record.workspace_id.clone(), record.provider.clone(), identity.id.clone(), message_limit,
16843                )
16844                .await?;
16845            if preview.session_id != identity.id {
16846                return Err(failure(
16847                    NodeFailureCode::SessionRecordConflict,
16848                    "native session preview identity changed while loading",
16849                ));
16850            }
16851            shared.revalidate_session_record_preview(&record, &identity)?;
16852            let preview: gate4agent_types::SessionRecordPreview = preview.into();
16853            shared.publish(NodeEvent::SessionRecordHistorySummarized {
16854                record_id: record_id.clone(),
16855                summary: SessionHistorySummaryV1::from(&preview),
16856            });
16857            Ok(NodeResponse::SessionRecordPreviewed {
16858                record_id,
16859                preview,
16860            })
16861        }
16862        NodeRequest::AcquireController { lease_ms } => {
16863            let controller = shared.acquire_controller(connection_id, role, lease_ms)?;
16864            Ok(NodeResponse::Controller { controller: Some(controller) })
16865        }
16866        NodeRequest::ReleaseController => {
16867            shared.release_controller(connection_id);
16868            Ok(NodeResponse::Controller { controller: shared.controller_state() })
16869        }
16870        NodeRequest::RegisterWorkspace { workspace_id, root } => {
16871            shared.require_controller(connection_id, role)?;
16872            let root = require_windows_path(root)?;
16873            shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16874            let workspace = shared.register_workspace(workspace_id, root).await?;
16875            Ok(NodeResponse::WorkspaceRegistered { workspace })
16876        }
16877        NodeRequest::CreateStandaloneWorkspace {
16878            workspace_id,
16879            root,
16880            initial_branch,
16881        } => {
16882            shared.require_controller(connection_id, role)?;
16883            let root = require_windows_path(root)?;
16884            shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16885            let workspace = shared
16886                .create_standalone_workspace(workspace_id, root, initial_branch)
16887                .await?;
16888            Ok(NodeResponse::StandaloneWorkspaceCreated { workspace })
16889        }
16890        NodeRequest::UnregisterWorkspace { workspace_id } => {
16891            shared.require_controller(connection_id, role)?;
16892            shared.reject_managed_reservation(Some(&workspace_id), None)?;
16893            shared.unregister_workspace(&workspace_id)?;
16894            Ok(NodeResponse::WorkspaceUnregistered { workspace_id })
16895        }
16896        NodeRequest::CreateWorktree {
16897            source_workspace_id,
16898            workspace_id,
16899            target_root,
16900            branch,
16901            base,
16902        } => {
16903            shared.require_controller(connection_id, role)?;
16904            let target_root = require_windows_path(target_root)?;
16905            shared.reject_managed_reservation(Some(&workspace_id), Some(&target_root))?;
16906            let (worktree, workspace) = shared
16907                .create_worktree(
16908                    source_workspace_id,
16909                    workspace_id,
16910                    target_root,
16911                    branch,
16912                    base,
16913                )
16914                .await?;
16915            Ok(NodeResponse::WorktreeCreated { worktree, workspace })
16916        }
16917        NodeRequest::RemoveWorktree {
16918            source_workspace_id,
16919            target_root,
16920        } => {
16921            shared.require_controller(connection_id, role)?;
16922            let native_target_root = require_windows_path(target_root.clone())?;
16923            shared.reject_managed_reservation(None, Some(&native_target_root))?;
16924            let workspace_id = shared
16925                .remove_worktree(source_workspace_id, native_target_root)
16926                .await?;
16927            Ok(NodeResponse::WorktreeRemoved {
16928                target_root,
16929                workspace_id,
16930            })
16931        }
16932        NodeRequest::Spawn { workspace_id, provider, mode, terminal_size, initial_prompt } => {
16933            shared.require_controller(connection_id, role)?;
16934            if !terminal_size.is_valid() {
16935                return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
16936            }
16937            if let Some(prompt) = initial_prompt.as_deref() {
16938                validate_node_text("spawn initial prompt", prompt)?;
16939            }
16940            match shared
16941                .spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt)
16942                .await
16943            {
16944                Ok(session) => {
16945                    shared.mark_spawn_provisional(session.clone());
16946                    Ok(NodeResponse::SpawnAccepted { session })
16947                }
16948                Err(err) => {
16949                    tracing::warn!(
16950                        %provider,
16951                        %workspace_id,
16952                        ?mode,
16953                        code = ?err.code,
16954                        message = %err.message,
16955                        "session spawn refused"
16956                    );
16957                    Err(err)
16958                }
16959            }
16960        }
16961        NodeRequest::SpawnWithOptions { workspace_id, provider, mode, terminal_size, initial_prompt, options } => {
16962            shared.require_controller(connection_id, role)?;
16963            if !terminal_size.is_valid() {
16964                return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
16965            }
16966            if let Some(prompt) = initial_prompt.as_deref() {
16967                validate_node_text("spawn initial prompt", prompt)?;
16968            }
16969            let plan = backend_paths::plan_launch_options(
16970                provider.as_str(),
16971                mode,
16972                options.model.is_some(),
16973                options.effort.is_some(),
16974            )
16975            .map_err(|r| {
16976                tracing::warn!(%provider, reason = %r.message, "launch option path refused");
16977                failure(NodeFailureCode::InvalidRequest, "launch option path refused")
16978            })?;
16979            let selection = spawn_selection(&options)?;
16980            let pty_selection = match plan {
16981                backend_paths::LaunchOptionsPlan::PtyFlags => selection,
16982                _ => None,
16983            };
16984            let spawned = SPAWN_ORIGIN.scope(options.origin.clone(), async {
16985                SPAWN_PARENT.scope(options.parent_session_id.clone(), async {
16986                    SPAWN_SESSION_OPTIONS
16987                        .scope(pty_selection, shared.spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt))
16988                        .await
16989                }).await
16990            }).await;
16991            match spawned {
16992                Ok(session) => {
16993                    if plan == backend_paths::LaunchOptionsPlan::AcpAfterSpawn {
16994                        shared
16995                            .pending_acp_options
16996                            .lock()
16997                            .unwrap_or_else(|p| p.into_inner())
16998                            .insert(session.session.instance_id, (options.model.clone(), options.effort.clone()));
16999                    }
17000                    shared.mark_spawn_provisional(session.clone());
17001                    tracing::info!(%provider, model = ?options.model, effort = ?options.effort, "session spawned with launch options");
17002                    Ok(NodeResponse::SpawnAccepted { session })
17003                }
17004                Err(err) => {
17005                    tracing::warn!(%provider, %workspace_id, code = ?err.code, message = %err.message, "session spawn with options refused");
17006                    Err(err)
17007                }
17008            }
17009        }
17010        NodeRequest::SpawnSpec { spec } => {
17011            shared.require_controller(connection_id, role)?;
17012            let receipt = shared.spawn_from_spec(spec).await?;
17013            Ok(NodeResponse::SpawnSpecAccepted { receipt })
17014        }
17015        NodeRequest::SpawnManagedWorktree { request } => {
17016            shared.require_controller(connection_id, role)?;
17017            let receipt = shared.spawn_managed_worktree(request).await?;
17018            Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
17019        }
17020        NodeRequest::SpawnManagedWorktreeV2 { request } => {
17021            shared.require_controller(connection_id, role)?;
17022            let receipt = shared.spawn_managed_worktree_v2(request).await?;
17023            Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
17024        }
17025        NodeRequest::CleanupManagedWorktree { lease_id } => {
17026            shared.require_controller(connection_id, role)?;
17027            let lease = shared.cleanup_managed_worktree(&lease_id, true).await?;
17028            Ok(NodeResponse::ManagedWorktreeCleanup { lease })
17029        }
17030        NodeRequest::DiscoverHistory { session, limit } => {
17031            controlled_session(shared, connection_id, role, &session)?;
17032            let candidates = shared.discover_history(&session, limit).await?;
17033            Ok(NodeResponse::HistoryDiscovered {
17034                session,
17035                candidates,
17036            })
17037        }
17038        NodeRequest::LoadHistory {
17039            session,
17040            candidate_id,
17041        } => {
17042            controlled_session(shared, connection_id, role, &session)?;
17043            let loaded = shared
17044                .load_history(&session, candidate_id)
17045                .await?;
17046            Ok(NodeResponse::HistoryLoaded {
17047                session,
17048                session_id: loaded.session_id,
17049                message_count: loaded.message_count,
17050                completed_turn_count: loaded.completed_turn_count,
17051            })
17052        }
17053        NodeRequest::ExportContextPackForSessionRecord { record_id, session } => {
17054            shared.require_controller(connection_id, role)?;
17055            let context = shared
17056                .export_context_pack_for_session_record(&record_id, &session, false)
17057                .await?;
17058            Ok(NodeResponse::ContextPackForSessionRecordExported {
17059                record_id,
17060                session,
17061                context,
17062            })
17063        }
17064        NodeRequest::ExportContextPack { session } => {
17065            controlled_session(shared, connection_id, role, &session)?;
17066            let context = shared.export_context_pack(&session).await?;
17067            Ok(NodeResponse::ContextPackExported { context })
17068        }
17069        NodeRequest::ForgetContextPack { context_id } => {
17070            shared.require_controller(connection_id, role)?;
17071            shared.forget_context_pack(&context_id)?;
17072            Ok(NodeResponse::ContextPackForgotten { context_id })
17073        }
17074        NodeRequest::ResolveDurableContextPack { context_id } => {
17075            // Read-only, idempotent, no controller lease required: this lets
17076            // a caller preflight "is this durably exported pack still here"
17077            // without holding the Node's mutation controller.
17078            let context = shared
17079                .context_catalog
17080                .read()
17081                .unwrap_or_else(|poisoned| poisoned.into_inner())
17082                .get(&context_id)
17083                .map(|pack| pack.receipt().clone())
17084                .ok_or_else(|| {
17085                    failure(
17086                        NodeFailureCode::UnknownContextPack,
17087                        "context pack is not present",
17088                    )
17089                })?;
17090            Ok(NodeResponse::DurableContextPackResolved { context })
17091        }
17092        NodeRequest::ReadContextPack { digest } => {
17093            // Read-only fetch of the pack's own bytes, keyed by content digest
17094            // rather than the caller's `SpawnContextId`: same no-controller-lease
17095            // rationale as `ResolveDurableContextPack` above, one step further
17096            // down the same catalog.
17097            let (id, byte_len, bytes) = shared
17098                .context_catalog
17099                .read()
17100                .unwrap_or_else(|poisoned| poisoned.into_inner())
17101                .get_by_digest(&digest)
17102                .map(|pack| {
17103                    (pack.receipt().id.clone(), pack.receipt().byte_len, pack.bytes().to_vec())
17104                })
17105                .ok_or_else(|| {
17106                    failure(
17107                        NodeFailureCode::UnknownContextPack,
17108                        &format!("context pack {digest} not on this node"),
17109                    )
17110                })?;
17111            tracing::debug!(
17112                digest = digest.as_str(),
17113                byte_len,
17114                "read context pack bytes",
17115            );
17116            Ok(NodeResponse::ContextPackBytesRead {
17117                pack: ContextPackBytesRead {
17118                    digest,
17119                    id,
17120                    byte_len,
17121                    bytes,
17122                },
17123            })
17124        }
17125        NodeRequest::Resume { session, terminal_size, initial_prompt } => {
17126            let provider = controlled_session(shared, connection_id, role, &session)?;
17127            if let Some(prompt) = initial_prompt.as_deref() {
17128                validate_node_text("resume initial prompt", prompt)?;
17129            }
17130            let runtime_requirement = if initial_prompt.is_some() {
17131                ProviderRuntimeRequirement::ResumeWithPrompt
17132            } else {
17133                ProviderRuntimeRequirement::Resume
17134            };
17135            shared.require_session_runtime_policy(&session, runtime_requirement)?;
17136            let runtime_policy = shared
17137                .admit_provider_runtime(&provider, runtime_requirement)
17138                .await?;
17139            let working_directory = shared.bound_workspace_root(&session)?;
17140            let request = ResumeLaunchRequest {
17141                working_directory,
17142                terminal_size,
17143                initial_prompt,
17144            };
17145            request
17146                .validate()
17147                .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
17148            let command = shared.prepare_command(ControlCommand::Resume {
17149                instance_id: session.session.instance_id,
17150                target: ResumeTarget::CurrentProvider,
17151                runtime_policy,
17152                request,
17153            });
17154            shared.arm_resume(&session, command.id, runtime_policy)?;
17155            let dispatch = shared.dispatch_envelope(command);
17156            if let Err(error) = dispatch {
17157                shared.clear_armed_resume(&session);
17158                return Err(error);
17159            }
17160            Ok(NodeResponse::Accepted)
17161        }
17162        NodeRequest::RenameSessionRecord {
17163            record_id,
17164            display_name,
17165        } => {
17166            shared.require_controller(connection_id, role)?;
17167            let record = shared.rename_session_record(&record_id, display_name)?;
17168            Ok(NodeResponse::SessionRecordUpdated { record })
17169        }
17170        NodeRequest::SetSessionTask {
17171            record_id,
17172            expected_revision,
17173            target,
17174        } => {
17175            shared.require_controller(connection_id, role)?;
17176            let record = shared.set_session_task(&record_id, expected_revision, target)?;
17177            Ok(NodeResponse::SessionRecordUpdated { record })
17178        }
17179        NodeRequest::IndexProviderSession {
17180            workspace_id,
17181            provider,
17182            identity,
17183            display_name,
17184        } => {
17185            shared.require_controller(connection_id, role)?;
17186            let record = shared.index_provider_session(
17187                workspace_id,
17188                provider,
17189                identity,
17190                display_name,
17191            )?;
17192            Ok(NodeResponse::ProviderSessionIndexed { record })
17193        }
17194        NodeRequest::ResumeSessionRecord {
17195            record_id,
17196            terminal_size,
17197            initial_prompt,
17198        } => {
17199            shared.require_controller(connection_id, role)?;
17200            if let Some(prompt) = initial_prompt.as_deref() {
17201                validate_node_text("managed resume initial prompt", prompt)?;
17202            }
17203            let (record, session) = shared
17204                .resume_session_record(
17205                    &record_id,
17206                    terminal_size,
17207                    initial_prompt,
17208                )
17209                .await?;
17210            Ok(NodeResponse::SessionRecordResumed { record, session })
17211        }
17212        NodeRequest::ForgetSessionRecord { record_id } => {
17213            shared.require_controller(connection_id, role)?;
17214            shared.forget_session_record(&record_id).await?;
17215            Ok(NodeResponse::SessionRecordForgotten { record_id })
17216        }
17217        NodeRequest::Prompt { session, text } => {
17218            let agent_id = controlled_session(shared, connection_id, role, &session)?;
17219            validate_node_text("prompt", &text)?;
17220            shared.require_session_runtime_policy(
17221                &session,
17222                ProviderRuntimeRequirement::SemanticPrompt,
17223            )?;
17224            let framing = prompt_framing(&agent_id);
17225            shared
17226                .dispatch_input_bounded(
17227                    &session,
17228                    InputAction::SubmitPrompt(PromptPayload { text, framing }),
17229                )
17230                .await?;
17231            Ok(NodeResponse::Accepted)
17232        }
17233        NodeRequest::Paste { session, text } => {
17234            controlled_session(shared, connection_id, role, &session)?;
17235            validate_node_text("paste", &text)?;
17236            shared.require_session_runtime_policy(
17237                &session,
17238                ProviderRuntimeRequirement::SemanticPrompt,
17239            )?;
17240            shared
17241                .dispatch_input_bounded(
17242                    &session,
17243                    InputAction::InsertDraft(PromptPayload {
17244                        text,
17245                        framing: PromptFraming::BracketedPaste,
17246                    }),
17247                )
17248                .await?;
17249            Ok(NodeResponse::Accepted)
17250        }
17251        NodeRequest::Input { session, text } => {
17252            controlled_session(shared, connection_id, role, &session)?;
17253            validate_node_text("terminal input", &text)?;
17254            shared
17255                .dispatch_input_bounded(
17256                    &session,
17257                    InputAction::TerminalText(TerminalText { text }),
17258                )
17259                .await?;
17260            Ok(NodeResponse::Accepted)
17261        }
17262        NodeRequest::TerminalBytes { session, bytes } => {
17263            controlled_session(shared, connection_id, role, &session)?;
17264            shared.sessionize_spawn(session.session.instance_id);
17265            let action = terminal_bytes_action(bytes)?;
17266            shared
17267                .dispatch_input_bounded(&session, action)
17268                .await?;
17269            Ok(NodeResponse::Accepted)
17270        }
17271        NodeRequest::TerminalControl { session, control } => {
17272            controlled_session(shared, connection_id, role, &session)?;
17273            shared
17274                .dispatch_input_bounded(&session, InputAction::TerminalControl(control))
17275                .await?;
17276            Ok(NodeResponse::Accepted)
17277        }
17278        NodeRequest::Resize { session, size } => {
17279            controlled_session(shared, connection_id, role, &session)?;
17280            shared.sessionize_spawn(session.session.instance_id);
17281            if !size.is_valid() {
17282                return Err(failure(NodeFailureCode::InvalidRequest, "terminal size is invalid"));
17283            }
17284            shared.dispatch_resize_bounded(&session, size).await?;
17285            Ok(NodeResponse::Accepted)
17286        }
17287        NodeRequest::Interrupt { session } => {
17288            controlled_session(shared, connection_id, role, &session)?;
17289            shared
17290                .dispatch_input_bounded(
17291                    &session,
17292                    InputAction::TerminalControl(TerminalControl::Interrupt),
17293                )
17294                .await?;
17295            Ok(NodeResponse::Accepted)
17296        }
17297        NodeRequest::Stop { session, force } => {
17298            controlled_session(shared, connection_id, role, &session)?;
17299            shared.sessionize_spawn(session.session.instance_id);
17300            shared.dispatch(ControlCommand::Stop { instance_id: session.session.instance_id, force })?;
17301            shared.retire_session_grok_leader(session.session.instance_id);
17302            Ok(NodeResponse::Accepted)
17303        }
17304        NodeRequest::Remove { session } => {
17305            controlled_session(shared, connection_id, role, &session)?;
17306            shared.remove_session(&session).await?;
17307            Ok(NodeResponse::Accepted)
17308        }
17309        NodeRequest::ResolveInteraction { session, correlation_id, response } => {
17310            shared.require_controller(connection_id, role)?;
17311            let snapshot = shared.require_acp_session(&session)?;
17312            let interaction = snapshot
17313                .provider
17314                .interactions
17315                .iter()
17316                .find(|interaction| {
17317                    interaction_correlation(
17318                        session.session.instance_id,
17319                        session.session.generation,
17320                        interaction.id.0,
17321                    ) == correlation_id
17322                })
17323                .ok_or_else(|| failure(
17324                    NodeFailureCode::InvalidRequest,
17325                    "unknown interaction correlation",
17326                ))?;
17327            if interaction.status != ProviderInteractionStatus::Pending {
17328                return Err(failure(
17329                    NodeFailureCode::BackendOperationFailed,
17330                    "interaction has already been resolved",
17331                ));
17332            }
17333            response
17334                .validate_for(interaction.interaction_kind)
17335                .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
17336            let interaction_id = interaction.id;
17337            let response_kind = response.kind();
17338            let response_outcome = response.outcome();
17339            shared.dispatch(ControlCommand::ResolveInteraction {
17340                instance_id: session.session.instance_id,
17341                generation: session.session.generation,
17342                interaction_id,
17343                response,
17344            })?;
17345            // The one line that lets an operator compare what THIS node
17346            // recorded against what the provider itself reports doing --
17347            // measured live on a kimi run where our side resolved `true`
17348            // while the agent reported "rejected" and neither layer had a
17349            // line to line up against the other.
17350            tracing::info!(
17351                node_id = %shared.node_id,
17352                session = ?session.session,
17353                correlation_id = %correlation_id,
17354                interaction_id = interaction_id.0,
17355                response_kind = ?response_kind,
17356                outcome = ?response_outcome,
17357                "interaction resolved",
17358            );
17359            Ok(NodeResponse::Accepted)
17360        }
17361        NodeRequest::SetSessionMode { session, mode_id } => {
17362            shared.require_controller(connection_id, role)?;
17363            shared.require_acp_session(&session)?;
17364            shared.dispatch(ControlCommand::SetSessionMode {
17365                instance_id: session.session.instance_id,
17366                mode_id,
17367            })?;
17368            Ok(NodeResponse::Accepted)
17369        }
17370        NodeRequest::SetSessionConfigOption {
17371            session,
17372            option_id,
17373            value_json,
17374        } => {
17375            shared.require_controller(connection_id, role)?;
17376            shared.require_acp_session(&session)?;
17377            shared.dispatch(ControlCommand::SetSessionConfigOption {
17378                instance_id: session.session.instance_id,
17379                option_id,
17380                value_json,
17381            })?;
17382            Ok(NodeResponse::Accepted)
17383        }
17384        NodeRequest::SetSessionModel { session, model_id } => {
17385            shared.require_controller(connection_id, role)?;
17386            shared.require_acp_session(&session)?;
17387            shared.dispatch(ControlCommand::SetSessionModel {
17388                instance_id: session.session.instance_id,
17389                model_id,
17390            })?;
17391            Ok(NodeResponse::Accepted)
17392        }
17393        NodeRequest::Shutdown => {
17394            shared.require_controller(connection_id, role)?;
17395            shared.begin_shutdown_locked().await?;
17396            Ok(NodeResponse::ShuttingDown)
17397        }
17398    }
17399}
17400
17401fn controlled_session(shared: &NodeShared, connection_id: u64, role: ClientRole, session: &SessionAddress) -> Result<AgentId, NodeFailure> {
17402    shared.require_controller(connection_id, role)?;
17403    shared.validate_address(session)
17404}
17405
17406/// Selects the sole history candidate matching `provider_session_id` when
17407/// the managed record carries a verified identity, or the sole candidate in
17408/// the discovered set at all when it does not (see
17409/// `export_context_pack_for_session_record_inner`'s identity match). Either
17410/// way, more than one match refuses instead of guessing which one is exact.
17411fn select_history_candidate(
17412    candidates: &[HistoryCandidateSummary],
17413    provider_session_id: Option<&str>,
17414) -> Result<String, NodeFailure> {
17415    let mut matches = candidates.iter().filter(|candidate| match provider_session_id {
17416        Some(id) => candidate.session_id_hint == id,
17417        None => true,
17418    });
17419    let candidate = matches.next().ok_or_else(|| {
17420        failure(
17421            NodeFailureCode::SessionRecordConflict,
17422            if provider_session_id.is_some() {
17423                "no history candidate matches the managed provider session"
17424            } else {
17425                "no history candidate exists for the managed session"
17426            },
17427        )
17428    })?;
17429    if matches.next().is_some() {
17430        return Err(failure(
17431            NodeFailureCode::SessionRecordConflict,
17432            if provider_session_id.is_some() {
17433                "history identity is ambiguous for the managed provider session"
17434            } else {
17435                "history is ambiguous without a verified provider session identity"
17436            },
17437        ));
17438    }
17439    Ok(candidate.id.clone())
17440}
17441
17442fn resolved_spawn_profile_summary(
17443    profile: &SpawnProfileDefaults,
17444    environment_profiles: &BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>,
17445) -> Option<SpawnProfileSummary> {
17446    let environment_profile = match profile.environment_profile_id.as_ref() {
17447        None => None,
17448        Some(profile_id) => {
17449            let binding = environment_profiles.get(profile_id)?;
17450            if binding.provider != profile.provider
17451                || binding.native_profile_id(profile.mode).is_none()
17452            {
17453                return None;
17454            }
17455            Some(ResolvedEnvironmentProfileReceipt {
17456                profile_id: binding.id.clone(),
17457                profile_revision: binding.revision.clone(),
17458                network_allowlist: None,
17459                browser_profile_id: None,
17460            })
17461        }
17462    };
17463    Some(SpawnProfileSummary {
17464        id: profile.profile_id.clone(),
17465        revision: profile.revision.clone(),
17466        environment_profile,
17467    })
17468}
17469
17470/// True if `current` is an acceptable version of `expected` at export
17471/// revalidation/commit time.
17472///
17473/// When `allow_clean_detachment` is `false` (the on-demand,
17474/// `NodeRequest::ExportContextPackForSessionRecord` API path), `current` must
17475/// be byte-identical to `expected` and still actively bound to `session` —
17476/// the original, unchanged invariant.
17477///
17478/// When `true` (the reactive auto-export-at-exit path, `§2.1`), `current` may
17479/// ALSO be the exact Live -> Dormant transition that the same clean exit this
17480/// export reacts to produces on its own: `state` Live -> Dormant,
17481/// `active_session` `Some(session)` -> `None`, `updated_at_unix_ms`
17482/// advancing. Every other field — including `provider_session`, `context`,
17483/// `bundle`, `task_binding` — must still match exactly. A record that changed
17484/// in any other way (rebind, a different active session, an identity
17485/// conflict, capacity eviction) is rejected either way; this widens nothing
17486/// about which sessions are eligible, only how the record's own expected
17487/// detachment is tolerated mid-export.
17488fn session_record_export_target_matches(
17489    expected: &ManagedSessionRecord,
17490    current: &ManagedSessionRecord,
17491    session: &SessionAddress,
17492    allow_clean_detachment: bool,
17493) -> bool {
17494    if current == expected && current.active_session.as_ref() == Some(session) {
17495        return current.state == ManagedSessionState::Live;
17496    }
17497    allow_clean_detachment
17498        && current.state == ManagedSessionState::Dormant
17499        && current.active_session.is_none()
17500        && current.record_id == expected.record_id
17501        && current.display_name == expected.display_name
17502        && current.provider == expected.provider
17503        && current.mode == expected.mode
17504        && current.workspace_id == expected.workspace_id
17505        && current.canonical_root == expected.canonical_root
17506        && current.provider_session == expected.provider_session
17507        && current.environment_profile == expected.environment_profile
17508        && current.bundle == expected.bundle
17509        && current.context_id == expected.context_id
17510        && current.context == expected.context
17511        && current.exported_context == expected.exported_context
17512        && current.task_binding == expected.task_binding
17513        && current.created_at_unix_ms == expected.created_at_unix_ms
17514        && current.last_error == expected.last_error
17515}
17516
17517fn session_record_context_export_binding_is_exact(
17518    expected: &ManagedSessionRecord,
17519    current: &ManagedSessionRecord,
17520    identity: Option<&ProviderSessionIdentity>,
17521    session: &SessionAddress,
17522    binding: &SessionBinding,
17523    provider: &AgentId,
17524    current_root: &str,
17525    allow_clean_detachment: bool,
17526) -> bool {
17527    session_record_export_target_matches(expected, current, session, allow_clean_detachment)
17528        && current.provider_session.as_ref() == identity
17529        && current.workspace_id == session.workspace_id
17530        && provider == &current.provider
17531        && binding.workspace_id == session.workspace_id
17532        && binding.generation == session.session.generation
17533        && binding.record_id.as_ref() == Some(&current.record_id)
17534        && platform::roots_equal(
17535            current_root,
17536            windows_path_text(&current.canonical_root),
17537        )
17538}
17539
17540fn session_record_context_export_source_is_usable(
17541    record: &ManagedSessionRecord,
17542    status: &SessionStatus,
17543    allow_clean_detachment: bool,
17544) -> bool {
17545    (record.state == ManagedSessionState::Live
17546        || (allow_clean_detachment && record.state == ManagedSessionState::Dormant))
17547        && context_pack_source_status_is_usable(status)
17548}
17549
17550fn prompt_framing(_agent_id: &AgentId) -> PromptFraming {
17551    PromptFraming::BracketedPaste
17552}
17553
17554fn validate_node_text(field: &str, text: &str) -> Result<(), NodeFailure> {
17555    if text.len() > MAX_NODE_TEXT_BYTES {
17556        return Err(failure(
17557            NodeFailureCode::InvalidRequest,
17558            &format!(
17559                "{field} length {} exceeds the {MAX_NODE_TEXT_BYTES}-byte node limit",
17560                text.len(),
17561            ),
17562        ));
17563    }
17564    Ok(())
17565}
17566
17567fn validate_terminal_bytes(bytes: &[u8]) -> Result<(), NodeFailure> {
17568    if bytes.is_empty() || bytes.len() > MAX_NODE_TERMINAL_BYTES {
17569        return Err(failure(
17570            NodeFailureCode::InvalidRequest,
17571            &format!(
17572                "terminal byte sequence must contain 1..={MAX_NODE_TERMINAL_BYTES} bytes; received {}",
17573                bytes.len(),
17574            ),
17575        ));
17576    }
17577    Ok(())
17578}
17579
17580fn terminal_bytes_action(bytes: Vec<u8>) -> Result<InputAction, NodeFailure> {
17581    validate_terminal_bytes(&bytes)?;
17582    Ok(InputAction::TerminalBytes(bytes))
17583}
17584
17585fn validate_workspace_request_root(root: &str) -> Result<(), NodeFailure> {
17586    if root.is_empty()
17587        || root.len() > MAX_WORKSPACE_ROOT_BYTES
17588        || root.chars().any(char::is_control)
17589    {
17590        return Err(failure(
17591            NodeFailureCode::InvalidWorkspaceRoot,
17592            &format!(
17593                "workspace root must contain 1..={MAX_WORKSPACE_ROOT_BYTES} bytes and no control characters",
17594            ),
17595        ));
17596    }
17597    Ok(())
17598}
17599
17600fn canonical_standalone_workspace_candidate(
17601    workspace_id: &WorkspaceId,
17602    root: &str,
17603) -> Result<String, NodeServerError> {
17604    let path = Path::new(root);
17605    match std::fs::symlink_metadata(path) {
17606        Ok(_) => WorkspaceConfig::new(workspace_id.clone(), path)
17607            .map(|workspace| workspace.canonical_root),
17608        Err(error) if error.kind() == io::ErrorKind::NotFound => {
17609            if !path.is_absolute() {
17610                return Err(NodeServerError::InvalidWorkspaceRoot {
17611                    workspace_id: workspace_id.clone(),
17612                    path: root.to_owned(),
17613                    message: "path is not absolute".to_owned(),
17614                });
17615            }
17616            let name = path.file_name().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
17617                workspace_id: workspace_id.clone(),
17618                path: root.to_owned(),
17619                message: "path has no standalone directory name".to_owned(),
17620            })?;
17621            let parent = path.parent().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
17622                workspace_id: workspace_id.clone(),
17623                path: root.to_owned(),
17624                message: "path has no parent directory".to_owned(),
17625            })?;
17626            let canonical = std::fs::canonicalize(parent)
17627                .map(|parent| parent.join(name))
17628                .map_err(|error| NodeServerError::InvalidWorkspaceRoot {
17629                    workspace_id: workspace_id.clone(),
17630                    path: root.to_owned(),
17631                    message: error.to_string(),
17632                })?;
17633            let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
17634                NodeServerError::InvalidWorkspaceRoot {
17635                    workspace_id: workspace_id.clone(),
17636                    path: path.to_string_lossy().into_owned(),
17637                    message: "canonical path is not valid Unicode".to_owned(),
17638                }
17639            })?;
17640            let canonical_root = platform::normalize_canonical_root(canonical_root);
17641            validate_workspace_root(workspace_id, &canonical_root)?;
17642            Ok(canonical_root)
17643        }
17644        Err(error) => Err(NodeServerError::InvalidWorkspaceRoot {
17645            workspace_id: workspace_id.clone(),
17646            path: root.to_owned(),
17647            message: error.to_string(),
17648        }),
17649    }
17650}
17651
17652fn validate_git_revision(field: &str, revision: Option<&str>) -> Result<(), NodeFailure> {
17653    let Some(revision) = revision else {
17654        return Ok(());
17655    };
17656    if revision.is_empty()
17657        || revision.len() > MAX_REPOSITORY_PATH_BYTES
17658        || revision.chars().any(char::is_control)
17659    {
17660        return Err(failure(
17661            NodeFailureCode::InvalidRequest,
17662            &format!(
17663                "{field} must contain 1..={MAX_REPOSITORY_PATH_BYTES} bytes and no control characters",
17664            ),
17665        ));
17666    }
17667    Ok(())
17668}
17669
17670fn git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
17671    let code = match error.kind {
17672        GitWorktreeErrorKind::Invalid => NodeFailureCode::InvalidRequest,
17673        GitWorktreeErrorKind::NotRepository => NodeFailureCode::NotGitRepository,
17674        GitWorktreeErrorKind::Conflict => NodeFailureCode::WorktreeConflict,
17675        GitWorktreeErrorKind::Protected => NodeFailureCode::WorktreeProtected,
17676        GitWorktreeErrorKind::Dirty => NodeFailureCode::WorktreeDirty,
17677        GitWorktreeErrorKind::Locked => NodeFailureCode::WorktreeLocked,
17678        GitWorktreeErrorKind::Failed => NodeFailureCode::BackendOperationFailed,
17679    };
17680    failure(code, &error.message)
17681}
17682
17683fn standalone_workspace_failure(error: StandaloneWorkspaceError) -> NodeFailure {
17684    let code = match error.kind {
17685        StandaloneWorkspaceErrorKind::InvalidRoot => NodeFailureCode::InvalidWorkspaceRoot,
17686        StandaloneWorkspaceErrorKind::InvalidInitialBranch => NodeFailureCode::InvalidRequest,
17687        StandaloneWorkspaceErrorKind::GitInitializationFailed => {
17688            NodeFailureCode::BackendOperationFailed
17689        }
17690        StandaloneWorkspaceErrorKind::RecoveryRequired => {
17691            NodeFailureCode::StandaloneWorkspaceRecoveryRequired
17692        }
17693    };
17694    failure(code, error.message)
17695}
17696
17697fn managed_git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
17698    let (code, message) = match error.kind {
17699        GitWorktreeErrorKind::Invalid => (
17700            NodeFailureCode::InvalidRequest,
17701            "managed Git input is invalid",
17702        ),
17703        GitWorktreeErrorKind::NotRepository => (
17704            NodeFailureCode::NotGitRepository,
17705            "managed source is not a Git repository",
17706        ),
17707        GitWorktreeErrorKind::Conflict => (
17708            NodeFailureCode::WorktreeConflict,
17709            "managed Git identity conflicts with existing repository state",
17710        ),
17711        GitWorktreeErrorKind::Protected => (
17712            NodeFailureCode::ManagedWorktreeOwnershipConflict,
17713            "managed worktree ownership validation failed",
17714        ),
17715        GitWorktreeErrorKind::Dirty => (
17716            NodeFailureCode::WorktreeDirty,
17717            "managed worktree contains uncommitted changes",
17718        ),
17719        GitWorktreeErrorKind::Locked => (
17720            NodeFailureCode::WorktreeLocked,
17721            "managed worktree is locked",
17722        ),
17723        GitWorktreeErrorKind::Failed => (
17724            NodeFailureCode::BackendOperationFailed,
17725            "managed Git operation failed",
17726        ),
17727    };
17728    NodeFailure { code, message: message.to_owned() }
17729}
17730
17731fn managed_spawn_receipt(
17732    resolved: &ResolvedSpawnSpec,
17733    incarnation_id: NodeIncarnationId,
17734    session: SessionAddress,
17735    lease: ManagedWorktreeLeaseSnapshot,
17736    environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
17737    bundle: Option<ResolvedBundleReceipt>,
17738    context: Option<ResolvedContextPackReceipt>,
17739) -> ManagedWorktreeSpawnReceipt {
17740    let mut managed = resolved.clone();
17741    managed.target.worktree_id = Some(lease.workspace_id.clone());
17742    ManagedWorktreeSpawnReceipt {
17743        spawn: managed.receipt_with_materialization(
17744            incarnation_id,
17745            session,
17746            environment_profile,
17747            bundle,
17748            context,
17749        ),
17750        lease,
17751    }
17752}
17753
17754fn validate_selected_worktree(
17755    source_root: &str,
17756    selected_root: &str,
17757    worktrees: &[NativeGitWorktreeSnapshot],
17758) -> Result<(), NodeFailure> {
17759    if !worktrees
17760        .iter()
17761        .any(|worktree| worktree_paths_equal(&worktree.path, source_root) && !worktree.is_bare)
17762    {
17763        return Err(failure(
17764            NodeFailureCode::WorktreeProtected,
17765            "source workspace is not an authoritative non-bare Git worktree root",
17766        ));
17767    }
17768    if !worktrees.iter().any(|worktree| {
17769        worktree_paths_equal(&worktree.path, selected_root)
17770            && !worktree.is_main
17771            && !worktree.is_bare
17772    }) {
17773        return Err(failure(
17774            NodeFailureCode::WorktreeProtected,
17775            "selected workspace is not an eligible Git worktree in the source repository",
17776        ));
17777    }
17778    Ok(())
17779}
17780
17781fn workspace_file_failure(error: WorkspaceFileReadError) -> NodeFailure {
17782    let code = match error.kind() {
17783        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17784        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17785            NodeFailureCode::RepositoryPathUnsafe
17786        }
17787        WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17788        WorkspaceFileReadErrorKind::NotRegularFile => {
17789            NodeFailureCode::RepositoryFileNotRegular
17790        }
17791        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17792            NodeFailureCode::RepositoryFileReadFailed
17793        }
17794        WorkspaceFileReadErrorKind::RevisionConflict => {
17795            NodeFailureCode::RepositoryFileRevisionConflict
17796        }
17797        WorkspaceFileReadErrorKind::AlreadyExists
17798        | WorkspaceFileReadErrorKind::ParentNotFound
17799        | WorkspaceFileReadErrorKind::ParentNotDirectory
17800        | WorkspaceFileReadErrorKind::Canceled => {
17801            NodeFailureCode::RepositoryFileReadFailed
17802        }
17803    };
17804    failure(code, "repository file read failed")
17805}
17806
17807fn workspace_file_write_failure(error: WorkspaceFileReadError) -> NodeFailure {
17808    let code = match error.kind() {
17809        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17810        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17811            NodeFailureCode::RepositoryPathUnsafe
17812        }
17813        WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17814        WorkspaceFileReadErrorKind::NotRegularFile => {
17815            NodeFailureCode::RepositoryFileNotRegular
17816        }
17817        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17818            NodeFailureCode::RepositoryFileWriteFailed
17819        }
17820        WorkspaceFileReadErrorKind::RevisionConflict => {
17821            NodeFailureCode::RepositoryFileRevisionConflict
17822        }
17823        WorkspaceFileReadErrorKind::AlreadyExists
17824        | WorkspaceFileReadErrorKind::ParentNotFound
17825        | WorkspaceFileReadErrorKind::ParentNotDirectory
17826        | WorkspaceFileReadErrorKind::Canceled => {
17827            NodeFailureCode::RepositoryFileWriteFailed
17828        }
17829    };
17830    failure(code, "repository file write failed")
17831}
17832
17833async fn settle_workspace_entry_create<T>(
17834    mut task: tokio::task::JoinHandle<Result<T, WorkspaceFileReadError>>,
17835    commit_state: Arc<AtomicU8>,
17836    deadline: Duration,
17837    timeout_message: &'static str,
17838    task_failure_message: &'static str,
17839) -> Result<T, NodeFailure> {
17840    let joined = match timeout(deadline, &mut task).await {
17841        Ok(joined) => joined,
17842        Err(_) => {
17843            if commit_state
17844                .compare_exchange(
17845                    WORKSPACE_ENTRY_CREATE_PENDING,
17846                    WORKSPACE_ENTRY_CREATE_CANCELED,
17847                    Ordering::AcqRel,
17848                    Ordering::Acquire,
17849                )
17850                .is_ok()
17851            {
17852                return Err(failure(
17853                    NodeFailureCode::RepositoryEntryCreateTimedOut,
17854                    timeout_message,
17855                ));
17856            }
17857            debug_assert_eq!(
17858                commit_state.load(Ordering::Acquire),
17859                WORKSPACE_ENTRY_CREATE_COMMITTING,
17860            );
17861            task.await
17862        }
17863    };
17864    joined
17865        .map_err(|_| {
17866            failure(
17867                NodeFailureCode::RepositoryEntryCreateFailed,
17868                task_failure_message,
17869            )
17870        })?
17871        .map_err(workspace_entry_create_failure)
17872}
17873
17874fn workspace_entry_create_failure(error: WorkspaceFileReadError) -> NodeFailure {
17875    let code = match error.kind() {
17876        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17877        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17878            NodeFailureCode::RepositoryPathUnsafe
17879        }
17880        WorkspaceFileReadErrorKind::AlreadyExists => {
17881            NodeFailureCode::RepositoryEntryAlreadyExists
17882        }
17883        WorkspaceFileReadErrorKind::ParentNotFound | WorkspaceFileReadErrorKind::NotFound => {
17884            NodeFailureCode::RepositoryParentNotFound
17885        }
17886        WorkspaceFileReadErrorKind::ParentNotDirectory
17887        | WorkspaceFileReadErrorKind::NotRegularFile => {
17888            NodeFailureCode::RepositoryParentNotDirectory
17889        }
17890        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17891            NodeFailureCode::RepositoryEntryCreateFailed
17892        }
17893        WorkspaceFileReadErrorKind::RevisionConflict => {
17894            NodeFailureCode::RepositoryEntryCreateFailed
17895        }
17896        WorkspaceFileReadErrorKind::Canceled => {
17897            NodeFailureCode::RepositoryEntryCreateTimedOut
17898        }
17899    };
17900    failure(code, "repository entry create failed")
17901}
17902
17903fn workspace_file_revision(bytes: &[u8]) -> WorkspaceFileRevision {
17904    let value = digest(&SHA256, bytes)
17905        .as_ref()
17906        .iter()
17907        .map(|byte| format!("{byte:02x}"))
17908        .collect();
17909    WorkspaceFileRevision::new(value).expect("SHA-256 produces a valid workspace revision")
17910}
17911
17912fn managed_spawn_request_digest_v2(
17913    request: &ManagedWorktreeSpawnRequestV2,
17914) -> Result<String, NodeFailure> {
17915    let encoded = serde_json::to_vec(request).map_err(|_| {
17916        failure(
17917            NodeFailureCode::InvalidRequest,
17918            "managed worktree V2 request could not be canonicalized",
17919        )
17920    })?;
17921    let mut value = String::with_capacity(71);
17922    value.push_str("sha256:");
17923    for byte in digest(&SHA256, &encoded).as_ref() {
17924        use std::fmt::Write as _;
17925        write!(&mut value, "{byte:02x}").expect("writing to a String cannot fail");
17926    }
17927    Ok(value)
17928}
17929
17930fn native_session_preview_failure(error: NativeSessionPreviewError) -> NodeFailure {
17931    let code = match error {
17932        NativeSessionPreviewError::InvalidLimit => NodeFailureCode::InvalidRequest,
17933        NativeSessionPreviewError::WorkspaceUnavailable => NodeFailureCode::UnknownWorkspace,
17934        NativeSessionPreviewError::StaleCatalog => NodeFailureCode::StaleNativeSessionCatalog,
17935        NativeSessionPreviewError::UnsupportedProvider
17936        | NativeSessionPreviewError::PreviewUnavailable
17937        | NativeSessionPreviewError::SessionNotFound
17938        | NativeSessionPreviewError::AmbiguousSession => {
17939            NodeFailureCode::BackendOperationFailed
17940        }
17941    };
17942    failure(code, "native session preview failed")
17943}
17944
17945fn host_directory_failure(error: HostDirectoryBrowseError) -> NodeFailure {
17946    let code = match error.kind() {
17947        HostDirectoryBrowseErrorKind::Invalid => NodeFailureCode::HostDirectoryInvalid,
17948        HostDirectoryBrowseErrorKind::ReadFailed => NodeFailureCode::HostDirectoryReadFailed,
17949    };
17950    failure(code, "host directory browse failed")
17951}
17952
17953fn delivery_failure(error: DeliveryStoreError) -> NodeFailure {
17954    let code = match error {
17955        DeliveryStoreError::InvalidManifest => NodeFailureCode::DeliveryManifestInvalid,
17956        DeliveryStoreError::UnknownStage => NodeFailureCode::UnknownDeliveryStage,
17957        DeliveryStoreError::Capacity | DeliveryStoreError::StageConflict => {
17958            NodeFailureCode::DeliveryStageConflict
17959        }
17960        DeliveryStoreError::UnexpectedBlob => NodeFailureCode::DeliveryBlobUnexpected,
17961        DeliveryStoreError::ChunkOutOfOrder | DeliveryStoreError::ChunkOverflow => {
17962            NodeFailureCode::DeliveryChunkOutOfOrder
17963        }
17964        DeliveryStoreError::BlobDigestMismatch => {
17965            NodeFailureCode::DeliveryBlobDigestMismatch
17966        }
17967        DeliveryStoreError::BundleDigestMismatch => {
17968            NodeFailureCode::DeliveryBundleDigestMismatch
17969        }
17970        DeliveryStoreError::StageIncomplete => NodeFailureCode::DeliveryStageIncomplete,
17971        DeliveryStoreError::Unavailable
17972        | DeliveryStoreError::Corrupt
17973        | DeliveryStoreError::Storage(_) => NodeFailureCode::DeliveryStageStorageFailed,
17974    };
17975    failure(code, "delivery operation failed")
17976}
17977
17978fn validate_workspace_root(
17979    workspace_id: &WorkspaceId,
17980    canonical_root: &str,
17981) -> Result<(), NodeServerError> {
17982    #[cfg(windows)]
17983    if canonical_root.starts_with(r"\\") {
17984        return Err(NodeServerError::InvalidWorkspaceRoot {
17985            workspace_id: workspace_id.clone(),
17986            path: canonical_root.to_owned(),
17987            message: "UNC workspace roots are unsupported by Windows PTY providers".to_owned(),
17988        });
17989    }
17990    if !platform::workspace_root_supported(canonical_root)
17991        || canonical_root.len() > WORKING_DIRECTORY_MAX_BYTES
17992    {
17993        return Err(NodeServerError::InvalidWorkspaceRoot {
17994            workspace_id: workspace_id.clone(),
17995            path: canonical_root.to_owned(),
17996            message: "canonical path must be absolute, bounded, and free of control characters"
17997                .to_owned(),
17998        });
17999    }
18000    Ok(())
18001}
18002
18003fn failure(code: NodeFailureCode, _message: &str) -> NodeFailure {
18004    NodeFailure {
18005        code,
18006        message: node_failure_category(code).to_owned(),
18007    }
18008}
18009
18010fn harness_mcp_failure(error: HarnessMcpProxyError) -> NodeFailure {
18011    let code = match error {
18012        HarnessMcpProxyError::Unavailable => NodeFailureCode::HarnessMcpUnavailable,
18013        HarnessMcpProxyError::NotFound => NodeFailureCode::ReservationNotFound,
18014        HarnessMcpProxyError::Conflict => NodeFailureCode::ReservationConflict,
18015        HarnessMcpProxyError::Expired => NodeFailureCode::ReservationExpired,
18016        HarnessMcpProxyError::BindingMismatch => NodeFailureCode::BindingMismatch,
18017        HarnessMcpProxyError::NotActivated => NodeFailureCode::NotActivated,
18018        HarnessMcpProxyError::CallNotFound => NodeFailureCode::CallNotFound,
18019        HarnessMcpProxyError::ChunkOutOfOrder => NodeFailureCode::ChunkOutOfOrder,
18020        HarnessMcpProxyError::ResponseTooLarge => NodeFailureCode::ResponseTooLarge,
18021    };
18022    failure(code, "harness MCP proxy operation failed")
18023}
18024
18025/// Numbers each harness-MCP overlay this process constructs, in
18026/// construction order, so two overlays built back to back in one node process
18027/// still get distinct trace files.
18028static NEXT_HARNESS_MCP_TRACE_SEQUENCE: AtomicU64 = AtomicU64::new(1);
18029
18030/// Builds the optional trace environment entry of one harness-MCP overlay.
18031/// The caller's launch description names both the entry and the node-process
18032/// variable that opts in: when that variable holds a non-empty directory, the
18033/// entry names a trace file under it. Returns `None` when the launch has no
18034/// trace, or the directory is unset, empty, or not valid Unicode. Never creates
18035/// the directory; that is the operator's job.
18036fn harness_mcp_trace_env_entry(launch: &HarnessMcpLaunchV1) -> Option<(OsString, OsString)> {
18037    let trace = launch.trace.as_ref()?;
18038    let trace_dir = std::env::var(&trace.dir_env).ok()?;
18039    if trace_dir.is_empty() {
18040        return None;
18041    }
18042    let sequence = NEXT_HARNESS_MCP_TRACE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
18043    let path = std::path::Path::new(&trace_dir).join(format!("harness-mcp-{sequence}.trace"));
18044    Some((OsString::from(trace.env.as_str()), path.into_os_string()))
18045}
18046
18047fn spawn_deadline_remaining(deadline: Instant) -> Result<Duration, NodeFailure> {
18048    let remaining = deadline.saturating_duration_since(Instant::now());
18049    if remaining.is_zero() {
18050        Err(failure(
18051            NodeFailureCode::SpawnDeadlineExceeded,
18052            "spawn deadline elapsed",
18053        ))
18054    } else {
18055        Ok(remaining)
18056    }
18057}
18058
18059fn spawn_dispatch_timeout(deadline: Option<Instant>) -> Result<Duration, NodeFailure> {
18060    let bounded = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
18061    match deadline {
18062        Some(deadline) => Ok(spawn_deadline_remaining(deadline)?.min(bounded)),
18063        None => Ok(bounded),
18064    }
18065}
18066
18067fn spawn_dispatch_error(error: NodeFailure, deadline: Option<Instant>) -> NodeFailure {
18068    if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
18069        failure(
18070            NodeFailureCode::SpawnDeadlineExceeded,
18071            "spawn dispatch exceeded the spawn deadline",
18072        )
18073    } else {
18074        error
18075    }
18076}
18077
18078fn spawn_runtime_capabilities(
18079    capabilities: &SpawnRequiredCapabilities,
18080) -> Result<Vec<ProviderRuntimeCapability>, NodeFailure> {
18081    capabilities
18082        .iter()
18083        .map(|capability| match capability.as_str() {
18084            SPAWN_RUNTIME_RAW_PTY_LIFECYCLE => Ok(ProviderRuntimeCapability::RawPtyLifecycle),
18085            SPAWN_RUNTIME_SEMANTIC_READINESS => Ok(ProviderRuntimeCapability::SemanticReadiness),
18086            SPAWN_RUNTIME_STRUCTURED_PROMPT => Ok(ProviderRuntimeCapability::StructuredPrompt),
18087            SPAWN_RUNTIME_PROVIDER_SESSION_IDENTITY => {
18088                Ok(ProviderRuntimeCapability::ProviderSessionIdentity)
18089            }
18090            SPAWN_RUNTIME_SEMANTIC_RESUME => Ok(ProviderRuntimeCapability::SemanticResume),
18091            _ => Err(failure(
18092                NodeFailureCode::UnsupportedSpawnCapability,
18093                "spawn required capability is unknown",
18094            )),
18095        })
18096        .collect()
18097}
18098
18099fn persistence_failure(_error: io::Error) -> NodeFailure {
18100    NodeFailure {
18101        code: NodeFailureCode::BackendOperationFailed,
18102        message: DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned(),
18103    }
18104}
18105
18106fn node_failure_category(code: NodeFailureCode) -> &'static str {
18107    match code {
18108        NodeFailureCode::InvalidRequest => "invalid-request",
18109        NodeFailureCode::UnsupportedCapability => "unsupported-capability",
18110        NodeFailureCode::HarnessMcpUnavailable => "harness-mcp-unavailable",
18111        NodeFailureCode::ReservationNotFound => "reservation-not-found",
18112        NodeFailureCode::ReservationConflict => "reservation-conflict",
18113        NodeFailureCode::ReservationExpired => "reservation-expired",
18114        NodeFailureCode::BindingMismatch => "binding-mismatch",
18115        NodeFailureCode::NotActivated => "not-activated",
18116        NodeFailureCode::CallNotFound => "call-not-found",
18117        NodeFailureCode::ChunkOutOfOrder => "chunk-out-of-order",
18118        NodeFailureCode::ResponseTooLarge => "response-too-large",
18119        NodeFailureCode::DeliveryManifestInvalid => "delivery-manifest-invalid",
18120        NodeFailureCode::UnknownDeliveryStage => "unknown-delivery-stage",
18121        NodeFailureCode::DeliveryStageConflict => "delivery-stage-conflict",
18122        NodeFailureCode::DeliveryBlobUnexpected => "delivery-blob-unexpected",
18123        NodeFailureCode::DeliveryChunkOutOfOrder => "delivery-chunk-out-of-order",
18124        NodeFailureCode::DeliveryBlobDigestMismatch => "delivery-blob-digest-mismatch",
18125        NodeFailureCode::DeliveryBundleDigestMismatch => "delivery-bundle-digest-mismatch",
18126        NodeFailureCode::DeliveryStageIncomplete => "delivery-stage-incomplete",
18127        NodeFailureCode::DeliveryStageStorageFailed => "delivery-stage-storage-failed",
18128        NodeFailureCode::Unauthorized => "unauthorized",
18129        NodeFailureCode::ObserverReadOnly => "observer-read-only",
18130        NodeFailureCode::ControllerBusy => "controller-busy",
18131        NodeFailureCode::ControllerRequired => "controller-required",
18132        NodeFailureCode::UnknownWorkspace => "unknown-workspace",
18133        NodeFailureCode::HostDirectoryInvalid => "host-directory-invalid",
18134        NodeFailureCode::HostDirectoryReadFailed => "host-directory-read-failed",
18135        NodeFailureCode::HostDirectoryReadTimedOut => "host-directory-read-timed-out",
18136        NodeFailureCode::InvalidRepositoryPath => "invalid-repository-path",
18137        NodeFailureCode::RepositoryFileNotFound => "repository-file-not-found",
18138        NodeFailureCode::RepositoryFileNotRegular => "repository-file-not-regular",
18139        NodeFailureCode::RepositoryPathUnsafe => "repository-path-unsafe",
18140        NodeFailureCode::RepositoryFileReadTimedOut => "repository-file-read-timed-out",
18141        NodeFailureCode::RepositoryFileReadFailed => "repository-file-read-failed",
18142        NodeFailureCode::RepositoryFileWriteTimedOut => "repository-file-write-timed-out",
18143        NodeFailureCode::RepositoryFileWriteFailed => "repository-file-write-failed",
18144        NodeFailureCode::RepositoryFileRevisionConflict => "repository-file-revision-conflict",
18145        NodeFailureCode::RepositoryEntryAlreadyExists => "repository-entry-already-exists",
18146        NodeFailureCode::RepositoryParentNotFound => "repository-parent-not-found",
18147        NodeFailureCode::RepositoryParentNotDirectory => "repository-parent-not-directory",
18148        NodeFailureCode::RepositoryEntryCreateTimedOut => "repository-entry-create-timed-out",
18149        NodeFailureCode::RepositoryEntryCreateFailed => "repository-entry-create-failed",
18150        NodeFailureCode::GitReadTimedOut => "git-read-timed-out",
18151        NodeFailureCode::GitReadFailed => "git-read-failed",
18152        NodeFailureCode::UnknownSpawnProfile => "unknown-spawn-profile",
18153        NodeFailureCode::SpawnProfileRevisionMismatch => "spawn-profile-revision-mismatch",
18154        NodeFailureCode::SpawnTargetMismatch => "spawn-target-mismatch",
18155        NodeFailureCode::SpawnIdempotencyConflict => "spawn-idempotency-conflict",
18156        NodeFailureCode::SpawnIdempotencyCapacity => "spawn-idempotency-capacity",
18157        NodeFailureCode::SpawnDeadlineExceeded => "spawn-deadline-exceeded",
18158        NodeFailureCode::UnsupportedSpawnCapability => "unsupported-spawn-capability",
18159        NodeFailureCode::UnsupportedTransport => "unsupported-transport",
18160        NodeFailureCode::TurnInFlight => "turn-in-flight",
18161        NodeFailureCode::UnknownEnvironmentProfile => "unknown-environment-profile",
18162        NodeFailureCode::UnknownNetworkAllowlist => "unknown-network-allowlist",
18163        NodeFailureCode::UnsupportedNetworkAllowlistMapping => {
18164            "unsupported-network-allowlist-mapping"
18165        }
18166        NodeFailureCode::BrowserStationProbeUnavailable => {
18167            "browser-station-probe-unavailable"
18168        }
18169        NodeFailureCode::BrowserStationUnreachable => "browser-station-unreachable",
18170        NodeFailureCode::BrowserStationProfileBusy => "browser-station-profile-busy",
18171        NodeFailureCode::EnvironmentProfileBindingMismatch => {
18172            "environment-profile-binding-mismatch"
18173        }
18174        NodeFailureCode::UnknownBundle => "unknown-bundle",
18175        NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
18176        NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
18177        NodeFailureCode::UnknownContextPack => "unknown-context-pack",
18178        NodeFailureCode::ContextPackBusy => "context-pack-busy",
18179        NodeFailureCode::ContextPackMaterializationFailed => {
18180            "context-pack-materialization-failed"
18181        }
18182        NodeFailureCode::InvalidWorkspaceRoot => "invalid-workspace-root",
18183        NodeFailureCode::DuplicateWorkspaceId => "duplicate-workspace-id",
18184        NodeFailureCode::DuplicateWorkspaceRoot => "duplicate-workspace-root",
18185        NodeFailureCode::WorkspaceBusy => "workspace-busy",
18186        NodeFailureCode::LastWorkspace => "last-workspace",
18187        NodeFailureCode::NotGitRepository => "not-git-repository",
18188        NodeFailureCode::WorktreeConflict => "worktree-conflict",
18189        NodeFailureCode::WorktreeProtected => "worktree-protected",
18190        NodeFailureCode::WorktreeDirty => "worktree-dirty",
18191        NodeFailureCode::WorktreeLocked => "worktree-locked",
18192        NodeFailureCode::UnknownManagedWorktreeLease => "unknown-managed-worktree-lease",
18193        NodeFailureCode::ManagedWorktreeBusy => "managed-worktree-busy",
18194        NodeFailureCode::ManagedWorktreeOwnershipConflict => "managed-worktree-ownership-conflict",
18195        NodeFailureCode::ManagedWorktreeProfileRevisionMismatch => {
18196            "managed-worktree-profile-revision-mismatch"
18197        }
18198        NodeFailureCode::ManagedWorktreeRecoveryRequired => "managed-worktree-recovery-required",
18199        NodeFailureCode::StandaloneWorkspaceRecoveryRequired => {
18200            "standalone-workspace-recovery-required"
18201        }
18202        NodeFailureCode::UnknownSession => "unknown-session",
18203        NodeFailureCode::UnknownSessionRecord => "unknown-session-record",
18204        NodeFailureCode::SessionRecordNotResumable => "session-record-not-resumable",
18205        NodeFailureCode::SessionRecordBusy => "session-record-busy",
18206        NodeFailureCode::SessionRecordConflict => "session-record-conflict",
18207        NodeFailureCode::SessionWorkspaceMismatch => "session-workspace-mismatch",
18208        NodeFailureCode::WorkspaceRegistrationRequired => {
18209            "workspace-registration-required"
18210        }
18211        NodeFailureCode::StaleNativeSessionCatalog => "stale-native-session-catalog",
18212        NodeFailureCode::StaleGeneration => "stale-generation",
18213        NodeFailureCode::BackendBusy => "backend-busy",
18214        NodeFailureCode::BackendDisconnected => "backend-disconnected",
18215        NodeFailureCode::BackendOperationFailed => "backend-operation-failed",
18216        NodeFailureCode::ShuttingDown => "shutting-down",
18217    }
18218}
18219
18220fn durable_state_server_error(error: io::Error, category: &'static str) -> NodeServerError {
18221    NodeServerError::DurableState(io::Error::new(error.kind(), category))
18222}
18223
18224fn durable_state_load_error(error: io::Error) -> NodeServerError {
18225    let category = match session_registry::state_load_refusal(&error) {
18226        Some(session_registry::StateLoadRefusal::UnsupportedSchema) => {
18227            DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR
18228        }
18229        Some(session_registry::StateLoadRefusal::PathSemanticsUnsupported) => {
18230            DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR
18231        }
18232        Some(session_registry::StateLoadRefusal::NodeIdentityMismatch) => {
18233            DURABLE_STATE_CONFLICT_ERROR
18234        }
18235        _ => DURABLE_STATE_LOAD_FAILED_ERROR,
18236    };
18237    durable_state_server_error(error, category)
18238}
18239
18240fn unix_time_ms() -> u64 {
18241    std::time::SystemTime::now()
18242        .duration_since(std::time::UNIX_EPOCH)
18243        .unwrap_or_default()
18244        .as_millis()
18245        .min(u64::MAX as u128) as u64
18246}
18247
18248fn detached_record_state(record: &ManagedSessionRecord) -> ManagedSessionState {
18249    if record.provider_session.is_some() {
18250        ManagedSessionState::Dormant
18251    } else {
18252        ManagedSessionState::Unavailable
18253    }
18254}
18255
18256fn active_record_state(
18257    identity_admitted: bool,
18258    provider_session_is_known: bool,
18259) -> ManagedSessionState {
18260    if identity_admitted && !provider_session_is_known {
18261        ManagedSessionState::IdentityPending
18262    } else {
18263        ManagedSessionState::Live
18264    }
18265}
18266
18267fn managed_worktree_record_holders(
18268    records: &[ManagedSessionRecord],
18269) -> Vec<(SessionRecordId, WorkspaceId)> {
18270    records
18271        .iter()
18272        .filter(|record| record.provider_session.is_some())
18273        .map(|record| (record.record_id.clone(), record.workspace_id.clone()))
18274        .collect()
18275}
18276
18277fn managed_spawn_record_ownership(
18278    binding: &SessionBinding,
18279    effective_runtime_policy: ProviderRuntimePolicy,
18280) -> (Option<SessionRecordId>, Option<SessionRecordId>) {
18281    debug_assert_eq!(binding.runtime_policy, effective_runtime_policy);
18282    if effective_runtime_policy.provider_session_identity {
18283        (binding.record_id.clone(), None)
18284    } else {
18285        (None, binding.record_id.clone())
18286    }
18287}
18288
18289#[derive(Debug, Error)]
18290pub enum NodeServerError {
18291    #[error("node endpoint must be a bounded absolute local transport path")]
18292    InvalidEndpoint,
18293    #[error("node access token must contain 1..=4096 bytes")]
18294    InvalidAccessToken,
18295    #[error("node HTTP observer must listen on a loopback address: {0}")]
18296    InvalidApiListen(std::net::SocketAddr),
18297    #[error("node browser bridge must listen on a loopback address: {0}")]
18298    InvalidBridgeListen(std::net::SocketAddr),
18299    #[error("node browser bridge token must contain 1..=4096 bytes")]
18300    InvalidBridgeToken,
18301    #[error("bridge underlay requires --bridge-listen (local HTTP+WS door)")]
18302    BridgeUnderlayRequiresBridgeListen,
18303    #[error("bridge underlay requires GATE4AGENT_BRIDGE_TOKEN (application barrier)")]
18304    BridgeUnderlayRequiresBridgeToken,
18305    #[error("mesh underlay auth token must contain 1..=4096 bytes")]
18306    InvalidBridgeUnderlayToken,
18307    #[error("mesh underlay transport key must be 32 bytes (64 hex digits)")]
18308    InvalidBridgeUnderlayKey,
18309    #[error("node call-home relay must be loopback or a concrete C2 tunnel address with a nonzero port: {0}")]
18310    InvalidCallHome(std::net::SocketAddr),
18311    #[error("node could not reach its call-home relay: {0}")]
18312    CallHomeTransport(#[source] std::io::Error),
18313    #[error("node durable state path must be an absolute file path: {0}")]
18314    InvalidStatePath(String),
18315    #[error("harness MCP helper must be an exact reviewed absolute regular file")]
18316    InvalidHarnessMcpHelper,
18317    #[error("network allowlist catalog path must be an absolute regular file: {0}")]
18318    InvalidNetworkAllowlistCatalogPath(String),
18319    #[error("network allowlist catalog could not be read: {0}")]
18320    NetworkAllowlistCatalogIo(#[source] std::io::Error),
18321    #[error("network allowlist catalog line {line}: {message}")]
18322    InvalidNetworkAllowlistCatalogEntry { line: usize, message: String },
18323    #[error("network allowlist catalog: {0}")]
18324    InvalidNetworkAllowlistCatalog(String),
18325    #[error("network allowlist catalog exceeds the {max}-entry limit")]
18326    NetworkAllowlistCatalogCapacity { max: usize },
18327    #[error("the local state directory is unavailable; supply an explicit state path")]
18328    LocalStateDirectoryUnavailable,
18329    #[error("the local runtime directory is unavailable; supply an explicit endpoint")]
18330    LocalRuntimeDirectoryUnavailable,
18331    #[error("node durable state failed: {0}")]
18332    DurableState(io::Error),
18333    #[error("node requires at least one configured workspace")]
18334    NoWorkspaces,
18335    #[error("workspace '{workspace_id}' root '{path}' is invalid: {message}")]
18336    InvalidWorkspaceRoot {
18337        workspace_id: WorkspaceId,
18338        path: String,
18339        message: String,
18340    },
18341    #[error("duplicate workspace ID: {0}")]
18342    DuplicateWorkspaceId(WorkspaceId),
18343    #[error("workspaces '{first}' and '{second}' resolve to the same root '{root}'")]
18344    DuplicateWorkspaceRoot {
18345        first: WorkspaceId,
18346        second: WorkspaceId,
18347        root: String,
18348    },
18349    #[error("workspace '{0}' uses managed worktrees but has no local profile")]
18350    ManagedWorktreeProfileRequired(WorkspaceId),
18351    #[error("workspace '{0}' has managed worktree profiles but is not in managed mode")]
18352    ManagedWorktreeProfileModeMismatch(WorkspaceId),
18353    #[error("workspace '{workspace_id}' has duplicate managed worktree profile '{profile_id}'")]
18354    DuplicateManagedWorktreeProfile {
18355        workspace_id: WorkspaceId,
18356        profile_id: WorktreeProfileId,
18357    },
18358    #[error("workspace '{workspace_id}' exceeds the {max}-profile managed worktree limit")]
18359    ManagedWorktreeProfileCapacity {
18360        workspace_id: WorkspaceId,
18361        max: usize,
18362    },
18363    #[error("workspace '{workspace_id}' managed worktree profile '{profile_id}' is invalid: {message}")]
18364    InvalidManagedWorktreeProfile {
18365        workspace_id: WorkspaceId,
18366        profile_id: WorktreeProfileId,
18367        message: String,
18368    },
18369    #[error("duplicate node environment profile: {0}")]
18370    DuplicateEnvironmentProfile(SpawnEnvironmentProfileId),
18371    #[error("node environment profile provider is unavailable: {0}")]
18372    EnvironmentProfileProviderUnavailable(AgentId),
18373    #[error("node environment profile capacity is {max}")]
18374    EnvironmentProfileCapacity { max: usize },
18375    #[error("node environment profiles reuse native profile ID '{0}'")]
18376    DuplicateNativeEnvironmentProfile(String),
18377    #[error("node native environment profile is invalid: {0}")]
18378    NativeEnvironmentProfile(String),
18379    #[error("node session-environment materialization root must be absolute")]
18380    InvalidSessionEnvironmentRoot,
18381    #[error("node session-environment materializer is unavailable")]
18382    SessionEnvironmentMaterializerRequired,
18383    #[error("node session-environment materializer failed to initialize")]
18384    SessionEnvironmentMaterializer,
18385    #[error("node bundle catalog is invalid: {0}")]
18386    BundleCatalog(String),
18387    #[error("node delivery store failed to initialize: {0}")]
18388    DeliveryStore(String),
18389    #[error("node context pack store failed to initialize")]
18390    ContextPackStore,
18391    #[error("active agent registry failed: {0}")]
18392    Registry(String),
18393    #[error("node provider contract manifest is invalid: {0}")]
18394    ProviderContractManifest(String),
18395    #[error("named pipe I/O failed: {0}")]
18396    Io(#[from] io::Error),
18397    #[error("node HTTP observer failed: {0}")]
18398    HttpApi(io::Error),
18399    #[error("node browser bridge failed: {0}")]
18400    BridgeApi(io::Error),
18401    #[error(transparent)]
18402    Frame(#[from] FrameError),
18403    #[error("node handshake failed: {0}")]
18404    Handshake(String),
18405    #[error("node authentication frame was not received before the bounded deadline")]
18406    AuthenticationTimedOut,
18407    #[error("node authentication primitive failed: {0}")]
18408    Authentication(String),
18409    #[error("node incarnation identity generation failed: {0}")]
18410    IncarnationIdentity(String),
18411    #[error("node pre-authentication limiter is closed")]
18412    PreauthClosed,
18413    #[error("node authenticated connection limit was reached")]
18414    AuthenticatedConnectionLimit,
18415    #[error("node Ctrl+C/CtrlBreak signal stream closed")]
18416    SignalStreamClosed,
18417    #[error("node stop-all dispatch failed: {0:?}")]
18418    ShutdownDispatch(NodeFailure),
18419    #[error("node shutdown timed out with {active_native_sessions} physical sessions retained")]
18420    ShutdownTimedOut { active_native_sessions: usize },
18421}
18422
18423impl From<crate::network_allowlist_catalog::NetworkAllowlistCatalogError> for NodeServerError {
18424    fn from(error: crate::network_allowlist_catalog::NetworkAllowlistCatalogError) -> Self {
18425        use crate::network_allowlist_catalog::NetworkAllowlistCatalogError as E;
18426        match error {
18427            E::InvalidPath(path) => Self::InvalidNetworkAllowlistCatalogPath(path),
18428            E::Io(error) => Self::NetworkAllowlistCatalogIo(error),
18429            E::InvalidEntry { line, message } => {
18430                Self::InvalidNetworkAllowlistCatalogEntry { line, message }
18431            }
18432            E::InvalidCatalog(message) => Self::InvalidNetworkAllowlistCatalog(message),
18433            E::Capacity { max } => Self::NetworkAllowlistCatalogCapacity { max },
18434        }
18435    }
18436}
18437
18438#[cfg(test)]
18439mod agent_stream_blocked_tests {
18440    use super::*;
18441    use gate4agent_types::{
18442        AdapterId, AdapterVerification, HostRequestOutcome as ProviderHostRequestOutcome,
18443        ProviderSource,
18444    };
18445
18446    fn provider_source(family: AdapterFamily, adapter: &str) -> ProviderSource {
18447        ProviderSource {
18448            family,
18449            binding: AdapterBinding::new(
18450                AdapterId::new(adapter).unwrap(),
18451                "fixture/v1",
18452                AdapterVerification::SyntheticFixture,
18453            )
18454            .unwrap(),
18455        }
18456    }
18457
18458    fn provider_control_event_at(
18459        family: AdapterFamily,
18460        adapter: &str,
18461        source_sequence: u64,
18462        provider_event: ProviderEvent,
18463    ) -> ControlEvent {
18464        ControlEvent {
18465            sequence: 41,
18466            command_id: None,
18467            instance_id: AgentInstanceId(7),
18468            generation: SessionGeneration(3),
18469            event: ControlEventKind::ProviderEvent {
18470                sequence: 8,
18471                source: provider_source(family, adapter),
18472                source_sequence,
18473                event: provider_event,
18474            },
18475        }
18476    }
18477
18478    fn provider_control_event(family: AdapterFamily, provider_event: ProviderEvent) -> ControlEvent {
18479        provider_control_event_at(family, "fixture", 9, provider_event)
18480    }
18481
18482    fn denied_host_request(
18483        by: ProviderHostDecisionAuthority,
18484        reason: Option<String>,
18485    ) -> ControlEvent {
18486        provider_control_event_at(
18487            AdapterFamily::Acp,
18488            "claude-code",
18489            2,
18490            ProviderEvent::HostRequestObserved {
18491                method: "terminal/create".to_owned(),
18492                params_json: String::new(),
18493                decision: ProviderHostRequestDecision::Denied { by },
18494                outcome: ProviderHostRequestOutcome::Executed,
18495                reason,
18496            },
18497        )
18498    }
18499
18500    fn blocked(
18501        event: &ControlEvent,
18502    ) -> (
18503        Option<String>,
18504        String,
18505        BlockAuthorityV1,
18506        Option<String>,
18507        String,
18508        Option<String>,
18509    ) {
18510        let chunk = agent_stream_chunk(event).expect("expected a Blocked chunk");
18511        chunk.validate().expect("minted chunk must validate");
18512        let AgentStreamChunkKindV1::Blocked {
18513            correlation_id,
18514            tool_class,
18515            authority,
18516            reason_kind,
18517            reason,
18518            help,
18519        } = chunk.kind
18520        else {
18521            panic!("expected AgentStreamChunkKindV1::Blocked");
18522        };
18523        (correlation_id, tool_class, authority, reason_kind, reason, help)
18524    }
18525
18526    /// Pins the fleet and every adapter each member declares: the fleet is
18527    /// exactly what `active_registry` says, and no member declares a hook
18528    /// adapter (lifecycle hooks are retired).
18529    #[test]
18530    fn the_enabled_provider_matrix_is_exact() {
18531        let registry = active_registry().unwrap();
18532        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
18533        assert_eq!(
18534            providers
18535                .iter()
18536                .map(|contract| contract.provider.as_str())
18537                .collect::<Vec<_>>(),
18538            // `shell` is the plain PTY provider (operator terminals and smoke tests).
18539            vec!["shell", "claude", "codex", "grok", "kimi"],
18540        );
18541        assert!(adapters.iter().all(|contract| {
18542            !matches!(contract.family, AdapterFamily::Hook | AdapterFamily::ManagedHook)
18543        }));
18544    }
18545
18546    /// A gate-denied host request, WITH its reason text carried all the way
18547    /// from the ACP host, mints a `Blocked` chunk naming the gate.
18548    #[test]
18549    fn gate_denied_host_request_mints_a_blocked_chunk() {
18550        let gate_text =
18551            "blocked by dangerous-command gate: rule=filesystem-wipe, argument=rm -rf /";
18552        let event = denied_host_request(
18553            ProviderHostDecisionAuthority::Gate,
18554            Some(gate_text.to_owned()),
18555        );
18556        let chunk = agent_stream_chunk(&event).expect("expected a Blocked chunk");
18557        assert_eq!(chunk.source_sequence, 2);
18558        let (correlation_id, tool_class, authority, reason_kind, reason, help) = blocked(&event);
18559        assert_eq!(authority, BlockAuthorityV1::HostGate);
18560        assert_eq!(reason, gate_text);
18561        assert_eq!(correlation_id, None);
18562        assert_eq!(reason_kind, None);
18563        assert_eq!(help, None);
18564        assert_eq!(tool_class, "Write");
18565    }
18566
18567    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.";
18568
18569    /// The owner's classifier sample, verbatim, arriving as a
18570    /// `tool_call_update`-derived `ProviderEvent::ToolCompleted` with
18571    /// `non_execution_kind: "permission-rule"` -- mints a `Blocked` chunk with
18572    /// `authority: ProviderPermissionRule`, the `Reason:` sentence as `reason`
18573    /// and the guidance tail as `help`.
18574    #[test]
18575    fn classifier_sample_permission_rule_mints_a_blocked_chunk() {
18576        let event = provider_control_event(
18577            AdapterFamily::Acp,
18578            ProviderEvent::ToolCompleted {
18579                id: "toolu_9".to_owned(),
18580                output: CLASSIFIER_SAMPLE.to_owned(),
18581                is_error: true,
18582                duration_ms: None,
18583                agent_id: None,
18584                non_execution_kind: Some("permission-rule".to_owned()),
18585            },
18586        );
18587        let (correlation_id, _, authority, reason_kind, reason, help) = blocked(&event);
18588        assert_eq!(authority, BlockAuthorityV1::ProviderPermissionRule);
18589        assert_eq!(reason, "Blocked by classifier");
18590        assert_eq!(reason_kind, Some("permission-rule".to_owned()));
18591        assert!(help.as_deref().is_some_and(|help| help.starts_with("If you have other tasks")));
18592        assert!(correlation_id.is_some(), "a tool-correlated block must carry a correlation id");
18593    }
18594
18595    /// A `session/prompt` RPC error with `data.codexErrorInfo:
18596    /// "usageLimitExceeded"` (measured live 2026-09-05) mints a `Blocked` chunk
18597    /// with `authority: ProviderQuota`, the message verbatim as `reason` and the
18598    /// vendor code as `reason_kind`.
18599    #[test]
18600    fn codex_quota_rpc_error_mints_a_blocked_chunk() {
18601        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.";
18602        let event = provider_control_event(
18603            AdapterFamily::Acp,
18604            ProviderEvent::SessionEnded {
18605                result: "JSON-RPC error -32603: Internal error".to_owned(),
18606                cost_usd: None,
18607                is_error: true,
18608                stop_reason: Some(ProviderStopReason::ProviderError {
18609                    code: -32603,
18610                    message: message.to_owned(),
18611                    vendor_code: Some("usageLimitExceeded".to_owned()),
18612                }),
18613            },
18614        );
18615        let (_, tool_class, authority, reason_kind, reason, _) = blocked(&event);
18616        assert_eq!(authority, BlockAuthorityV1::ProviderQuota);
18617        assert_eq!(reason, message);
18618        assert_eq!(reason_kind, Some("usageLimitExceeded".to_owned()));
18619        assert_eq!(tool_class, "Session");
18620    }
18621
18622    /// ACP's `stopReason: "refusal"` mints a `Blocked` chunk with
18623    /// `authority: ProviderRefusal`.
18624    #[test]
18625    fn session_end_stop_reason_refusal_mints_a_blocked_chunk() {
18626        let event = provider_control_event(
18627            AdapterFamily::Acp,
18628            ProviderEvent::SessionEnded {
18629                result: "refusal".to_owned(),
18630                cost_usd: None,
18631                is_error: true,
18632                stop_reason: Some(ProviderStopReason::Refusal),
18633            },
18634        );
18635        let (_, _, authority, _, _, _) = blocked(&event);
18636        assert_eq!(authority, BlockAuthorityV1::ProviderRefusal);
18637    }
18638
18639    /// A failed tool completion with no typed `non_execution_kind` -- or one
18640    /// that only says the turn stopped -- is a plain execution failure, never a
18641    /// block; nor is an agent's own narrative claiming one.
18642    #[test]
18643    fn failures_and_narrative_without_a_typed_block_mint_no_blocked_chunk() {
18644        for non_execution_kind in [None, Some("interrupted".to_owned()), Some("cancelled".to_owned())] {
18645            let event = provider_control_event(
18646                AdapterFamily::Acp,
18647                ProviderEvent::ToolCompleted {
18648                    id: "toolu_1".to_owned(),
18649                    output: "command exited with status 1".to_owned(),
18650                    is_error: true,
18651                    duration_ms: Some(12),
18652                    agent_id: None,
18653                    non_execution_kind,
18654                },
18655            );
18656            assert!(agent_stream_chunk(&event).is_none());
18657        }
18658        let narrative = provider_control_event(
18659            AdapterFamily::Acp,
18660            ProviderEvent::ToolCompleted {
18661                id: "toolu_2".to_owned(),
18662                output: "the harness blocked the write, approval needed".to_owned(),
18663                is_error: false,
18664                duration_ms: Some(3),
18665                agent_id: None,
18666                non_execution_kind: None,
18667            },
18668        );
18669        assert!(agent_stream_chunk(&narrative).is_none());
18670    }
18671
18672    /// `Granted`/`Deferred` decisions never mint a `Blocked` chunk, whatever the
18673    /// execution outcome: a granted request that failed while running was
18674    /// still authorized.
18675    #[test]
18676    fn granted_and_deferred_host_requests_never_mint_a_blocked_chunk() {
18677        for (decision, outcome) in [
18678            (
18679                ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
18680                ProviderHostRequestOutcome::Executed,
18681            ),
18682            (ProviderHostRequestDecision::Deferred, ProviderHostRequestOutcome::Executed),
18683            (
18684                ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
18685                ProviderHostRequestOutcome::Failed {
18686                    error: "terminal/create spawn failed: os error 3".to_owned(),
18687                },
18688            ),
18689        ] {
18690            let event = provider_control_event_at(
18691                AdapterFamily::Acp,
18692                "claude-code",
18693                3,
18694                ProviderEvent::HostRequestObserved {
18695                    method: "fs/read_text_file".to_owned(),
18696                    params_json: String::new(),
18697                    decision: decision.clone(),
18698                    outcome,
18699                    reason: None,
18700                },
18701            );
18702            assert!(agent_stream_chunk(&event).is_none(), "{decision:?}");
18703        }
18704    }
18705
18706    /// A deferred `session/request_permission` resolved by an operator or an
18707    /// expired deadline carries no reason text -- the chunk falls back to a
18708    /// categorical sentence naming the authority rather than inventing detail.
18709    #[test]
18710    fn denied_host_request_with_no_reason_falls_back_to_a_categorical_sentence() {
18711        for (by, expected_authority, expected_reason) in [
18712            (
18713                ProviderHostDecisionAuthority::Operator,
18714                BlockAuthorityV1::Operator,
18715                "declined by the operator",
18716            ),
18717            (
18718                ProviderHostDecisionAuthority::DeadlinePolicy,
18719                BlockAuthorityV1::HostDeadline,
18720                "denied by host policy after the operator deadline expired",
18721            ),
18722        ] {
18723            let (_, _, authority, _, reason, _) = blocked(&denied_host_request(by, None));
18724            assert_eq!(authority, expected_authority);
18725            assert_eq!(reason, expected_reason);
18726        }
18727    }
18728
18729    /// An over-long reason is cut to the wire bound BEFORE the chunk is built,
18730    /// at a safe UTF-8 boundary -- `validate` rejects an over-long value
18731    /// outright rather than truncating.
18732    #[test]
18733    fn overlong_blocked_reason_is_truncated_to_the_wire_bound() {
18734        let oversized = "x".repeat(MAX_ACP_BLOCKED_REASON_BYTES + 500);
18735        let (_, _, _, _, reason, _) = blocked(&denied_host_request(
18736            ProviderHostDecisionAuthority::Policy,
18737            Some(oversized),
18738        ));
18739        assert!(reason.len() <= MAX_ACP_BLOCKED_REASON_BYTES);
18740    }
18741}
18742
18743#[cfg(test)]
18744mod standalone_capability_platform_tests {
18745    use super::*;
18746
18747    #[test]
18748    fn standalone_workspace_capability_requires_windows_identity_guards() {
18749        let capabilities = baseline_capabilities().unwrap();
18750        let advertised = capabilities.iter().any(|capability| {
18751            capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
18752        });
18753        assert_eq!(advertised, cfg!(windows));
18754
18755        let request = NodeRequest::CreateStandaloneWorkspace {
18756            workspace_id: WorkspaceId::new("standalone-platform").unwrap(),
18757            root: OpaqueHostPath::utf8("standalone-platform-root".to_owned()).unwrap(),
18758            initial_branch: None,
18759        };
18760        assert_eq!(
18761            request_uses_unnegotiated_capability(&request, &capabilities),
18762            !cfg!(windows),
18763        );
18764    }
18765}
18766
18767
18768#[cfg(test)]
18769mod station_knobs_resolve_tests {
18770    use super::*;
18771    use gate4agent_catalog::EnvMutation;
18772    use gate4agent_runtime_native::{
18773        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
18774        NativeLaunchProfileId,
18775    };
18776    use std::ffi::OsString;
18777    use std::sync::Arc;
18778
18779    struct EmptyEnvironmentResolver;
18780
18781    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
18782        fn resolve_child_environment(
18783            &self,
18784        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
18785            Ok(vec![EnvMutation {
18786                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
18787                value: None,
18788            }])
18789        }
18790    }
18791
18792    fn agent(value: &str) -> AgentId {
18793        AgentId::new(value).unwrap()
18794    }
18795
18796    fn station_knobs_fixture() -> (NodeShared, crate::protocol::SpawnProfileDefaults) {
18797        let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
18798        let profile_revision =
18799            crate::protocol::SpawnEnvironmentProfileRevision::new("local-claude-r1").unwrap();
18800        let spawn_defaults = crate::protocol::SpawnProfileDefaults {
18801            profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
18802            revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
18803            provider: agent("claude"),
18804            mode: SessionMode::Pty,
18805            terminal_size: gate4agent_types::TerminalSize {
18806                rows: 24,
18807                columns: 80,
18808            },
18809            prompt: None,
18810            bundle_id: None,
18811            context_id: None,
18812            environment_profile_id: Some(profile_id.clone()),
18813        };
18814        let spawn_profiles = SpawnProfileRegistry::new([spawn_defaults.clone()]).unwrap();
18815        let catalog = active_registry().unwrap();
18816        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18817        let control = runtime.native_launch_profile_control();
18818        let node_profile = NodeEnvironmentProfile::new(
18819            profile_id.clone(),
18820            profile_revision,
18821            agent("claude"),
18822            [NativeLaunchProfile::new(
18823                NativeLaunchProfileId::new("local-claude-pty").unwrap(),
18824                agent("claude"),
18825                TransportKind::Pty,
18826                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
18827                Arc::new(EmptyEnvironmentResolver),
18828            )
18829            .unwrap()],
18830        )
18831        .unwrap();
18832        let (binding, native_profiles, _materialization) = node_profile.into_parts();
18833        for native_profile in native_profiles {
18834            runtime.upsert_native_launch_profile(native_profile).unwrap();
18835        }
18836        let workspace = WorkspaceConfig::new(
18837            WorkspaceId::new("primary").unwrap(),
18838            std::env::current_dir().unwrap(),
18839        )
18840        .unwrap();
18841        let shared = NodeShared::new_with_incarnation(
18842            handle,
18843            "fixture-token".to_owned(),
18844            NodeId::new("node-station-knobs-test").unwrap(),
18845            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
18846            vec![workspace],
18847            vec![agent("claude")],
18848            ProviderRuntimeStatuses::default(),
18849            None,
18850            Vec::new(),
18851            Vec::new(),
18852            spawn_profiles,
18853            Some(control),
18854            None,
18855            None,
18856            None,
18857            Vec::new(),
18858            Vec::new(),
18859            Vec::new(),
18860            Vec::new(),
18861            None,
18862            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
18863        );
18864        shared
18865            .environment_profiles
18866            .write()
18867            .unwrap_or_else(|poisoned| poisoned.into_inner())
18868            .insert(binding.id.clone(), binding);
18869        (shared, spawn_defaults)
18870    }
18871
18872    /// Empty-default catalog refuses unknown network_allowlist ids.
18873    /// Feature-off dig2-station-probe keeps browser_profile stub echo.
18874    #[test]
18875    fn resolve_environment_profile_refuses_unknown_network_allowlist() {
18876        let (shared, defaults) = station_knobs_fixture();
18877        let unknown = crate::protocol::SpawnNetworkAllowlistId::new("not-registered").unwrap();
18878        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18879        let spec = crate::protocol::SpawnSpec {
18880            target: crate::protocol::SpawnTarget {
18881                node_id: shared.node_id.clone(),
18882                workspace_id: WorkspaceId::new("primary").unwrap(),
18883                worktree_id: None,
18884            },
18885            profile_id: defaults.profile_id.clone(),
18886            expected_profile_revision: defaults.revision.clone(),
18887            overrides: crate::protocol::SpawnOverrides {
18888                network_allowlist: Some(unknown),
18889                browser_profile_id: Some(browser),
18890                ..crate::protocol::SpawnOverrides::default()
18891            },
18892            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18893            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18894                "station-knobs-unknown-allowlist",
18895            )
18896            .unwrap(),
18897            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18898        };
18899        let resolved = spec.resolve(&defaults).unwrap();
18900        let failure = shared
18901            .resolve_environment_profile(&resolved)
18902            .expect_err("unknown allowlist must refuse");
18903        assert_eq!(failure.code, NodeFailureCode::UnknownNetworkAllowlist);
18904    }
18905
18906    /// Registered network_allowlist (+ browser_profile_id when feature off)
18907    /// echo onto the env-profile receipt. Empty catalog still allows omit
18908    /// (None). Empty/whitespace refuse is type-level. Feature-off
18909    /// dig2-station-probe keeps stub browser echo; feature-on refuse is
18910    /// covered by `…_refuses_browser_profile_when_station_probe_fails`.
18911    #[test]
18912    fn resolve_environment_profile_echoes_registered_station_network_and_browser_knobs() {
18913        let (shared, defaults) = station_knobs_fixture();
18914        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18915        #[cfg(not(feature = "dig2-station-probe"))]
18916        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18917        shared
18918            .network_allowlist_catalog
18919            .write()
18920            .unwrap_or_else(|poisoned| poisoned.into_inner())
18921            .insert_id(allowlist.clone())
18922            .unwrap();
18923        let spec = crate::protocol::SpawnSpec {
18924            target: crate::protocol::SpawnTarget {
18925                node_id: shared.node_id.clone(),
18926                workspace_id: WorkspaceId::new("primary").unwrap(),
18927                worktree_id: None,
18928            },
18929            profile_id: defaults.profile_id.clone(),
18930            expected_profile_revision: defaults.revision.clone(),
18931            overrides: crate::protocol::SpawnOverrides {
18932                network_allowlist: Some(allowlist.clone()),
18933                #[cfg(not(feature = "dig2-station-probe"))]
18934                browser_profile_id: Some(browser.clone()),
18935                ..crate::protocol::SpawnOverrides::default()
18936            },
18937            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18938            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-echo")
18939                .unwrap(),
18940            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18941        };
18942        let resolved = spec.resolve(&defaults).unwrap();
18943        assert_eq!(resolved.network_allowlist.as_ref(), Some(&allowlist));
18944        #[cfg(not(feature = "dig2-station-probe"))]
18945        assert_eq!(resolved.browser_profile_id.as_ref(), Some(&browser));
18946        let receipt = shared
18947            .resolve_environment_profile(&resolved)
18948            .expect("resolve ok")
18949            .expect("env profile present");
18950        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18951        #[cfg(not(feature = "dig2-station-probe"))]
18952        assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
18953        #[cfg(feature = "dig2-station-probe")]
18954        assert_eq!(receipt.browser_profile_id, None);
18955        assert_eq!(receipt.profile_id.as_str(), "local-claude");
18956
18957        let omitted = crate::protocol::SpawnSpec {
18958            overrides: crate::protocol::SpawnOverrides::default(),
18959            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-omit")
18960                .unwrap(),
18961            ..spec
18962        };
18963        let resolved_omit = omitted.resolve(&defaults).unwrap();
18964        let bare = shared
18965            .resolve_environment_profile(&resolved_omit)
18966            .expect("resolve ok")
18967            .expect("env profile present");
18968        assert_eq!(bare.network_allowlist, None);
18969        assert_eq!(bare.browser_profile_id, None);
18970    }
18971
18972    /// Feature `dig2-station-probe`: browser_profile_id set refuses when the
18973    /// cheap station probe cannot succeed. On non-Windows (this CI host) that
18974    /// is `BrowserStationProbeUnavailable`. Never cookies on C2. Lease map is
18975    /// unit-tested separately (probe refuses before lease on Linux).
18976    #[cfg(feature = "dig2-station-probe")]
18977    #[test]
18978    fn resolve_environment_profile_refuses_browser_profile_when_station_probe_fails() {
18979        let (shared, defaults) = station_knobs_fixture();
18980        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18981        let spec = crate::protocol::SpawnSpec {
18982            target: crate::protocol::SpawnTarget {
18983                node_id: shared.node_id.clone(),
18984                workspace_id: WorkspaceId::new("primary").unwrap(),
18985                worktree_id: None,
18986            },
18987            profile_id: defaults.profile_id.clone(),
18988            expected_profile_revision: defaults.revision.clone(),
18989            overrides: crate::protocol::SpawnOverrides {
18990                browser_profile_id: Some(browser),
18991                ..crate::protocol::SpawnOverrides::default()
18992            },
18993            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18994            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18995                "station-knobs-probe-refuse",
18996            )
18997            .unwrap(),
18998            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18999        };
19000        let resolved = spec.resolve(&defaults).unwrap();
19001        let failure = shared
19002            .resolve_environment_profile(&resolved)
19003            .expect_err("browser_profile with failing probe must refuse");
19004        #[cfg(windows)]
19005        {
19006            assert!(
19007                matches!(
19008                    failure.code,
19009                    NodeFailureCode::BrowserStationUnreachable
19010                        | NodeFailureCode::BrowserStationProbeUnavailable
19011                ),
19012                "unexpected code {:?}",
19013                failure.code
19014            );
19015        }
19016        #[cfg(not(windows))]
19017        {
19018            assert_eq!(
19019                failure.code,
19020                NodeFailureCode::BrowserStationProbeUnavailable
19021            );
19022        }
19023    }
19024
19025    /// Feature on: omitting browser_profile_id still resolves (no probe).
19026    #[cfg(feature = "dig2-station-probe")]
19027    #[test]
19028    fn resolve_environment_profile_skips_station_probe_when_browser_profile_omitted() {
19029        let (shared, defaults) = station_knobs_fixture();
19030        let spec = crate::protocol::SpawnSpec {
19031            target: crate::protocol::SpawnTarget {
19032                node_id: shared.node_id.clone(),
19033                workspace_id: WorkspaceId::new("primary").unwrap(),
19034                worktree_id: None,
19035            },
19036            profile_id: defaults.profile_id.clone(),
19037            expected_profile_revision: defaults.revision.clone(),
19038            overrides: crate::protocol::SpawnOverrides::default(),
19039            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19040            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19041                "station-knobs-probe-omit",
19042            )
19043            .unwrap(),
19044            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19045        };
19046        let resolved = spec.resolve(&defaults).unwrap();
19047        let receipt = shared
19048            .resolve_environment_profile(&resolved)
19049            .expect("omit browser_profile must not probe")
19050            .expect("env profile present");
19051        assert_eq!(receipt.browser_profile_id, None);
19052    }
19053
19054    /// Feature dig2-station-probe: exclusive BrowserStationLease on NodeShared
19055    /// refuses a second holder for the same opaque profile id; release on
19056    /// remove_binding allows reacquire. Never ImportSession/cookies on C2.
19057    #[cfg(feature = "dig2-station-probe")]
19058    #[test]
19059    fn browser_station_lease_exclusive_and_clears_on_remove_binding() {
19060        let (shared, _defaults) = station_knobs_fixture();
19061        let profile = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
19062        let workspace_id = WorkspaceId::new("primary").unwrap();
19063        let address_a = SessionAddress {
19064            workspace_id: workspace_id.clone(),
19065            session: SessionKey {
19066                instance_id: AgentInstanceId(101),
19067                generation: SessionGeneration(1),
19068            },
19069        };
19070        let address_b = SessionAddress {
19071            workspace_id: workspace_id.clone(),
19072            session: SessionKey {
19073                instance_id: AgentInstanceId(102),
19074                generation: SessionGeneration(1),
19075            },
19076        };
19077        let guard_a = crate::dig2_station_lease::acquire_lease(
19078            &shared.browser_station_leases,
19079            profile.clone(),
19080            address_a.session.instance_id,
19081            1,
19082        )
19083        .expect("first lease");
19084        let busy = crate::dig2_station_lease::acquire_lease(
19085            &shared.browser_station_leases,
19086            profile.clone(),
19087            address_b.session.instance_id,
19088            2,
19089        )
19090        .expect_err("second holder must refuse");
19091        assert!(matches!(
19092            busy,
19093            crate::dig2_station_lease::BrowserStationLeaseError::ProfileBusy { .. }
19094        ));
19095        // Simulate a bound session so remove_binding clears the lease.
19096        shared.bind_session_with_policy(
19097            &address_a,
19098            ProviderRuntimePolicy::raw_pty(),
19099            None,
19100        );
19101        guard_a.retain();
19102        assert!(shared.remove_binding(&address_a).is_some());
19103        crate::dig2_station_lease::acquire_lease(
19104            &shared.browser_station_leases,
19105            profile,
19106            address_b.session.instance_id,
19107            3,
19108        )
19109        .expect("reacquire after clear")
19110        .retain();
19111        assert!(
19112            crate::dig2_station_lease::release_lease_for_holder(
19113                &shared.browser_station_leases,
19114                address_b.session.instance_id,
19115            )
19116        );
19117    }
19118
19119    /// Feature-off: no browser_station_leases field; stub echo documented in
19120    /// resolve_environment_profile_echoes_registered_station_network_and_browser_knobs.
19121    #[cfg(not(feature = "dig2-station-probe"))]
19122    #[test]
19123    fn browser_station_lease_feature_off_keeps_stub_without_lease_table() {
19124        let (shared, defaults) = station_knobs_fixture();
19125        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
19126        let spec = crate::protocol::SpawnSpec {
19127            target: crate::protocol::SpawnTarget {
19128                node_id: shared.node_id.clone(),
19129                workspace_id: WorkspaceId::new("primary").unwrap(),
19130                worktree_id: None,
19131            },
19132            profile_id: defaults.profile_id.clone(),
19133            expected_profile_revision: defaults.revision.clone(),
19134            overrides: crate::protocol::SpawnOverrides {
19135                browser_profile_id: Some(browser.clone()),
19136                ..crate::protocol::SpawnOverrides::default()
19137            },
19138            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19139            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("lease-stub-off")
19140                .unwrap(),
19141            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19142        };
19143        let resolved = spec.resolve(&defaults).unwrap();
19144        let receipt = shared
19145            .resolve_environment_profile(&resolved)
19146            .expect("stub resolve")
19147            .expect("env profile");
19148        assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
19149    }
19150
19151
19152    /// Startup file loader registers ids into the empty-default catalog.
19153    /// Feature-off dig2 probe stays stub. Never secrets on C2.
19154    #[test]
19155    fn network_allowlist_catalog_loads_from_temp_file() {
19156        let unique = std::time::SystemTime::now()
19157            .duration_since(std::time::UNIX_EPOCH)
19158            .unwrap()
19159            .as_nanos();
19160        let path = std::env::temp_dir().join(format!(
19161            "gate4agent-node-allowlist-config-{}-{unique}.txt",
19162            std::process::id(),
19163        ));
19164        std::fs::write(
19165            &path,
19166            "# opaque station allowlist ids\negress-default\nlab-egress\n",
19167        )
19168        .unwrap();
19169        let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
19170            .expect("temp catalog file must load");
19171        let _ = std::fs::remove_file(&path);
19172        assert_eq!(loaded.len(), 2);
19173        let (shared, defaults) = station_knobs_fixture();
19174        {
19175            let mut catalog = shared
19176                .network_allowlist_catalog
19177                .write()
19178                .unwrap_or_else(|poisoned| poisoned.into_inner());
19179            *catalog = loaded;
19180        }
19181        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19182        let spec = crate::protocol::SpawnSpec {
19183            target: crate::protocol::SpawnTarget {
19184                node_id: shared.node_id.clone(),
19185                workspace_id: WorkspaceId::new("primary").unwrap(),
19186                worktree_id: None,
19187            },
19188            profile_id: defaults.profile_id.clone(),
19189            expected_profile_revision: defaults.revision.clone(),
19190            overrides: crate::protocol::SpawnOverrides {
19191                network_allowlist: Some(allowlist.clone()),
19192                ..crate::protocol::SpawnOverrides::default()
19193            },
19194            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19195            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19196                "station-knobs-catalog-file",
19197            )
19198            .unwrap(),
19199            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19200        };
19201        let resolved = spec.resolve(&defaults).unwrap();
19202        let receipt = shared
19203            .resolve_environment_profile(&resolved)
19204            .expect("registered via file load")
19205            .expect("env profile present");
19206        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
19207    }
19208
19209    /// Snapshot launch inventory echoes registered catalog ids (opaque).
19210    /// Dig2 station probe is orthogonal (feature `dig2-station-probe`). Never secrets on C2.
19211    #[test]
19212    fn snapshot_launch_inventory_lists_network_allowlist_catalog_ids() {
19213        let (shared, _defaults) = station_knobs_fixture();
19214        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19215        shared
19216            .network_allowlist_catalog
19217            .write()
19218            .unwrap_or_else(|poisoned| poisoned.into_inner())
19219            .insert_id(allowlist.clone())
19220            .unwrap();
19221        let snapshot = shared.snapshot();
19222        let inventory = snapshot
19223            .launch_inventory
19224            .expect("launch inventory present");
19225        let ids = inventory
19226            .network_allowlists
19227            .expect("non-empty catalog surfaces ids");
19228        assert_eq!(ids, vec![allowlist]);
19229    }
19230
19231    /// Track B: registered id with provider_native codex_network_access refuses
19232    /// clearly for Claude (fixture provider). Unknown id still UnknownNetworkAllowlist.
19233    /// Never secrets on C2. Codex Moderate maps to `-c` overlay; RO/FullAuto refuse.
19234    #[test]
19235    fn resolve_environment_profile_refuses_unsupported_provider_native_mapping() {
19236        let (shared, defaults) = station_knobs_fixture();
19237        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-only").unwrap();
19238        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19239            id: allowlist.clone(),
19240            permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
19241                protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19242                peer: "127.0.0.1:443".to_owned(),
19243            }],
19244            provider_native: Some(
19245                crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
19246                    codex_network_access: Some(true),
19247                },
19248            ),
19249        };
19250        shared
19251            .network_allowlist_catalog
19252            .write()
19253            .unwrap_or_else(|poisoned| poisoned.into_inner())
19254            .insert(entry)
19255            .unwrap();
19256        let spec = crate::protocol::SpawnSpec {
19257            target: crate::protocol::SpawnTarget {
19258                node_id: shared.node_id.clone(),
19259                workspace_id: WorkspaceId::new("primary").unwrap(),
19260                worktree_id: None,
19261            },
19262            profile_id: defaults.profile_id.clone(),
19263            expected_profile_revision: defaults.revision.clone(),
19264            overrides: crate::protocol::SpawnOverrides {
19265                network_allowlist: Some(allowlist),
19266                ..crate::protocol::SpawnOverrides::default()
19267            },
19268            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19269            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19270                "station-knobs-unsupported-mapping",
19271            )
19272            .unwrap(),
19273            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19274        };
19275        let resolved = spec.resolve(&defaults).unwrap();
19276        assert_eq!(resolved.provider.as_str(), "claude");
19277        let failure = shared
19278            .resolve_environment_profile(&resolved)
19279            .expect_err("claude + codex_network_access must refuse");
19280        assert_eq!(
19281            failure.code,
19282            NodeFailureCode::UnsupportedNetworkAllowlistMapping
19283        );
19284    }
19285
19286    /// Codex Moderate + codex_network_access yields `-c` overlay argv.
19287    /// ReadOnly / FullAuto refuse UnsupportedNetworkAllowlistMapping.
19288    /// Never secrets on C2.
19289    #[test]
19290    fn resolve_codex_network_access_overlay_moderate_only() {
19291        let (shared, defaults) = station_knobs_fixture();
19292        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-net").unwrap();
19293        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19294            id: allowlist.clone(),
19295            permits: Vec::new(),
19296            provider_native: Some(
19297                crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
19298                    codex_network_access: Some(true),
19299                },
19300            ),
19301        };
19302        shared
19303            .network_allowlist_catalog
19304            .write()
19305            .unwrap_or_else(|poisoned| poisoned.into_inner())
19306            .insert(entry)
19307            .unwrap();
19308
19309        let overlay = shared
19310            .resolve_codex_network_access_overlay_args(
19311                &agent("codex"),
19312                SessionMode::Pty,
19313                ApprovalLevel::Moderate,
19314                Some(&allowlist),
19315            )
19316            .expect("Moderate Codex maps network_access");
19317        assert_eq!(
19318            overlay,
19319            vec![
19320                std::ffi::OsString::from("-c"),
19321                std::ffi::OsString::from("sandbox_workspace_write.network_access=true"),
19322            ]
19323        );
19324
19325        let acp = shared
19326            .resolve_codex_network_access_overlay_args(
19327                &agent("codex"),
19328                SessionMode::Acp,
19329                ApprovalLevel::Moderate,
19330                Some(&allowlist),
19331            )
19332            .unwrap();
19333        assert_eq!(acp, overlay);
19334
19335        let inline = shared
19336            .resolve_codex_network_access_overlay_args(
19337                &agent("codex"),
19338                SessionMode::Inline,
19339                ApprovalLevel::Moderate,
19340                Some(&allowlist),
19341            )
19342            .unwrap_err();
19343        assert_eq!(
19344            inline.code,
19345            NodeFailureCode::UnsupportedNetworkAllowlistMapping
19346        );
19347
19348        for level in [ApprovalLevel::ReadOnly, ApprovalLevel::FullAuto] {
19349            let spec = crate::protocol::SpawnSpec {
19350                target: crate::protocol::SpawnTarget {
19351                    node_id: shared.node_id.clone(),
19352                    workspace_id: WorkspaceId::new("primary").unwrap(),
19353                    worktree_id: None,
19354                },
19355                profile_id: defaults.profile_id.clone(),
19356                expected_profile_revision: defaults.revision.clone(),
19357                overrides: crate::protocol::SpawnOverrides {
19358                    provider: crate::protocol::SpawnOverride::Set {
19359                        value: agent("codex"),
19360                    },
19361                    approval_level: Some(level),
19362                    network_allowlist: Some(allowlist.clone()),
19363                    ..crate::protocol::SpawnOverrides::default()
19364                },
19365                deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19366                idempotency_key: crate::protocol::SpawnIdempotencyKey::new(format!(
19367                    "codex-net-refuse-{}",
19368                    match level {
19369                        ApprovalLevel::ReadOnly => "ro",
19370                        ApprovalLevel::FullAuto => "fa",
19371                        _ => "other",
19372                    }
19373                ))
19374                .unwrap(),
19375                required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19376            };
19377            let resolved = spec.resolve(&defaults).unwrap();
19378            let failure = shared
19379                .resolve_environment_profile(&resolved)
19380                .expect_err("RO/FullAuto must refuse");
19381            assert_eq!(
19382                failure.code,
19383                NodeFailureCode::UnsupportedNetworkAllowlistMapping
19384            );
19385        }
19386    }
19387
19388    /// Claude PTY + catalog permits → ProviderHome `--settings` sidecar with
19389    /// sandbox.network.allowedDomains. ACP / missing ProviderHome refuse.
19390    /// Never secrets on C2; does not invent provider_native Claude keys.
19391    #[test]
19392    fn resolve_claude_settings_network_overlay_pty_writes_sidecar() {
19393        let (shared, _defaults) = station_knobs_fixture();
19394        let allowlist =
19395            crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-domains").unwrap();
19396        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19397            id: allowlist.clone(),
19398            permits: vec![
19399                crate::network_allowlist_catalog::NetworkPermitSketch {
19400                    protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19401                    peer: "github.com:443".to_owned(),
19402                },
19403                crate::network_allowlist_catalog::NetworkPermitSketch {
19404                    protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19405                    peer: "*.npmjs.org".to_owned(),
19406                },
19407            ],
19408            provider_native: None,
19409        };
19410        shared
19411            .network_allowlist_catalog
19412            .write()
19413            .unwrap_or_else(|poisoned| poisoned.into_inner())
19414            .insert(entry)
19415            .unwrap();
19416
19417        let unique = std::time::SystemTime::now()
19418            .duration_since(std::time::UNIX_EPOCH)
19419            .unwrap()
19420            .as_nanos();
19421        let provider_home = std::env::temp_dir().join(format!(
19422            "gate4agent-claude-provider-home-{}-{unique}",
19423            std::process::id(),
19424        ));
19425        std::fs::create_dir_all(&provider_home).unwrap();
19426
19427        let overlay = shared
19428            .resolve_network_allowlist_overlay_args(
19429                &agent("claude"),
19430                SessionMode::Pty,
19431                ApprovalLevel::Moderate,
19432                Some(&allowlist),
19433                Some(provider_home.as_path()),
19434            )
19435            .expect("Claude PTY settings overlay on unix");
19436        let settings_path = provider_home.join(
19437            crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
19438        );
19439        assert_eq!(
19440            overlay,
19441            vec![
19442                std::ffi::OsString::from("--settings"),
19443                std::ffi::OsString::from(settings_path.as_os_str()),
19444            ]
19445        );
19446        let body = std::fs::read_to_string(&settings_path).unwrap();
19447        let value: serde_json::Value = serde_json::from_str(&body).unwrap();
19448        assert_eq!(value["sandbox"]["enabled"], true);
19449        assert_eq!(
19450            value["sandbox"]["network"]["allowedDomains"],
19451            serde_json::json!(["github.com", "*.npmjs.org"])
19452        );
19453        assert!(settings_path.starts_with(&provider_home));
19454
19455        let acp = shared
19456            .resolve_network_allowlist_overlay_args(
19457                &agent("claude"),
19458                SessionMode::Acp,
19459                ApprovalLevel::Moderate,
19460                Some(&allowlist),
19461                Some(provider_home.as_path()),
19462            )
19463            .expect_err("Claude ACP must refuse settings overlay");
19464        assert_eq!(
19465            acp.code,
19466            NodeFailureCode::UnsupportedNetworkAllowlistMapping
19467        );
19468
19469        let no_home = shared
19470            .resolve_network_allowlist_overlay_args(
19471                &agent("claude"),
19472                SessionMode::Pty,
19473                ApprovalLevel::Moderate,
19474                Some(&allowlist),
19475                None,
19476            )
19477            .expect_err("Claude overlay requires ProviderHome");
19478        assert_eq!(
19479            no_home.code,
19480            NodeFailureCode::UnsupportedNetworkAllowlistMapping
19481        );
19482
19483        let bare = crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-bare").unwrap();
19484        shared
19485            .network_allowlist_catalog
19486            .write()
19487            .unwrap_or_else(|poisoned| poisoned.into_inner())
19488            .insert_id(bare.clone())
19489            .unwrap();
19490        let empty = shared
19491            .resolve_network_allowlist_overlay_args(
19492                &agent("claude"),
19493                SessionMode::Pty,
19494                ApprovalLevel::Moderate,
19495                Some(&bare),
19496                Some(provider_home.as_path()),
19497            )
19498            .unwrap();
19499        assert!(empty.is_empty());
19500
19501        let _ = std::fs::remove_dir_all(&provider_home);
19502    }
19503
19504    /// Kimi / Grok with permits stay without invented argv overlays.
19505    #[test]
19506    fn resolve_kimi_grok_network_overlay_stays_empty_without_native() {
19507        let (shared, _defaults) = station_knobs_fixture();
19508        let allowlist =
19509            crate::protocol::SpawnNetworkAllowlistId::new("egress-kimi-permits").unwrap();
19510        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19511            id: allowlist.clone(),
19512            permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
19513                protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19514                peer: "example.com:443".to_owned(),
19515            }],
19516            provider_native: None,
19517        };
19518        shared
19519            .network_allowlist_catalog
19520            .write()
19521            .unwrap_or_else(|poisoned| poisoned.into_inner())
19522            .insert(entry)
19523            .unwrap();
19524        for provider in ["kimi", "grok"] {
19525            let overlay = shared
19526                .resolve_network_allowlist_overlay_args(
19527                    &agent(provider),
19528                    SessionMode::Pty,
19529                    ApprovalLevel::Moderate,
19530                    Some(&allowlist),
19531                    None,
19532                )
19533                .unwrap();
19534            assert!(
19535                overlay.is_empty(),
19536                "{provider} must not invent network argv from permits"
19537            );
19538        }
19539    }
19540
19541    /// Track B: JSON v2 catalog file loads permits; inventory still ids only.
19542    #[test]
19543    fn network_allowlist_catalog_loads_json_v2_and_lists_ids_only() {
19544        let unique = std::time::SystemTime::now()
19545            .duration_since(std::time::UNIX_EPOCH)
19546            .unwrap()
19547            .as_nanos();
19548        let path = std::env::temp_dir().join(format!(
19549            "gate4agent-node-allowlist-json-{}-{unique}.json",
19550            std::process::id(),
19551        ));
19552        std::fs::write(
19553            &path,
19554            r#"{
19555              "schema_version": 2,
19556              "entries": [
19557                {
19558                  "id": "egress-default",
19559                  "permits": [{ "protocol": "tcp", "peer": "127.0.0.1:443" }]
19560                },
19561                { "id": "lab-egress" }
19562              ]
19563            }"#,
19564        )
19565        .unwrap();
19566        let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
19567            .expect("json v2 catalog must load");
19568        let _ = std::fs::remove_file(&path);
19569        assert_eq!(loaded.len(), 2);
19570        let entry = loaded
19571            .get(&crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap())
19572            .unwrap();
19573        assert_eq!(entry.permits.len(), 1);
19574        let (shared, defaults) = station_knobs_fixture();
19575        {
19576            let mut catalog = shared
19577                .network_allowlist_catalog
19578                .write()
19579                .unwrap_or_else(|poisoned| poisoned.into_inner());
19580            *catalog = loaded;
19581        }
19582        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19583        let spec = crate::protocol::SpawnSpec {
19584            target: crate::protocol::SpawnTarget {
19585                node_id: shared.node_id.clone(),
19586                workspace_id: WorkspaceId::new("primary").unwrap(),
19587                worktree_id: None,
19588            },
19589            profile_id: defaults.profile_id.clone(),
19590            expected_profile_revision: defaults.revision.clone(),
19591            overrides: crate::protocol::SpawnOverrides {
19592                network_allowlist: Some(allowlist.clone()),
19593                ..crate::protocol::SpawnOverrides::default()
19594            },
19595            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19596            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19597                "station-knobs-catalog-json",
19598            )
19599            .unwrap(),
19600            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19601        };
19602        let resolved = spec.resolve(&defaults).unwrap();
19603        let receipt = shared
19604            .resolve_environment_profile(&resolved)
19605            .expect("permits-only entry ok for claude")
19606            .expect("env profile present");
19607        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
19608        let inventory = shared
19609            .snapshot()
19610            .launch_inventory
19611            .expect("launch inventory present");
19612        let ids = inventory.network_allowlists.expect("ids surface");
19613        assert_eq!(
19614            ids,
19615            vec![
19616                crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap(),
19617                crate::protocol::SpawnNetworkAllowlistId::new("lab-egress").unwrap(),
19618            ]
19619        );
19620    }
19621
19622
19623}
19624
19625
19626#[cfg(test)]
19627mod workspace_environment_collision_tests {
19628    use super::*;
19629    use crate::session_environment::{
19630        NodeSessionMaterializationProfile, NodeSessionPathBinding, NodeSessionPathClass,
19631    };
19632    use gate4agent_catalog::EnvMutation;
19633    use gate4agent_runtime_native::{
19634        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
19635        NativeLaunchProfileId,
19636    };
19637    use std::ffi::OsString;
19638    use std::sync::Arc;
19639
19640    struct EmptyEnvironmentResolver;
19641
19642    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19643        fn resolve_child_environment(
19644            &self,
19645        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19646            Ok(vec![EnvMutation {
19647                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19648                value: None,
19649            }])
19650        }
19651    }
19652
19653    fn agent(value: &str) -> AgentId {
19654        AgentId::new(value).unwrap()
19655    }
19656
19657    /// Two Claude ProviderHome profiles (`CLAUDE_CONFIG_DIR` A vs B) on one node.
19658    fn claude_config_dir_collision_fixture() -> (
19659        NodeShared,
19660        ResolvedEnvironmentProfileReceipt,
19661        ResolvedEnvironmentProfileReceipt,
19662    ) {
19663        let catalog = active_registry().unwrap();
19664        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19665        let control = runtime.native_launch_profile_control();
19666        let workspace = WorkspaceConfig::new(
19667            WorkspaceId::new("primary").unwrap(),
19668            std::env::current_dir().unwrap(),
19669        )
19670        .unwrap();
19671        let shared = NodeShared::new_with_incarnation(
19672            handle,
19673            "fixture-token".to_owned(),
19674            NodeId::new("node-env-collision-test").unwrap(),
19675            NodeIncarnationId::from_bytes([3; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19676            vec![workspace],
19677            vec![agent("claude")],
19678            ProviderRuntimeStatuses::default(),
19679            None,
19680            Vec::new(),
19681            Vec::new(),
19682            SpawnProfileRegistry::default(),
19683            Some(control),
19684            None,
19685            None,
19686            None,
19687            Vec::new(),
19688            Vec::new(),
19689            Vec::new(),
19690            Vec::new(),
19691            None,
19692            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19693        );
19694
19695        let mut receipts = Vec::new();
19696        for (suffix, native_suffix) in [("a", "a-pty"), ("b", "b-pty")] {
19697            let profile_id =
19698                SpawnEnvironmentProfileId::new(format!("claude-config-dir-{suffix}")).unwrap();
19699            let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(format!(
19700                "claude-config-dir-{suffix}-r1"
19701            ))
19702            .unwrap();
19703            let materialization = NodeSessionMaterializationProfile::new(
19704                Vec::new(),
19705                vec![NodeSessionPathBinding::new(
19706                    "CLAUDE_CONFIG_DIR",
19707                    NodeSessionPathClass::ProviderHome,
19708                )
19709                .unwrap()],
19710                Vec::new(),
19711            )
19712            .unwrap();
19713            let node_profile = NodeEnvironmentProfile::new_with_materialization(
19714                profile_id.clone(),
19715                profile_revision.clone(),
19716                agent("claude"),
19717                [NativeLaunchProfile::new(
19718                    NativeLaunchProfileId::new(format!("claude-config-dir-{native_suffix}"))
19719                        .unwrap(),
19720                    agent("claude"),
19721                    TransportKind::Pty,
19722                    vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19723                    Arc::new(EmptyEnvironmentResolver),
19724                )
19725                .unwrap()],
19726                Some(materialization),
19727            )
19728            .unwrap();
19729            let (binding, native_profiles, materialization) = node_profile.into_parts();
19730            for native_profile in native_profiles {
19731                runtime.upsert_native_launch_profile(native_profile).unwrap();
19732            }
19733            shared
19734                .environment_profiles
19735                .write()
19736                .unwrap_or_else(|poisoned| poisoned.into_inner())
19737                .insert(binding.id.clone(), binding);
19738            shared
19739                .environment_materialization_profiles
19740                .write()
19741                .unwrap_or_else(|poisoned| poisoned.into_inner())
19742                .insert(profile_id.clone(), materialization.unwrap());
19743            receipts.push(ResolvedEnvironmentProfileReceipt {
19744                profile_id,
19745                profile_revision,
19746                network_allowlist: None,
19747                browser_profile_id: None,
19748            });
19749        }
19750        let profile_b = receipts.pop().unwrap();
19751        let profile_a = receipts.pop().unwrap();
19752        (shared, profile_a, profile_b)
19753    }
19754
19755    #[test]
19756    fn refuse_distinct_claude_config_dir_profiles_on_same_workspace_cwd() {
19757        let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
19758        let primary = WorkspaceId::new("primary").unwrap();
19759        let worktree = WorkspaceId::new("worktree-b").unwrap();
19760        let address = SessionAddress {
19761            workspace_id: primary.clone(),
19762            session: SessionKey {
19763                instance_id: AgentInstanceId(41),
19764                generation: SessionGeneration(1),
19765            },
19766        };
19767        shared.bind_session_with_policy(
19768            &address,
19769            ProviderRuntimePolicy::raw_pty(),
19770            Some(profile_a.clone()),
19771        );
19772
19773        let refused = shared
19774            .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_b))
19775            .unwrap_err();
19776        assert_eq!(refused.code, NodeFailureCode::WorkspaceBusy);
19777
19778        // Some vs None on the same cwd is also a collision.
19779        let refused_none = shared
19780            .ensure_workspace_environment_profile_compatible(&primary, None)
19781            .unwrap_err();
19782        assert_eq!(refused_none.code, NodeFailureCode::WorkspaceBusy);
19783
19784        // Same profile on the same workspace remains allowed.
19785        shared
19786            .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_a))
19787            .unwrap();
19788
19789        // Distinct workspace id (managed-worktree parallel path) allows profile B.
19790        shared
19791            .ensure_workspace_environment_profile_compatible(&worktree, Some(&profile_b))
19792            .unwrap();
19793    }
19794
19795    #[tokio::test]
19796    async fn spawn_session_refuses_conflicting_environment_profile_before_mutation() {
19797        let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
19798        let primary = WorkspaceId::new("primary").unwrap();
19799        let address = SessionAddress {
19800            workspace_id: primary.clone(),
19801            session: SessionKey {
19802                instance_id: AgentInstanceId(41),
19803                generation: SessionGeneration(1),
19804            },
19805        };
19806        shared.bind_session_with_policy(
19807            &address,
19808            ProviderRuntimePolicy::raw_pty(),
19809            Some(profile_a),
19810        );
19811
19812        let failure = shared
19813            .spawn_session_with_deadline(
19814                primary,
19815                agent("claude"),
19816                SessionMode::Pty,
19817                gate4agent_types::TerminalSize {
19818                    rows: 24,
19819                    columns: 80,
19820                },
19821                None,
19822                Some(profile_b),
19823                None,
19824                None,
19825                None,
19826                None,
19827                SpawnRecordPolicy::ProviderIdentityOnly,
19828                Some(Instant::now() + Duration::from_secs(30)),
19829                &[],
19830                None,
19831                ApprovalLevel::default(),
19832                None,
19833                None,
19834            )
19835            .await
19836            .unwrap_err();
19837        assert_eq!(failure.code, NodeFailureCode::WorkspaceBusy);
19838        // Guard runs before instance id mint / bind.
19839        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
19840        assert!(shared.handle.snapshot().sessions.is_empty());
19841    }
19842}
19843
19844#[cfg(test)]
19845mod spawn_characterization_tests {
19846    use super::*;
19847    use crate::session_environment::{
19848        NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
19849        NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
19850        NodeSessionPathClass,
19851    };
19852    use gate4agent_catalog::EnvMutation;
19853    use gate4agent_runtime_native::{
19854        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
19855        NativeLaunchProfile, NativeLaunchProfileId,
19856    };
19857    use std::ffi::OsString;
19858    use std::sync::Arc;
19859
19860    struct EmptyEnvironmentResolver;
19861
19862    struct FixtureSecretResolver {
19863        deny: Arc<AtomicBool>,
19864    }
19865
19866    impl NodeSecretResolver for FixtureSecretResolver {
19867        fn resolve(
19868            &self,
19869            _reference: &NodeSecretReference,
19870        ) -> Result<NodeSecretValue, NodeSecretResolveError> {
19871            if self.deny.load(Ordering::Acquire) {
19872                Err(NodeSecretResolveError::Denied)
19873            } else {
19874                NodeSecretValue::text("fixture-secret")
19875                    .map_err(|_| NodeSecretResolveError::Unavailable)
19876            }
19877        }
19878    }
19879
19880    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19881        fn resolve_child_environment(
19882            &self,
19883        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19884            Ok(vec![EnvMutation {
19885                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19886                value: None,
19887            }])
19888        }
19889    }
19890
19891    fn agent(value: &str) -> AgentId {
19892        AgentId::new(value).unwrap()
19893    }
19894
19895    fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
19896        let unique = std::time::SystemTime::now()
19897            .duration_since(std::time::UNIX_EPOCH)
19898            .unwrap()
19899            .as_nanos();
19900        std::env::temp_dir().join(format!(
19901            "gate4agent-node-{label}-{}-{unique}",
19902            std::process::id(),
19903        ))
19904    }
19905
19906    fn materialization_test_shared(
19907        deny_secret: bool,
19908    ) -> (
19909        NodeShared,
19910        NativeRuntime,
19911        ResolvedEnvironmentProfileReceipt,
19912        PathBuf,
19913        Arc<AtomicBool>,
19914    ) {
19915        let workspace = WorkspaceConfig::new(
19916            WorkspaceId::new("primary").unwrap(),
19917            std::env::current_dir().unwrap(),
19918        )
19919        .unwrap();
19920        materialization_test_shared_with_workspace(deny_secret, workspace)
19921    }
19922
19923    fn materialization_test_shared_with_workspace(
19924        deny_secret: bool,
19925        workspace: WorkspaceConfig,
19926    ) -> (
19927        NodeShared,
19928        NativeRuntime,
19929        ResolvedEnvironmentProfileReceipt,
19930        PathBuf,
19931        Arc<AtomicBool>,
19932    ) {
19933        let root = temporary_workspace_root(if deny_secret {
19934            "materialization-denied"
19935        } else {
19936            "materialization-ready"
19937        });
19938        std::fs::create_dir_all(&root).unwrap();
19939        let materialization_root = root.join("session-environments");
19940        let deny = Arc::new(AtomicBool::new(deny_secret));
19941        let resolver = Arc::new(FixtureSecretResolver {
19942            deny: Arc::clone(&deny),
19943        });
19944        let materializer = SessionEnvironmentMaterializer::new(
19945            materialization_root,
19946            resolver,
19947        )
19948        .unwrap();
19949        let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
19950        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19951            "materialized-claude-r1",
19952        )
19953        .unwrap();
19954        let materialization = NodeSessionMaterializationProfile::new(
19955            vec![NodeSessionEnvironmentMutation::SetSecret {
19956                key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
19957                reference: NodeSecretReference::new("fixture-token").unwrap(),
19958            }],
19959            vec![NodeSessionPathBinding::new(
19960                "GATE4AGENT_PROVIDER_HOME",
19961                NodeSessionPathClass::ProviderHome,
19962            )
19963            .unwrap()],
19964            vec![NodeSessionFile::secret(
19965                NodeSessionPathClass::Config,
19966                "auth/token",
19967                NodeSecretReference::new("fixture-token").unwrap(),
19968            )
19969            .unwrap()],
19970        )
19971        .unwrap();
19972        let catalog = active_registry().unwrap();
19973        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19974        let control = runtime.native_launch_profile_control();
19975        let node_profile = NodeEnvironmentProfile::new_with_materialization(
19976            profile_id.clone(),
19977            profile_revision.clone(),
19978            agent("claude"),
19979            [NativeLaunchProfile::new(
19980                NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
19981                agent("claude"),
19982                TransportKind::Pty,
19983                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19984                Arc::new(EmptyEnvironmentResolver),
19985            )
19986            .unwrap()],
19987            Some(materialization),
19988        )
19989        .unwrap();
19990        let (binding, native_profiles, materialization) = node_profile.into_parts();
19991        for native_profile in native_profiles {
19992            runtime.upsert_native_launch_profile(native_profile).unwrap();
19993        }
19994        let shared = NodeShared::new_with_incarnation(
19995            handle,
19996            "fixture-token".to_owned(),
19997            NodeId::new("node-materialization-test").unwrap(),
19998            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19999            vec![workspace],
20000            vec![agent("claude")],
20001            ProviderRuntimeStatuses::default(),
20002            None,
20003            Vec::new(),
20004            Vec::new(),
20005            SpawnProfileRegistry::default(),
20006            Some(control),
20007            None,
20008            Some(materializer),
20009            None,
20010            Vec::new(),
20011            Vec::new(),
20012            Vec::new(),
20013            Vec::new(),
20014            None,
20015            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20016        );
20017        shared
20018            .environment_profiles
20019            .write()
20020            .unwrap_or_else(|poisoned| poisoned.into_inner())
20021            .insert(binding.id.clone(), binding);
20022        shared
20023            .environment_materialization_profiles
20024            .write()
20025            .unwrap_or_else(|poisoned| poisoned.into_inner())
20026            .insert(profile_id.clone(), materialization.unwrap());
20027        (
20028            shared,
20029            runtime,
20030            ResolvedEnvironmentProfileReceipt {
20031                profile_id,
20032                profile_revision,
20033                network_allowlist: None,
20034                browser_profile_id: None,
20035            },
20036            root,
20037            deny,
20038        )
20039    }
20040
20041
20042    // ---- Characterization of the spawn preparation block (pre-SpawnPreparer) ----
20043
20044    async fn characterization_spawn(
20045        shared: &NodeShared,
20046        mode: SessionMode,
20047        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
20048        required: &[ProviderRuntimeCapability],
20049        harness_mcp: Option<&PreparedHarnessMcpSpawn>,
20050    ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
20051        shared
20052            .spawn_session_with_deadline(
20053                WorkspaceId::new("primary").unwrap(),
20054                agent("claude"),
20055                mode,
20056                gate4agent_types::TerminalSize { rows: 24, columns: 80 },
20057                None,
20058                environment_profile,
20059                None,
20060                None,
20061                None,
20062                Some(ProviderRuntimePolicy::raw_pty()),
20063                SpawnRecordPolicy::ProviderIdentityOnly,
20064                Some(Instant::now() + Duration::from_secs(30)),
20065                required,
20066                harness_mcp,
20067                ApprovalLevel::default(),
20068                None,
20069                None,
20070            )
20071            .await
20072    }
20073
20074    fn assert_no_session_state(shared: &NodeShared) {
20075        assert!(shared.handle.snapshot().sessions.is_empty());
20076        assert!(shared
20077            .session_bindings
20078            .lock()
20079            .unwrap_or_else(|poisoned| poisoned.into_inner())
20080            .is_empty());
20081        assert!(shared
20082            .materializations
20083            .lock()
20084            .unwrap_or_else(|poisoned| poisoned.into_inner())
20085            .is_empty());
20086    }
20087
20088    #[tokio::test]
20089    async fn spawn_unadmitted_required_capability_refuses_before_id_mint() {
20090        let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
20091        let error = characterization_spawn(
20092            &shared,
20093            SessionMode::Pty,
20094            None,
20095            &[ProviderRuntimeCapability::SemanticReadiness],
20096            None,
20097        )
20098        .await
20099        .unwrap_err();
20100        assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
20101        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
20102        assert_no_session_state(&shared);
20103        drop(shared);
20104        drop(runtime);
20105        std::fs::remove_dir_all(root).unwrap();
20106    }
20107
20108    #[tokio::test]
20109    async fn spawn_denied_environment_materialization_fails_after_id_mint_with_no_state() {
20110        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
20111        let error = characterization_spawn(&shared, SessionMode::Pty, Some(receipt), &[], None)
20112            .await
20113            .unwrap_err();
20114        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
20115        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 2);
20116        assert_no_session_state(&shared);
20117        drop(shared);
20118        drop(runtime);
20119        std::fs::remove_dir_all(root).unwrap();
20120    }
20121
20122    #[test]
20123    fn remove_binding_returns_binding_and_is_idempotent_for_environment_session() {
20124        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20125        let address = SessionAddress {
20126            workspace_id: WorkspaceId::new("primary").unwrap(),
20127            session: SessionKey {
20128                instance_id: AgentInstanceId(77),
20129                generation: SessionGeneration(1),
20130            },
20131        };
20132        shared.bind_session_with_policy(&address, ProviderRuntimePolicy::raw_pty(), Some(receipt));
20133        let removed = shared.remove_binding(&address).expect("binding removed");
20134        assert!(removed.environment_profile.is_some());
20135        assert!(shared.remove_binding(&address).is_none());
20136        assert_no_session_state(&shared);
20137        drop(shared);
20138        drop(runtime);
20139        std::fs::remove_dir_all(root).unwrap();
20140    }
20141
20142    async fn characterization_prepared_harness(provider: &str) -> PreparedHarnessMcpSpawn {
20143        use crate::harness_mcp_proxy::{HarnessMcpProxyRegistry, ReviewedHarnessMcpProgram};
20144        use crate::protocol::{HarnessMcpActivationDigest, HarnessMcpLaunchV1, HarnessMcpReservationId};
20145        let helper = ReviewedHarnessMcpProgram::review(std::env::current_exe().unwrap())
20146            .expect("review current test executable as fixture helper");
20147        let registry = HarnessMcpProxyRegistry::new(helper);
20148        let spec = crate::protocol::SpawnSpec {
20149            target: crate::protocol::SpawnTarget {
20150                node_id: NodeId::new("node-characterization").unwrap(),
20151                workspace_id: WorkspaceId::new("primary").unwrap(),
20152                worktree_id: None,
20153            },
20154            profile_id: crate::protocol::SpawnProfileId::new("claude").unwrap(),
20155            expected_profile_revision: crate::protocol::SpawnProfileRevision::new("r1").unwrap(),
20156            overrides: crate::protocol::SpawnOverrides::default(),
20157            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
20158            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("characterization-harness").unwrap(),
20159            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
20160        };
20161        let reservation = HarnessMcpReservationId::new(format!("hmcpres_{}", "c".repeat(24))).unwrap();
20162        let digest = HarnessMcpActivationDigest::new(format!("sha256:{}", "c".repeat(64))).unwrap();
20163        let launch = HarnessMcpLaunchV1 {
20164            server_name: "fixture-harness".to_owned(),
20165            args: vec!["--session-proxy".to_owned()],
20166            endpoint_env: "FIXTURE_SESSION_ENDPOINT".to_owned(),
20167            token_env: "FIXTURE_SESSION_TOKEN".to_owned(),
20168            program_env: "FIXTURE_MCP_PROGRAM".to_owned(),
20169            trace: None,
20170            scrub_env: Vec::new(),
20171        };
20172        let expires = unix_time_ms().saturating_add(30_000);
20173        registry.arm(reservation.clone(), digest.clone(), spec.clone(), launch, expires).await.unwrap();
20174        registry
20175            .prepare_spawn(&reservation, &digest, &spec, expires, agent(provider))
20176            .unwrap()
20177    }
20178
20179    #[tokio::test]
20180    async fn spawn_harness_overlay_refuses_inline_and_provider_mismatch_with_no_state() {
20181        let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
20182        let prepared = characterization_prepared_harness("claude").await;
20183        let error = characterization_spawn(&shared, SessionMode::Inline, None, &[], Some(&prepared))
20184            .await
20185            .unwrap_err();
20186        // Inline is stopped by runtime admission before the harness check runs.
20187        assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
20188        let mismatched = characterization_prepared_harness("codex").await;
20189        let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&mismatched))
20190            .await
20191            .unwrap_err();
20192        assert_eq!(error.code, NodeFailureCode::BindingMismatch);
20193        assert_no_session_state(&shared);
20194        drop(shared);
20195        drop(runtime);
20196        std::fs::remove_dir_all(root).unwrap();
20197    }
20198
20199    #[tokio::test]
20200    async fn spawn_harness_overlay_without_launch_control_is_unavailable_with_no_state() {
20201        let (mut shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
20202        shared.native_launch_profile_control = None;
20203        let prepared = characterization_prepared_harness("claude").await;
20204        let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&prepared))
20205            .await
20206            .unwrap_err();
20207        assert_eq!(error.code, NodeFailureCode::HarnessMcpUnavailable);
20208        assert_no_session_state(&shared);
20209        drop(shared);
20210        drop(runtime);
20211        std::fs::remove_dir_all(root).unwrap();
20212    }
20213}
20214
20215#[cfg(all(test, windows))]
20216mod tests {
20217    use super::*;
20218    use gate4agent_types::ProviderActivity;
20219    use crate::session_environment::{
20220        NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
20221        NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
20222        NodeSessionPathClass,
20223    };
20224    use gate4agent_catalog::EnvMutation;
20225    use gate4agent_runtime_native::{
20226        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
20227        NativeLaunchProfile, NativeLaunchProfileId,
20228    };
20229    use std::ffi::OsString;
20230    use std::sync::Arc;
20231
20232    struct EmptyEnvironmentResolver;
20233
20234    struct FixtureSecretResolver {
20235        deny: Arc<AtomicBool>,
20236    }
20237
20238    impl NodeSecretResolver for FixtureSecretResolver {
20239        fn resolve(
20240            &self,
20241            _reference: &NodeSecretReference,
20242        ) -> Result<NodeSecretValue, NodeSecretResolveError> {
20243            if self.deny.load(Ordering::Acquire) {
20244                Err(NodeSecretResolveError::Denied)
20245            } else {
20246                NodeSecretValue::text("fixture-secret")
20247                    .map_err(|_| NodeSecretResolveError::Unavailable)
20248            }
20249        }
20250    }
20251
20252    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
20253        fn resolve_child_environment(
20254            &self,
20255        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
20256            Ok(vec![EnvMutation {
20257                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
20258                value: None,
20259            }])
20260        }
20261    }
20262
20263    fn agent(value: &str) -> AgentId {
20264        AgentId::new(value).unwrap()
20265    }
20266
20267    fn terminal_test_shared_with_mode(mode: WorktreeServiceMode) -> NodeShared {
20268        let catalog = active_registry().unwrap();
20269        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20270        let workspace = WorkspaceConfig::new(
20271            WorkspaceId::new("primary").unwrap(),
20272            std::env::current_dir().unwrap(),
20273        )
20274        .unwrap()
20275        .with_worktree_service_mode(mode);
20276        NodeShared::new(
20277            handle,
20278            "fixture-token".to_owned(),
20279            NodeId::new("node-terminal-test").unwrap(),
20280            vec![workspace],
20281            vec![agent("claude")],
20282        )
20283    }
20284
20285    fn terminal_test_shared() -> NodeShared {
20286        terminal_test_shared_with_mode(WorktreeServiceMode::Manual)
20287    }
20288
20289    #[cfg(feature = "fixture")]
20290    #[test]
20291    fn managed_worktree_v2_failure_probe_keeps_only_the_latest_code() {
20292        let probe = SpawnManagedWorktreeV2FailureProbe::default();
20293        assert_eq!(probe.latest_code(), None);
20294
20295        probe.record(Some(NodeFailureCode::ManagedWorktreeOwnershipConflict));
20296        assert_eq!(
20297            probe.latest_code(),
20298            Some(NodeFailureCode::ManagedWorktreeOwnershipConflict),
20299        );
20300
20301        probe.record(Some(NodeFailureCode::SpawnDeadlineExceeded));
20302        assert_eq!(
20303            probe.latest_code(),
20304            Some(NodeFailureCode::SpawnDeadlineExceeded),
20305        );
20306
20307        probe.record(None);
20308        assert_eq!(probe.latest_code(), None);
20309    }
20310
20311    #[test]
20312    fn launch_snapshot_is_authoritative_bounded_and_path_free() {
20313        let source = temporary_workspace_root("launch-inventory-source");
20314        let allocation = temporary_workspace_root("launch-inventory-allocation");
20315        std::fs::create_dir_all(&source).unwrap();
20316        std::fs::create_dir_all(&allocation).unwrap();
20317        let workspace_id = WorkspaceId::new("primary").unwrap();
20318        let mut workspace = WorkspaceConfig::new(workspace_id.clone(), &source)
20319            .unwrap()
20320            .with_worktree_service_mode(WorktreeServiceMode::Managed);
20321        for index in 0..MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE {
20322            workspace = workspace
20323                .with_managed_worktree_profile(
20324                    ManagedWorktreeProfile::new(
20325                        WorktreeProfileId::new(format!("profile-{index}")).unwrap(),
20326                        crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
20327                        &allocation,
20328                        "gate4agent",
20329                        "HEAD",
20330                        ManagedWorktreeRetention::RemoveWhenReleased,
20331                    )
20332                    .unwrap(),
20333                )
20334                .unwrap();
20335        }
20336        let overflow = workspace.clone().with_managed_worktree_profile(
20337            ManagedWorktreeProfile::new(
20338                WorktreeProfileId::new("overflow").unwrap(),
20339                crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
20340                &allocation,
20341                "gate4agent",
20342                "HEAD",
20343                ManagedWorktreeRetention::Retain,
20344            )
20345            .unwrap(),
20346        );
20347        assert!(matches!(
20348            overflow,
20349            Err(NodeServerError::ManagedWorktreeProfileCapacity {
20350                max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
20351                ..
20352            }),
20353        ));
20354
20355        let catalog = active_registry().unwrap();
20356        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20357        let shared = NodeShared::new(
20358            handle,
20359            "fixture-token".to_owned(),
20360            NodeId::new("node-launch-inventory").unwrap(),
20361            vec![workspace],
20362            vec![agent("claude")],
20363        );
20364        let snapshot = shared.snapshot();
20365        let launch = snapshot.launch_inventory.as_ref().unwrap();
20366        assert_eq!(launch.spawn_profiles.as_ref().unwrap().len(), 1);
20367        assert!(launch.bundles.as_ref().unwrap().is_empty());
20368        let profiles = snapshot.workspaces[0]
20369            .managed_worktree_profiles
20370            .as_ref()
20371            .unwrap();
20372        assert_eq!(
20373            snapshot.workspaces[0].worktree_service_mode,
20374            Some(WorktreeServiceMode::Managed),
20375        );
20376        assert_eq!(
20377            profiles.profiles.len(),
20378            MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
20379        );
20380        let public_inventory = serde_json::to_string(&(launch, profiles)).unwrap();
20381        for forbidden in ["allocation_root", "branch_prefix", "base", "prompt", "environment", "home"] {
20382            assert!(!public_inventory.contains(forbidden), "launch inventory leaked {forbidden}");
20383        }
20384        let legacy = snapshot_for_wire(
20385            &shared,
20386            true,
20387            true,
20388            false,
20389            false,
20390            true,
20391            false,
20392            true,
20393            false,
20394            true,
20395        );
20396        assert!(legacy.launch_inventory.is_none());
20397        assert!(legacy.workspaces[0].managed_worktree_profiles.is_none());
20398
20399        drop(shared);
20400        std::fs::remove_dir_all(source).unwrap();
20401        std::fs::remove_dir_all(allocation).unwrap();
20402    }
20403
20404    #[test]
20405    fn agent_progress_projection_omits_sensitive_provider_payloads_and_is_capability_gated() {
20406        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20407            capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
20408        }));
20409        let source = gate4agent_types::ProviderSource {
20410            family: AdapterFamily::PtySemantic,
20411            binding: AdapterBinding::new(
20412                gate4agent_types::AdapterId::new("claude").unwrap(),
20413                "fixture/v1",
20414                gate4agent_types::AdapterVerification::SyntheticFixture,
20415            )
20416            .unwrap(),
20417        };
20418        let provider = ProviderSnapshot {
20419            sequence: 17,
20420            session: Some(ProviderSessionIdentity {
20421                key: ProviderSessionKey::SessionId,
20422                id: "PRIVATE_PROVIDER_SESSION".to_owned(),
20423                transcript_path: Some(r"C:\PRIVATE_PROVIDER_PATH".to_owned()),
20424            }),
20425            model: Some("PRIVATE_MODEL".to_owned()),
20426            tools: vec!["PRIVATE_TOOL_INVENTORY".to_owned()],
20427            completed_turns: 3,
20428            usage: gate4agent_types::TokenUsage {
20429                input_tokens: 10,
20430                output_tokens: 20,
20431                cache_read_tokens: 30,
20432                cache_write_tokens: 40,
20433                reasoning_tokens: 50,
20434                context_window: Some(200_000),
20435            },
20436            lead_activity: ProviderActivity::WaitingForInput,
20437            activity: ProviderActivity::WaitingForInput,
20438            current_prompt: Some("PRIVATE_CURRENT_PROMPT".to_owned()),
20439            active_tools: (0..9)
20440                .map(|index| gate4agent_types::ActiveProviderTool {
20441                    id: format!("private-tool-id-{index}"),
20442                    name: if index == 0 {
20443                        format!("  Read\u{0000}{}", "x".repeat(80))
20444                    } else {
20445                        format!("Tool-{index}")
20446                    },
20447                    input_json: "PRIVATE_TOOL_INPUT_JSON".to_owned(),
20448                })
20449                .collect(),
20450            interactions: vec![gate4agent_types::ProviderInteraction {
20451                id: gate4agent_types::ProviderInteractionId(17),
20452                source: source.clone(),
20453                provider_request_id: Some("PRIVATE_PROVIDER_REQUEST".to_owned()),
20454                interaction_kind: ProviderInteractionKind::Approval,
20455                tool_name: " Approve\u{0007}Tool ".to_owned(),
20456                prompt: "PRIVATE_INTERACTION_PROMPT".to_owned(),
20457                agent_id: Some("PRIVATE_INTERACTION_AGENT".to_owned()),
20458                resume_lead_activity: Some(ProviderActivity::Working),
20459                status: ProviderInteractionStatus::Pending,
20460            }],
20461            subagents: vec![gate4agent_types::ProviderSubagent {
20462                source: source.clone(),
20463                provider_agent_id: "PRIVATE_SUBAGENT_ID".to_owned(),
20464                agent_type: Some("PRIVATE_SUBAGENT_TYPE".to_owned()),
20465                description: Some("PRIVATE_SUBAGENT_DESCRIPTION".to_owned()),
20466            }],
20467            sources: vec![gate4agent_types::ProviderSourceCursor {
20468                source,
20469                sequence: 17,
20470                gap_count: 2,
20471                stale: true,
20472            }],
20473            last_event: Some(ProviderEvent::Error {
20474                message: "PRIVATE_PROVIDER_ERROR".to_owned(),
20475            }),
20476            gap_count: 2,
20477            stale: true,
20478        };
20479        let address = terminal_address(3);
20480        let entry = agent_progress_from_provider_snapshot(address.clone(), &provider).unwrap();
20481        assert_eq!(entry.address, address);
20482        assert_eq!(entry.progress.provider_sequence, 17);
20483        assert_eq!(entry.progress.active_tool_count, 9);
20484        assert_eq!(
20485            entry.progress.active_tool_labels.len(),
20486            MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS,
20487        );
20488        assert_eq!(entry.progress.subagent_count, 1);
20489        assert_eq!(entry.progress.last_event_kind, Some(AgentProgressEventKindV1::Error));
20490        assert!(entry.progress.truncated);
20491        let encoded = serde_json::to_string(&entry).unwrap();
20492        for forbidden in [
20493            "PRIVATE_PROVIDER_SESSION",
20494            "PRIVATE_PROVIDER_PATH",
20495            "PRIVATE_MODEL",
20496            "PRIVATE_TOOL_INVENTORY",
20497            "PRIVATE_CURRENT_PROMPT",
20498            "PRIVATE_TOOL_INPUT_JSON",
20499            "PRIVATE_PROVIDER_REQUEST",
20500            "PRIVATE_INTERACTION_PROMPT",
20501            "PRIVATE_INTERACTION_AGENT",
20502            "PRIVATE_SUBAGENT_ID",
20503            "PRIVATE_SUBAGENT_TYPE",
20504            "PRIVATE_SUBAGENT_DESCRIPTION",
20505            "PRIVATE_PROVIDER_ERROR",
20506            "input_json",
20507            "current_prompt",
20508            "session_id",
20509        ] {
20510            assert!(!encoded.contains(forbidden), "agent progress leaked {forbidden}");
20511        }
20512
20513        let snapshot = NodeSnapshot {
20514            node_id: NodeId::new("node-progress-test").unwrap(),
20515            enabled_providers: vec![agent("claude")],
20516            provider_runtime_statuses: ProviderRuntimeStatuses::default(),
20517            workspaces: Vec::new(),
20518            session_records: Vec::new(),
20519            managed_worktrees: Vec::new(),
20520            launch_inventory: None,
20521            agent_progress: vec![entry],
20522        };
20523        let mut legacy_reply = ResponseEnvelope {
20524            request_id: 1,
20525            result: Ok(NodeResponse::Snapshot {
20526                event_sequence: 0,
20527                controller: None,
20528                snapshot: snapshot.clone(),
20529            }),
20530        };
20531        project_response_without_agent_progress(&mut legacy_reply);
20532        let Ok(NodeResponse::Snapshot { snapshot: legacy, .. }) = legacy_reply.result else {
20533            panic!("agent progress projection changed response kind");
20534        };
20535        assert!(legacy.agent_progress.is_empty());
20536        assert_eq!(snapshot.agent_progress.len(), 1);
20537    }
20538
20539    fn terminal_address(generation: u64) -> SessionAddress {
20540        SessionAddress {
20541            workspace_id: WorkspaceId::new("primary").unwrap(),
20542            session: SessionKey {
20543                instance_id: AgentInstanceId(41),
20544                generation: SessionGeneration(generation),
20545            },
20546        }
20547    }
20548
20549    fn spawn_spec_fixture() -> SpawnSpec {
20550        SpawnSpec {
20551            target: crate::protocol::SpawnTarget {
20552                node_id: NodeId::new("node-terminal-test").unwrap(),
20553                workspace_id: WorkspaceId::new("primary").unwrap(),
20554                worktree_id: None,
20555            },
20556            profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
20557            expected_profile_revision: crate::protocol::SpawnProfileRevision::new("builtin-v1")
20558                .unwrap(),
20559            overrides: crate::protocol::SpawnOverrides::default(),
20560            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
20561            idempotency_key: SpawnIdempotencyKey::new("spawn-spec-fixture").unwrap(),
20562            required_capabilities: SpawnRequiredCapabilities::default(),
20563        }
20564    }
20565
20566    fn environment_profile_test_shared(
20567    ) -> (NodeShared, NativeRuntime, ResolvedEnvironmentProfileReceipt) {
20568        let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
20569        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20570            "local-claude-r1",
20571        )
20572        .unwrap();
20573        let spawn_profiles = SpawnProfileRegistry::new([
20574            crate::protocol::SpawnProfileDefaults {
20575                profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
20576                revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
20577                provider: agent("claude"),
20578                mode: SessionMode::Pty,
20579                terminal_size: gate4agent_types::TerminalSize {
20580                    rows: 24,
20581                    columns: 80,
20582                },
20583                prompt: None,
20584                bundle_id: None,
20585                context_id: None,
20586                environment_profile_id: Some(profile_id.clone()),
20587            },
20588        ])
20589        .unwrap();
20590        let catalog = active_registry().unwrap();
20591        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20592        let control = runtime.native_launch_profile_control();
20593        let node_profile = NodeEnvironmentProfile::new(
20594            profile_id.clone(),
20595            profile_revision.clone(),
20596            agent("claude"),
20597            [NativeLaunchProfile::new(
20598                NativeLaunchProfileId::new("local-claude-pty").unwrap(),
20599                agent("claude"),
20600                TransportKind::Pty,
20601                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20602                Arc::new(EmptyEnvironmentResolver),
20603            )
20604            .unwrap()],
20605        )
20606        .unwrap();
20607        let (binding, native_profiles, _materialization) = node_profile.into_parts();
20608        for native_profile in native_profiles {
20609            runtime.upsert_native_launch_profile(native_profile).unwrap();
20610        }
20611        let workspace = WorkspaceConfig::new(
20612            WorkspaceId::new("primary").unwrap(),
20613            std::env::current_dir().unwrap(),
20614        )
20615        .unwrap();
20616        let shared = NodeShared::new_with_incarnation(
20617            handle,
20618            "fixture-token".to_owned(),
20619            NodeId::new("node-terminal-test").unwrap(),
20620            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20621            vec![workspace],
20622            vec![agent("claude")],
20623            ProviderRuntimeStatuses::default(),
20624            None,
20625            Vec::new(),
20626            Vec::new(),
20627            spawn_profiles,
20628            Some(control),
20629            None,
20630            None,
20631            None,
20632            Vec::new(),
20633            Vec::new(),
20634            Vec::new(),
20635            Vec::new(),
20636            None,
20637            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20638        );
20639        shared
20640            .environment_profiles
20641            .write()
20642            .unwrap_or_else(|poisoned| poisoned.into_inner())
20643            .insert(binding.id.clone(), binding);
20644        (
20645            shared,
20646            runtime,
20647            ResolvedEnvironmentProfileReceipt {
20648                profile_id,
20649                profile_revision,
20650                network_allowlist: None,
20651                browser_profile_id: None,
20652            },
20653        )
20654    }
20655
20656    fn materialization_test_shared(
20657        deny_secret: bool,
20658    ) -> (
20659        NodeShared,
20660        NativeRuntime,
20661        ResolvedEnvironmentProfileReceipt,
20662        PathBuf,
20663        Arc<AtomicBool>,
20664    ) {
20665        let workspace = WorkspaceConfig::new(
20666            WorkspaceId::new("primary").unwrap(),
20667            std::env::current_dir().unwrap(),
20668        )
20669        .unwrap();
20670        materialization_test_shared_with_workspace(deny_secret, workspace)
20671    }
20672
20673    fn materialization_test_shared_with_workspace(
20674        deny_secret: bool,
20675        workspace: WorkspaceConfig,
20676    ) -> (
20677        NodeShared,
20678        NativeRuntime,
20679        ResolvedEnvironmentProfileReceipt,
20680        PathBuf,
20681        Arc<AtomicBool>,
20682    ) {
20683        let root = temporary_workspace_root(if deny_secret {
20684            "materialization-denied"
20685        } else {
20686            "materialization-ready"
20687        });
20688        std::fs::create_dir_all(&root).unwrap();
20689        let materialization_root = root.join("session-environments");
20690        let deny = Arc::new(AtomicBool::new(deny_secret));
20691        let resolver = Arc::new(FixtureSecretResolver {
20692            deny: Arc::clone(&deny),
20693        });
20694        let materializer = SessionEnvironmentMaterializer::new(
20695            materialization_root,
20696            resolver,
20697        )
20698        .unwrap();
20699        let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
20700        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20701            "materialized-claude-r1",
20702        )
20703        .unwrap();
20704        let materialization = NodeSessionMaterializationProfile::new(
20705            vec![NodeSessionEnvironmentMutation::SetSecret {
20706                key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
20707                reference: NodeSecretReference::new("fixture-token").unwrap(),
20708            }],
20709            vec![NodeSessionPathBinding::new(
20710                "GATE4AGENT_PROVIDER_HOME",
20711                NodeSessionPathClass::ProviderHome,
20712            )
20713            .unwrap()],
20714            vec![NodeSessionFile::secret(
20715                NodeSessionPathClass::Config,
20716                "auth/token",
20717                NodeSecretReference::new("fixture-token").unwrap(),
20718            )
20719            .unwrap()],
20720        )
20721        .unwrap();
20722        let catalog = active_registry().unwrap();
20723        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20724        let control = runtime.native_launch_profile_control();
20725        let node_profile = NodeEnvironmentProfile::new_with_materialization(
20726            profile_id.clone(),
20727            profile_revision.clone(),
20728            agent("claude"),
20729            [NativeLaunchProfile::new(
20730                NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
20731                agent("claude"),
20732                TransportKind::Pty,
20733                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20734                Arc::new(EmptyEnvironmentResolver),
20735            )
20736            .unwrap()],
20737            Some(materialization),
20738        )
20739        .unwrap();
20740        let (binding, native_profiles, materialization) = node_profile.into_parts();
20741        for native_profile in native_profiles {
20742            runtime.upsert_native_launch_profile(native_profile).unwrap();
20743        }
20744        let shared = NodeShared::new_with_incarnation(
20745            handle,
20746            "fixture-token".to_owned(),
20747            NodeId::new("node-materialization-test").unwrap(),
20748            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20749            vec![workspace],
20750            vec![agent("claude")],
20751            ProviderRuntimeStatuses::default(),
20752            None,
20753            Vec::new(),
20754            Vec::new(),
20755            SpawnProfileRegistry::default(),
20756            Some(control),
20757            None,
20758            Some(materializer),
20759            None,
20760            Vec::new(),
20761            Vec::new(),
20762            Vec::new(),
20763            Vec::new(),
20764            None,
20765            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20766        );
20767        shared
20768            .environment_profiles
20769            .write()
20770            .unwrap_or_else(|poisoned| poisoned.into_inner())
20771            .insert(binding.id.clone(), binding);
20772        shared
20773            .environment_materialization_profiles
20774            .write()
20775            .unwrap_or_else(|poisoned| poisoned.into_inner())
20776            .insert(profile_id.clone(), materialization.unwrap());
20777        (
20778            shared,
20779            runtime,
20780            ResolvedEnvironmentProfileReceipt {
20781                profile_id,
20782                profile_revision,
20783                network_allowlist: None,
20784                browser_profile_id: None,
20785            },
20786            root,
20787            deny,
20788        )
20789    }
20790
20791    #[cfg(feature = "fixture")]
20792    #[derive(Clone, Copy)]
20793    enum CodexHomeProfile {
20794        Missing,
20795        WrongClass,
20796        Exact,
20797    }
20798
20799    #[cfg(feature = "fixture")]
20800    fn codex_bundle_test_shared(
20801        home_profile: CodexHomeProfile,
20802    ) -> (
20803        NodeShared,
20804        NativeRuntime,
20805        ResolvedEnvironmentProfileReceipt,
20806        NodeBundle,
20807        PathBuf,
20808    ) {
20809        const ROOT_MANIFEST: &[u8] = br#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"review-tools"}"#;
20810        const CLAUDE_MANIFEST: &[u8] =
20811            br#"{"name":"review-tools","version":"1.0.0","description":"Review helpers"}"#;
20812        const SKILL: &[u8] = b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n";
20813        const DIGEST: &str =
20814            "sha256:941f20868a6ef49b4329bf1bb1368515763f5b64d8279b05cd9700966083d707";
20815
20816        let (shared, mut runtime, _claude_receipt, root, _deny) =
20817            materialization_test_shared(false);
20818        let profile_id = SpawnEnvironmentProfileId::new(match home_profile {
20819            CodexHomeProfile::Missing => "codex-home-missing",
20820            CodexHomeProfile::WrongClass => "codex-home-wrong",
20821            CodexHomeProfile::Exact => "codex-home-exact",
20822        })
20823        .unwrap();
20824        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20825            "codex-home-r1",
20826        )
20827        .unwrap();
20828        let materialization = match home_profile {
20829            CodexHomeProfile::Missing => None,
20830            CodexHomeProfile::WrongClass | CodexHomeProfile::Exact => {
20831                Some(NodeSessionMaterializationProfile::new(
20832                    Vec::new(),
20833                    vec![NodeSessionPathBinding::new(
20834                        "CODEX_HOME",
20835                        if matches!(home_profile, CodexHomeProfile::Exact) {
20836                            NodeSessionPathClass::ProviderHome
20837                        } else {
20838                            NodeSessionPathClass::Config
20839                        },
20840                    )
20841                    .unwrap()],
20842                    Vec::new(),
20843                )
20844                .unwrap())
20845            }
20846        };
20847        let node_profile = NodeEnvironmentProfile::new_with_materialization(
20848            profile_id.clone(),
20849            profile_revision.clone(),
20850            agent("codex"),
20851            [NativeLaunchProfile::new(
20852                NativeLaunchProfileId::new(format!("{profile_id}-pty")).unwrap(),
20853                agent("codex"),
20854                TransportKind::Pty,
20855                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20856                Arc::new(EmptyEnvironmentResolver),
20857            )
20858            .unwrap()],
20859            materialization,
20860        )
20861        .unwrap();
20862        let (binding, native_profiles, materialization) = node_profile.into_parts();
20863        for native_profile in native_profiles {
20864            runtime.upsert_native_launch_profile(native_profile).unwrap();
20865        }
20866        shared
20867            .environment_profiles
20868            .write()
20869            .unwrap_or_else(|poisoned| poisoned.into_inner())
20870            .insert(binding.id.clone(), binding);
20871        if let Some(materialization) = materialization {
20872            shared
20873                .environment_materialization_profiles
20874                .write()
20875                .unwrap_or_else(|poisoned| poisoned.into_inner())
20876                .insert(profile_id.clone(), materialization);
20877        }
20878
20879        let bundle_source = root.join(format!("bundle-source-{profile_id}"));
20880        std::fs::create_dir_all(bundle_source.join(".claude-plugin")).unwrap();
20881        std::fs::create_dir_all(bundle_source.join("skills/review-code")).unwrap();
20882        std::fs::write(bundle_source.join("plugin.json"), ROOT_MANIFEST).unwrap();
20883        std::fs::write(
20884            bundle_source.join(".claude-plugin/plugin.json"),
20885            CLAUDE_MANIFEST,
20886        )
20887        .unwrap();
20888        std::fs::write(bundle_source.join("skills/review-code/SKILL.md"), SKILL).unwrap();
20889        crate::bundle_catalog::protect_bundle_source_tree_fixture(&bundle_source).unwrap();
20890        let bundle = NodeBundle::new(
20891            crate::protocol::SpawnBundleId::new("review-tools").unwrap(),
20892            crate::protocol::SpawnBundleRevision::new("review-tools-r1").unwrap(),
20893            crate::protocol::SpawnBundleDigest::new(DIGEST).unwrap(),
20894            &bundle_source,
20895        )
20896        .unwrap();
20897        *shared
20898            .bundle_catalog
20899            .write()
20900            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
20901            BundleCatalog::new([bundle.clone()]).unwrap();
20902        (
20903            shared,
20904            runtime,
20905            ResolvedEnvironmentProfileReceipt {
20906                profile_id,
20907                profile_revision,
20908                network_allowlist: None,
20909                browser_profile_id: None,
20910            },
20911            bundle,
20912            root,
20913        )
20914    }
20915
20916    #[cfg(feature = "fixture")]
20917    #[test]
20918    fn codex_bundle_preflight_rejects_missing_or_wrong_home_before_materialization() {
20919        for home_profile in [CodexHomeProfile::Missing, CodexHomeProfile::WrongClass] {
20920            let (shared, runtime, receipt, bundle, root) =
20921                codex_bundle_test_shared(home_profile);
20922            let error = shared
20923                .bundle_layout(
20924                    &agent("codex"),
20925                    SessionMode::Pty,
20926                    Some(&receipt),
20927                    &bundle,
20928                )
20929                .unwrap_err();
20930            assert_eq!(error.code, NodeFailureCode::BundleBindingMismatch);
20931            assert!(shared
20932                .materializations
20933                .lock()
20934                .unwrap_or_else(|poisoned| poisoned.into_inner())
20935                .is_empty());
20936            let materialization_root = root.join("session-environments");
20937            assert!(!std::fs::read_dir(&materialization_root)
20938                .unwrap()
20939                .filter_map(Result::ok)
20940                .any(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())));
20941            drop(shared);
20942            drop(runtime);
20943            let _ = std::fs::remove_dir_all(root);
20944        }
20945    }
20946
20947    #[cfg(feature = "fixture")]
20948    #[test]
20949    fn codex_bundle_exact_home_profile_materializes_only_the_skill_tree() {
20950        let (shared, runtime, receipt, bundle, root) =
20951            codex_bundle_test_shared(CodexHomeProfile::Exact);
20952        let address = SessionAddress {
20953            workspace_id: WorkspaceId::new("primary").unwrap(),
20954            session: SessionKey {
20955                instance_id: AgentInstanceId(310),
20956                generation: SessionGeneration(1),
20957            },
20958        };
20959        let (overlay, guard) = shared
20960            .prepare_session_materialization(
20961                &address,
20962                &agent("codex"),
20963                SessionMode::Pty,
20964                Some(&receipt),
20965                Some(&bundle.receipt()),
20966                None,
20967                None,
20968            )
20969            .unwrap()
20970            .unwrap();
20971        assert!(matches!(overlay, Some(PreparedNativeLaunchOverlay::Instance(_))));
20972        let ownership = shared
20973            .materializations
20974            .lock()
20975            .unwrap_or_else(|poisoned| poisoned.into_inner())
20976            .get(guard.id().unwrap())
20977            .unwrap()
20978            .clone();
20979        let (profile, _, _) = shared
20980            .materialization_profile(
20981                &agent("codex"),
20982                SessionMode::Pty,
20983                Some(&receipt),
20984                Some(&bundle.receipt()),
20985                None,
20986            )
20987            .unwrap()
20988            .unwrap();
20989        let environment = shared
20990            .session_environment_materializer
20991            .as_ref()
20992            .unwrap()
20993            .resolve_environment(&ownership, &profile)
20994            .unwrap();
20995        assert!(environment.iter().any(|mutation| {
20996            mutation.key == OsString::from("CODEX_HOME")
20997                && mutation.value.as_deref() == Some(ownership.provider_home().as_os_str())
20998        }));
20999        assert_eq!(
21000            std::fs::read(ownership.provider_home().join("skills/review-code/SKILL.md"))
21001                .unwrap(),
21002            b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n",
21003        );
21004        assert!(std::fs::read_dir(ownership.bundle_root())
21005            .unwrap()
21006            .next()
21007            .is_none());
21008        drop(guard);
21009        drop(shared);
21010        drop(runtime);
21011        let _ = std::fs::remove_dir_all(root);
21012    }
21013
21014    #[cfg(feature = "fixture")]
21015    #[test]
21016    fn codex_cold_record_resume_revalidates_the_isolated_skill_tree() {
21017        let (shared, runtime, receipt, bundle, root) =
21018            codex_bundle_test_shared(CodexHomeProfile::Exact);
21019        let bundle_receipt = bundle.receipt();
21020        let address = SessionAddress {
21021            workspace_id: WorkspaceId::new("primary").unwrap(),
21022            session: SessionKey {
21023                instance_id: AgentInstanceId(311),
21024                generation: SessionGeneration(1),
21025            },
21026        };
21027        let (_overlay, guard) = shared
21028            .prepare_session_materialization(
21029                &address,
21030                &agent("codex"),
21031                SessionMode::Pty,
21032                Some(&receipt),
21033                Some(&bundle_receipt),
21034                None,
21035                None,
21036            )
21037            .unwrap()
21038            .unwrap();
21039        let materialization_id = guard.id().unwrap().clone();
21040        let skill_path = shared
21041            .materializations
21042            .lock()
21043            .unwrap_or_else(|poisoned| poisoned.into_inner())
21044            .get(&materialization_id)
21045            .unwrap()
21046            .provider_home()
21047            .join("skills/review-code/SKILL.md");
21048        let record_id = shared
21049            .bind_spawn_session_with_materialization(
21050                &address,
21051                agent("codex"),
21052                SessionMode::Pty,
21053                ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap(),
21054                SpawnRecordPolicy::ProviderIdentityOnly,
21055                Some(receipt.clone()),
21056                Some(bundle_receipt.clone()),
21057                None,
21058                Some(materialization_id.clone()),
21059            )
21060            .unwrap()
21061            .unwrap();
21062        guard.retain();
21063        std::fs::write(skill_path, b"changed-after-record-bind").unwrap();
21064
21065        let error = match shared.resolve_record_materialization(
21066                &record_id,
21067                &agent("codex"),
21068                SessionMode::Pty,
21069                Some(&receipt),
21070                Some(&bundle_receipt),
21071                None,
21072            ) {
21073                Ok(_) => panic!("changed Codex skill tree unexpectedly revalidated"),
21074                Err(error) => error,
21075            };
21076        assert_eq!(error.code, NodeFailureCode::BundleMaterializationFailed);
21077        assert_eq!(
21078            shared
21079                .materializations
21080                .lock()
21081                .unwrap_or_else(|poisoned| poisoned.into_inner())
21082                .get(&materialization_id)
21083                .unwrap()
21084                .state(),
21085            MaterializationState::RecoveryRequired,
21086        );
21087        drop(shared);
21088        drop(runtime);
21089        let _ = std::fs::remove_dir_all(root);
21090    }
21091
21092    fn bind_record_owned_materialization_fixture(
21093        shared: &NodeShared,
21094        receipt: &ResolvedEnvironmentProfileReceipt,
21095        instance_id: u64,
21096    ) -> (SessionAddress, SessionRecordId, MaterializationId, PathBuf) {
21097        let address = SessionAddress {
21098            workspace_id: WorkspaceId::new("primary").unwrap(),
21099            session: SessionKey {
21100                instance_id: AgentInstanceId(instance_id),
21101                generation: SessionGeneration(1),
21102            },
21103        };
21104        let (overlay, materialization_guard) = shared
21105            .prepare_session_materialization(
21106                &address,
21107                &agent("claude"),
21108                SessionMode::Pty,
21109                Some(receipt),
21110                None,
21111                None,
21112                None,
21113            )
21114            .unwrap()
21115            .unwrap();
21116        let materialization_id = materialization_guard.id().unwrap().clone();
21117        let materialization_root = shared
21118            .materializations
21119            .lock()
21120            .unwrap_or_else(|poisoned| poisoned.into_inner())
21121            .get(&materialization_id)
21122            .unwrap()
21123            .root()
21124            .to_path_buf();
21125        let selection = shared
21126            .select_environment_profile(
21127                address.session.instance_id,
21128                &agent("claude"),
21129                SessionMode::Pty,
21130                Some(receipt),
21131            )
21132            .unwrap()
21133            .unwrap();
21134        assert!(shared
21135            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21136            .unwrap()
21137            .is_none());
21138        let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
21139        let record_id = shared
21140            .bind_spawn_session_with_materialization(
21141                &address,
21142                agent("claude"),
21143                SessionMode::Pty,
21144                policy,
21145                SpawnRecordPolicy::ProviderIdentityOnly,
21146                Some(receipt.clone()),
21147                None,
21148                None,
21149                Some(materialization_id.clone()),
21150            )
21151            .unwrap()
21152            .unwrap();
21153        selection.retain();
21154        materialization_guard.retain();
21155        (address, record_id, materialization_id, materialization_root)
21156    }
21157
21158    #[test]
21159    fn child_environment_profile_capability_is_state_aware_and_legacy_safe() {
21160        let (shared, _runtime, environment_profile) = environment_profile_test_shared();
21161        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21162            capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
21163        }));
21164        let support = node_compatibility_support_for_manifest(&[], &[]).unwrap();
21165        assert_eq!(support.state_schema.versions.maximum(), NODE_STATE_SCHEMA_V10);
21166        let mut spec = spawn_spec_fixture();
21167        spec.expected_profile_revision =
21168            crate::protocol::SpawnProfileRevision::new("test-r1").unwrap();
21169        assert!(matches!(
21170            &spec.overrides.environment_profile_id,
21171            crate::protocol::SpawnOverride::Inherit,
21172        ));
21173        assert!(!NodeRequest::SpawnSpec { spec: spec.clone() }
21174            .requires_child_environment_profile_capability());
21175        assert!(request_requires_child_environment_profile(
21176            &shared,
21177            &NodeRequest::SpawnSpec { spec: spec.clone() },
21178        ));
21179
21180        let address = terminal_address(1);
21181        shared.bind_session_with_policy(
21182            &address,
21183            ProviderRuntimePolicy::raw_pty(),
21184            Some(environment_profile.clone()),
21185        );
21186        assert!(request_requires_child_environment_profile(
21187            &shared,
21188            &NodeRequest::Input {
21189                session: address.clone(),
21190                text: "x".to_owned(),
21191            },
21192        ));
21193
21194        let mut record = record("claude", "profile-record");
21195        record.active_session = Some(address.clone());
21196        record.environment_profile = Some(environment_profile.clone());
21197        let record_id = record.record_id.clone();
21198        shared.insert_record(record.clone()).unwrap();
21199        assert!(request_requires_child_environment_profile(
21200            &shared,
21201            &NodeRequest::ResumeSessionRecord {
21202                record_id,
21203                terminal_size: gate4agent_types::TerminalSize {
21204                    rows: 24,
21205                    columns: 80,
21206                },
21207                initial_prompt: None,
21208            },
21209        ));
21210
21211        let snapshot = snapshot_for_wire(
21212            &shared, false, true, true, true, false, false, false, false, true,
21213        );
21214        assert_eq!(snapshot.session_records.len(), 1);
21215        assert!(snapshot.session_records[0].environment_profile.is_none());
21216        let event = project_event_without_child_environment_profile(NodeEventEnvelope {
21217            sequence: 1,
21218            event: NodeEvent::SessionRecordUpserted { record },
21219        });
21220        let NodeEvent::SessionRecordUpserted { record } = event.event else {
21221            panic!("record projection changed the event kind");
21222        };
21223        assert!(record.environment_profile.is_none());
21224
21225        let resolved = shared.resolve_spawn_spec(&spec).unwrap();
21226        let mut reply = ResponseEnvelope {
21227            request_id: 1,
21228            result: Ok(NodeResponse::SpawnSpecAccepted {
21229                receipt: resolved.receipt_with_environment(
21230                    shared.incarnation_id,
21231                    address,
21232                    Some(environment_profile),
21233                ),
21234            }),
21235        };
21236        project_response_without_child_environment_profile(&mut reply);
21237        assert_eq!(
21238            reply.result.unwrap_err().code,
21239            NodeFailureCode::UnsupportedCapability,
21240        );
21241    }
21242
21243    #[test]
21244    fn spawn_profile_environment_authority_is_exact_and_legacy_safe() {
21245        let (shared, _runtime, environment_profile) = environment_profile_test_shared();
21246        let snapshot = shared.snapshot();
21247        let profiles = snapshot
21248            .launch_inventory
21249            .as_ref()
21250            .and_then(|inventory| inventory.spawn_profiles.as_ref())
21251            .unwrap();
21252        assert_eq!(profiles.len(), 1);
21253        assert_eq!(profiles[0].environment_profile, Some(environment_profile));
21254        assert!(snapshot.requires_child_environment_profile_capability());
21255
21256        let mut legacy = snapshot.clone();
21257        clear_snapshot_child_environment_profiles(&mut legacy);
21258        assert!(legacy
21259            .launch_inventory
21260            .as_ref()
21261            .unwrap()
21262            .spawn_profiles
21263            .as_ref()
21264            .unwrap()[0]
21265            .environment_profile
21266            .is_none());
21267        assert!(!legacy.requires_child_environment_profile_capability());
21268
21269        let profile = shared.spawn_profiles.iter().next().unwrap().clone();
21270        let bindings = shared
21271            .environment_profiles
21272            .read()
21273            .unwrap_or_else(|poisoned| poisoned.into_inner())
21274            .clone();
21275        let mut provider_mismatch = bindings.clone();
21276        provider_mismatch
21277            .values_mut()
21278            .next()
21279            .unwrap()
21280            .provider = agent("codex");
21281        assert!(resolved_spawn_profile_summary(&profile, &provider_mismatch).is_none());
21282        let mut mode_mismatch = profile;
21283        mode_mismatch.mode = SessionMode::Inline;
21284        assert!(resolved_spawn_profile_summary(&mode_mismatch, &bindings).is_none());
21285
21286        shared
21287            .environment_profiles
21288            .write()
21289            .unwrap_or_else(|poisoned| poisoned.into_inner())
21290            .clear();
21291        assert!(shared
21292            .snapshot()
21293            .launch_inventory
21294            .unwrap()
21295            .spawn_profiles
21296            .unwrap()
21297            .is_empty());
21298    }
21299
21300    #[test]
21301    fn session_bundle_capability_is_state_aware_and_legacy_safe() {
21302        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21303            capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
21304        }));
21305        let shared = terminal_test_shared();
21306        let bundle = ResolvedBundleReceipt {
21307            id: crate::protocol::SpawnBundleId::new("review-bundle").unwrap(),
21308            revision: crate::protocol::SpawnBundleRevision::new("r1").unwrap(),
21309            digest: crate::protocol::SpawnBundleDigest::new(format!(
21310                "sha256:{}",
21311                "0".repeat(64),
21312            ))
21313            .unwrap(),
21314        };
21315        let mut bundled = record("claude", "bundle-record");
21316        bundled.bundle = Some(bundle.clone());
21317        let record_id = bundled.record_id.clone();
21318        shared.insert_record(bundled.clone()).unwrap();
21319        assert!(request_requires_session_bundle(
21320            &shared,
21321            &NodeRequest::ForgetSessionRecord {
21322                record_id: record_id.clone(),
21323            },
21324        ));
21325
21326        let snapshot = snapshot_for_wire(
21327            &shared, false, true, true, true, true, false, false, false, true,
21328        );
21329        assert!(snapshot.session_records[0].bundle.is_none());
21330        let launch = snapshot.launch_inventory.as_ref().unwrap();
21331        assert!(launch.spawn_profiles.is_some());
21332        assert!(launch.bundles.is_none());
21333        let event = project_event_without_session_bundle(NodeEventEnvelope {
21334            sequence: 1,
21335            event: NodeEvent::SessionRecordUpserted { record: bundled },
21336        });
21337        let NodeEvent::SessionRecordUpserted { record } = event.event else {
21338            panic!("record projection changed the event kind");
21339        };
21340        assert!(record.bundle.is_none());
21341
21342        let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
21343        let mut reply = ResponseEnvelope {
21344            request_id: 1,
21345            result: Ok(NodeResponse::SpawnSpecAccepted {
21346                receipt: resolved.receipt_with_materialization(
21347                    shared.incarnation_id,
21348                    terminal_address(17),
21349                    None,
21350                    Some(bundle),
21351                    None,
21352                ),
21353            }),
21354        };
21355        project_response_without_session_bundle(&mut reply);
21356        assert_eq!(
21357            reply.result.unwrap_err().code,
21358            NodeFailureCode::UnsupportedCapability,
21359        );
21360    }
21361
21362    #[test]
21363    fn history_context_pack_capability_redacts_loaded_messages_and_legacy_metadata() {
21364        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21365            capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
21366        }));
21367        let context_id = SpawnContextId::new("context-review").unwrap();
21368        let context = ResolvedContextPackReceipt {
21369            id: context_id.clone(),
21370            digest: crate::protocol::SpawnContextDigest::new(format!(
21371                "sha256:{}",
21372                "c".repeat(64),
21373            ))
21374            .unwrap(),
21375            lineage: ContextPackLineageReceipt {
21376                source_node_id: NodeId::new("node-source").unwrap(),
21377                source_session: terminal_address(1),
21378                source_provider: agent("kimi"),
21379            },
21380            source_message_count: 2,
21381            retained_message_count: 2,
21382            byte_len: 256,
21383            truncated: false,
21384        };
21385        assert!(context.is_valid());
21386        let mut bound_record = record("codex", "context-record");
21387        bound_record.context_id = Some(context_id);
21388        bound_record.context = Some(context.clone());
21389        let session = gate4agent_types::SessionSnapshot {
21390            instance_id: AgentInstanceId(91),
21391            agent_id: agent("kimi"),
21392            transport: TransportKind::Pty,
21393            generation: SessionGeneration(3),
21394            status: SessionStatus::Running,
21395            pending_operation: None,
21396            pending_input: None,
21397            process_id: Some(9001),
21398            terminal_size: None,
21399            terminal_frame: None,
21400            terminal_stale: None,
21401            session_options: None,
21402            capabilities: gate4agent_types::CapabilitySnapshot::default(),
21403            history: gate4agent_types::HistorySnapshot {
21404                pending: None,
21405                candidates: vec![HistoryCandidateSummary {
21406                    id: "candidate-opaque".to_owned(),
21407                    session_id_hint: "session-hint".to_owned(),
21408                    modified_at_unix_ms: Some(1),
21409                }],
21410                loaded_candidate_id: Some("candidate-opaque".to_owned()),
21411                loaded: Some(HistorySessionRecord {
21412                    session_id: "private-vendor-session".to_owned(),
21413                    title: Some("private title".to_owned()),
21414                    cwd: Some(r"C:\private\history-root".to_owned()),
21415                    model: Some("private-model".to_owned()),
21416                    message_count: 2,
21417                    completed_turn_count: None,
21418                    total_tokens: 19,
21419                    messages: vec![
21420                        gate4agent_types::HistoryMessageRecord {
21421                            role: gate4agent_types::HistoryMessageRole::User,
21422                            text: "F7_PRIVATE_HISTORY_MESSAGE".to_owned(),
21423                        },
21424                        gate4agent_types::HistoryMessageRecord {
21425                            role: gate4agent_types::HistoryMessageRole::Assistant,
21426                            text: "private answer".to_owned(),
21427                        },
21428                    ],
21429                }),
21430                last_error: None,
21431            },
21432            resume: gate4agent_types::ResumeSnapshot::default(),
21433            foreground: gate4agent_types::ForegroundSnapshot::default(),
21434            provider: gate4agent_types::ProviderSnapshot::default(),
21435            screen_state: gate4agent_types::PtyScreenState::default(),
21436        };
21437        let original = NodeSnapshot {
21438            node_id: NodeId::new("node-terminal-test").unwrap(),
21439            enabled_providers: vec![agent("kimi"), agent("codex")],
21440            provider_runtime_statuses: ProviderRuntimeStatuses::default(),
21441            workspaces: vec![WorkspaceSnapshot {
21442                workspace_id: WorkspaceId::new("primary").unwrap(),
21443                canonical_root: opaque_windows_path(
21444                    std::env::current_dir().unwrap().to_string_lossy().into_owned(),
21445                ),
21446                sessions: vec![session],
21447                worktree_service_mode: None,
21448                managed_worktree_profiles: None,
21449            }],
21450            session_records: vec![bound_record],
21451            managed_worktrees: Vec::new(),
21452            launch_inventory: None,
21453            agent_progress: Vec::new(),
21454        };
21455
21456        let mut negotiated = original.clone();
21457        project_snapshot_history_for_wire(&mut negotiated, true);
21458        assert_eq!(negotiated.workspaces[0].sessions[0].history.candidates.len(), 1);
21459        assert!(negotiated.workspaces[0].sessions[0].history.loaded.is_none());
21460        assert_eq!(negotiated.session_records[0].context.as_ref(), Some(&context));
21461        assert!(!serde_json::to_string(&negotiated)
21462            .unwrap()
21463            .contains("F7_PRIVATE_HISTORY_MESSAGE"));
21464
21465        let mut legacy = original;
21466        project_snapshot_history_for_wire(&mut legacy, false);
21467        clear_snapshot_context_packs(&mut legacy);
21468        assert_eq!(
21469            legacy.workspaces[0].sessions[0].history,
21470            gate4agent_types::HistorySnapshot::default(),
21471        );
21472        assert!(legacy.session_records[0].context_id.is_none());
21473        assert!(legacy.session_records[0].context.is_none());
21474        let encoded = serde_json::to_string(&legacy).unwrap();
21475        assert!(!encoded.contains("F7_PRIVATE_HISTORY_MESSAGE"));
21476        assert!(!encoded.contains("context-review"));
21477
21478        let mut reply = ResponseEnvelope {
21479            request_id: 7,
21480            result: Ok(NodeResponse::ContextPackExported { context }),
21481        };
21482        project_response_without_context_pack(&mut reply);
21483        assert_eq!(
21484            reply.result.unwrap_err().code,
21485            NodeFailureCode::UnsupportedCapability,
21486        );
21487    }
21488
21489    #[test]
21490    fn forgetting_unbound_context_pack_evicts_replay_but_retained_record_is_busy() {
21491        let shared = terminal_test_shared();
21492        let history = HistorySessionRecord {
21493            session_id: "fixture-history".to_owned(),
21494            title: None,
21495            cwd: None,
21496            model: None,
21497            message_count: 1,
21498            completed_turn_count: None,
21499            total_tokens: 0,
21500            messages: vec![gate4agent_types::HistoryMessageRecord {
21501                role: gate4agent_types::HistoryMessageRole::User,
21502                text: "bounded handoff".to_owned(),
21503            }],
21504        };
21505        let pack = NodeContextPack::export(
21506            ContextPackLineageReceipt {
21507                source_node_id: shared.node_id.clone(),
21508                source_session: terminal_address(1),
21509                source_provider: agent("claude"),
21510            },
21511            &history,
21512        )
21513        .unwrap();
21514        let context = shared
21515            .context_catalog
21516            .write()
21517            .unwrap_or_else(|poisoned| poisoned.into_inner())
21518            .insert(pack)
21519            .unwrap();
21520        let mut retained = record("codex", "context-retained-record");
21521        retained.context_id = Some(context.id.clone());
21522        retained.context = Some(context.clone());
21523        let record_id = retained.record_id.clone();
21524        shared.insert_record(retained).unwrap();
21525        assert_eq!(
21526            shared.forget_context_pack(&context.id).unwrap_err().code,
21527            NodeFailureCode::ContextPackBusy,
21528        );
21529        assert!(shared
21530            .context_catalog
21531            .read()
21532            .unwrap_or_else(|poisoned| poisoned.into_inner())
21533            .get(&context.id)
21534            .is_some());
21535        shared
21536            .session_records
21537            .lock()
21538            .unwrap_or_else(|poisoned| poisoned.into_inner())
21539            .records
21540            .remove(&record_id);
21541
21542        let mut spec = spawn_spec_fixture();
21543        spec.overrides.context_id = crate::protocol::SpawnOverride::Set {
21544            value: context.id.clone(),
21545        };
21546        let resolved = shared.resolve_spawn_spec(&spec).unwrap();
21547        let receipt = resolved.receipt_with_materialization(
21548            shared.incarnation_id,
21549            terminal_address(2),
21550            None,
21551            None,
21552            Some(context.clone()),
21553        );
21554        assert!(receipt.context_binding_is_valid());
21555        shared.remember_spawn_spec(spec.clone(), Ok(receipt), Instant::now());
21556
21557        shared.forget_context_pack(&context.id).unwrap();
21558        assert!(shared
21559            .context_catalog
21560            .read()
21561            .unwrap_or_else(|poisoned| poisoned.into_inner())
21562            .get(&context.id)
21563            .is_none());
21564        assert!(shared
21565            .replay_spawn_spec(&spec, Instant::now())
21566            .unwrap()
21567            .is_none());
21568    }
21569
21570    #[test]
21571    fn environment_profile_selection_is_owned_by_the_exact_session_binding() {
21572        let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
21573        let address = terminal_address(1);
21574        let selection = shared
21575            .select_environment_profile(
21576                address.session.instance_id,
21577                &agent("claude"),
21578                SessionMode::Pty,
21579                Some(&environment_profile),
21580            )
21581            .unwrap()
21582            .unwrap();
21583        shared.bind_session_with_policy(
21584            &address,
21585            ProviderRuntimePolicy::raw_pty(),
21586            Some(environment_profile.clone()),
21587        );
21588        selection.retain();
21589        let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
21590        assert!(matches!(
21591            runtime.remove_native_launch_profile(&native_id),
21592            Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
21593        ));
21594        assert!(shared.remove_binding(&address).is_some());
21595        assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
21596
21597        let mismatched = ResolvedEnvironmentProfileReceipt {
21598            profile_id: environment_profile.profile_id,
21599            profile_revision: crate::protocol::SpawnEnvironmentProfileRevision::new(
21600                "local-claude-r2",
21601            )
21602            .unwrap(),
21603            network_allowlist: None,
21604            browser_profile_id: None,
21605        };
21606        let error = match shared.select_environment_profile(
21607                AgentInstanceId(42),
21608                &agent("claude"),
21609                SessionMode::Pty,
21610                Some(&mismatched),
21611            ) {
21612            Ok(_) => panic!("mismatched environment profile revision was selected"),
21613            Err(error) => error,
21614        };
21615        assert_eq!(
21616            error.code,
21617            NodeFailureCode::EnvironmentProfileBindingMismatch,
21618        );
21619        assert!(!shared
21620            .session_bindings
21621            .lock()
21622            .unwrap_or_else(|poisoned| poisoned.into_inner())
21623            .contains_key(&AgentInstanceId(42)));
21624    }
21625
21626    #[test]
21627    fn combined_environment_and_bundle_overlay_clears_with_its_binding() {
21628        let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
21629        let address = terminal_address(1);
21630        let selection = shared
21631            .select_environment_profile(
21632                address.session.instance_id,
21633                &agent("claude"),
21634                SessionMode::Pty,
21635                Some(&environment_profile),
21636            )
21637            .unwrap()
21638            .unwrap();
21639        let overlay = NativeInstanceLaunchOverlay::new(
21640            agent("claude"),
21641            TransportKind::Pty,
21642            vec![EnvMutation {
21643                key: OsString::from("GATE4AGENT_SESSION_TOKEN"),
21644                value: Some(OsString::from("fixture-secret")),
21645            }],
21646            vec![
21647                OsString::from("--plugin-dir"),
21648                std::env::current_dir().unwrap().into_os_string(),
21649            ],
21650        )
21651        .unwrap();
21652        let instance_overlay = shared
21653            .install_prepared_launch_overlay(
21654                address.session.instance_id,
21655                PreparedNativeLaunchOverlay::Instance(overlay),
21656            )
21657            .unwrap()
21658            .unwrap();
21659        let bundle = ResolvedBundleReceipt {
21660            id: crate::protocol::SpawnBundleId::new("combined-bundle").unwrap(),
21661            revision: crate::protocol::SpawnBundleRevision::new("combined-r1").unwrap(),
21662            digest: crate::protocol::SpawnBundleDigest::new(format!(
21663                "sha256:{}",
21664                "0".repeat(64),
21665            ))
21666            .unwrap(),
21667        };
21668        shared.bind_session_with_materialization(
21669            &address,
21670            ProviderRuntimePolicy::raw_pty(),
21671            Some(environment_profile),
21672            Some(bundle),
21673            None,
21674            None,
21675        );
21676        selection.retain();
21677        instance_overlay.retain();
21678
21679        let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
21680        assert!(matches!(
21681            runtime.remove_native_launch_profile(&native_id),
21682            Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
21683        ));
21684        assert!(shared.remove_binding(&address).is_some());
21685        assert!(!shared
21686            .native_launch_profile_control
21687            .as_ref()
21688            .unwrap()
21689            .clear_native_instance_launch_overlay(address.session.instance_id));
21690        assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
21691    }
21692
21693    #[test]
21694    fn session_environment_materialization_failure_is_pre_child_and_durable() {
21695        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
21696        let address = SessionAddress {
21697            workspace_id: WorkspaceId::new("primary").unwrap(),
21698            session: SessionKey {
21699                instance_id: AgentInstanceId(201),
21700                generation: SessionGeneration(1),
21701            },
21702        };
21703        let error = match shared.prepare_session_materialization(
21704            &address,
21705            &agent("claude"),
21706            SessionMode::Pty,
21707            Some(&receipt),
21708            None,
21709            None,
21710            None,
21711        ) {
21712            Ok(_) => panic!("denied local reference unexpectedly materialized"),
21713            Err(error) => error,
21714        };
21715        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21716        assert!(shared.handle.snapshot().sessions.is_empty());
21717        assert!(shared
21718            .session_bindings
21719            .lock()
21720            .unwrap_or_else(|poisoned| poisoned.into_inner())
21721            .is_empty());
21722        assert!(shared
21723            .materializations
21724            .lock()
21725            .unwrap_or_else(|poisoned| poisoned.into_inner())
21726            .is_empty());
21727        drop(shared);
21728        drop(runtime);
21729        std::fs::remove_dir_all(root).unwrap();
21730    }
21731
21732    #[test]
21733    fn session_environment_binding_retains_on_stop_and_cleans_after_remove_reap() {
21734        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21735        let address = SessionAddress {
21736            workspace_id: WorkspaceId::new("primary").unwrap(),
21737            session: SessionKey {
21738                instance_id: AgentInstanceId(202),
21739                generation: SessionGeneration(1),
21740            },
21741        };
21742        let (overlay, materialization_guard) = shared
21743            .prepare_session_materialization(
21744                &address,
21745                &agent("claude"),
21746                SessionMode::Pty,
21747                Some(&receipt),
21748                None,
21749                None,
21750                None,
21751            )
21752            .unwrap()
21753            .unwrap();
21754        let materialization_id = materialization_guard.id().unwrap().clone();
21755        let materialization_root = shared
21756            .materializations
21757            .lock()
21758            .unwrap_or_else(|poisoned| poisoned.into_inner())
21759            .get(&materialization_id)
21760            .unwrap()
21761            .root()
21762            .to_path_buf();
21763        let selection = shared
21764            .select_environment_profile(
21765                address.session.instance_id,
21766                &agent("claude"),
21767                SessionMode::Pty,
21768                Some(&receipt),
21769            )
21770            .unwrap()
21771            .unwrap();
21772        assert!(shared
21773            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21774            .unwrap()
21775            .is_none());
21776        shared.bind_session_with_materialization(
21777            &address,
21778            ProviderRuntimePolicy::raw_pty(),
21779            Some(receipt),
21780            None,
21781            None,
21782            Some(materialization_id),
21783        );
21784        selection.retain();
21785        materialization_guard.retain();
21786
21787        assert!(materialization_root.is_dir());
21788        assert!(shared
21789            .session_bindings
21790            .lock()
21791            .unwrap_or_else(|poisoned| poisoned.into_inner())
21792            .contains_key(&address.session.instance_id));
21793
21794        let removed = shared.remove_binding(&address).unwrap();
21795        shared
21796            .cleanup_session_owned_materialization(&address, &removed)
21797            .unwrap();
21798        assert!(!materialization_root.exists());
21799        assert!(shared
21800            .materializations
21801            .lock()
21802            .unwrap_or_else(|poisoned| poisoned.into_inner())
21803            .is_empty());
21804        drop(shared);
21805        drop(runtime);
21806        std::fs::remove_dir_all(root).unwrap();
21807    }
21808
21809    #[tokio::test]
21810    async fn record_owned_session_environment_reuses_on_resume_and_forget_cleans() {
21811        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21812        let address = SessionAddress {
21813            workspace_id: WorkspaceId::new("primary").unwrap(),
21814            session: SessionKey {
21815                instance_id: AgentInstanceId(203),
21816                generation: SessionGeneration(1),
21817            },
21818        };
21819        let (overlay, materialization_guard) = shared
21820            .prepare_session_materialization(
21821                &address,
21822                &agent("claude"),
21823                SessionMode::Pty,
21824                Some(&receipt),
21825                None,
21826                None,
21827                None,
21828            )
21829            .unwrap()
21830            .unwrap();
21831        let materialization_id = materialization_guard.id().unwrap().clone();
21832        let materialization_root = shared
21833            .materializations
21834            .lock()
21835            .unwrap_or_else(|poisoned| poisoned.into_inner())
21836            .get(&materialization_id)
21837            .unwrap()
21838            .root()
21839            .to_path_buf();
21840        let selection = shared
21841            .select_environment_profile(
21842                address.session.instance_id,
21843                &agent("claude"),
21844                SessionMode::Pty,
21845                Some(&receipt),
21846            )
21847            .unwrap()
21848            .unwrap();
21849        assert!(shared
21850            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21851            .unwrap()
21852            .is_none());
21853        let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
21854        let record_id = shared
21855            .bind_spawn_session_with_materialization(
21856                &address,
21857                agent("claude"),
21858                SessionMode::Pty,
21859                policy,
21860                SpawnRecordPolicy::ProviderIdentityOnly,
21861                Some(receipt.clone()),
21862                None,
21863                None,
21864                Some(materialization_id.clone()),
21865            )
21866            .unwrap()
21867            .unwrap();
21868        selection.retain();
21869        materialization_guard.retain();
21870        assert_eq!(
21871            shared
21872                .materializations
21873                .lock()
21874                .unwrap_or_else(|poisoned| poisoned.into_inner())
21875                .get(&materialization_id)
21876                .unwrap()
21877                .owner(),
21878            &MaterializationOwner::Record {
21879                record_id: record_id.clone(),
21880            },
21881        );
21882
21883        let removed = shared.remove_binding(&address).unwrap();
21884        shared
21885            .cleanup_session_owned_materialization(&address, &removed)
21886            .unwrap();
21887        assert!(materialization_root.is_dir());
21888        assert!(shared
21889            .resolve_record_materialization(
21890                &record_id,
21891                &agent("claude"),
21892                SessionMode::Pty,
21893                Some(&receipt),
21894                None,
21895                None,
21896            )
21897            .unwrap()
21898            .is_some());
21899        {
21900            let mut records = shared
21901                .session_records
21902                .lock()
21903                .unwrap_or_else(|poisoned| poisoned.into_inner());
21904            let record = records.records.get_mut(&record_id).unwrap();
21905            record.active_session = None;
21906            record.state = ManagedSessionState::Unavailable;
21907        }
21908        shared.forget_session_record(&record_id).await.unwrap();
21909        assert!(!materialization_root.exists());
21910        assert!(shared
21911            .materializations
21912            .lock()
21913            .unwrap_or_else(|poisoned| poisoned.into_inner())
21914            .is_empty());
21915        drop(shared);
21916        drop(runtime);
21917        std::fs::remove_dir_all(root).unwrap();
21918    }
21919
21920    #[test]
21921    fn record_materialization_revalidation_failure_persists_recovery_required() {
21922        let (mut shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21923        let state_path = root.join("node-state-v6.json");
21924        shared.state_path = Some(state_path.clone());
21925        let (_address, _record_id, materialization_id, materialization_root) =
21926            bind_record_owned_materialization_fixture(&shared, &receipt, 205);
21927        std::fs::write(
21928            materialization_root.join(".gate4agent-materialization-owner"),
21929            b"tampered",
21930        )
21931        .unwrap();
21932
21933        let error = match shared.resolve_record_materialization(
21934            &_record_id,
21935            &agent("claude"),
21936            SessionMode::Pty,
21937            Some(&receipt),
21938            None,
21939            None,
21940        ) {
21941            Ok(_) => panic!("tampered owner marker unexpectedly revalidated"),
21942            Err(error) => error,
21943        };
21944        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21945        assert_eq!(
21946            shared
21947                .materializations
21948                .lock()
21949                .unwrap_or_else(|poisoned| poisoned.into_inner())
21950                .get(&materialization_id)
21951                .unwrap()
21952                .state(),
21953            MaterializationState::RecoveryRequired,
21954        );
21955        let loaded = session_registry::load(Some(&state_path), &shared.node_id).unwrap();
21956        assert_eq!(loaded.materializations.len(), 1);
21957        assert_eq!(
21958            loaded.materializations[0].state(),
21959            MaterializationState::RecoveryRequired,
21960        );
21961        drop(shared);
21962        drop(runtime);
21963        std::fs::remove_dir_all(root).unwrap();
21964    }
21965
21966    #[test]
21967    fn transient_record_materialization_resolution_failure_remains_ready() {
21968        let (shared, runtime, receipt, root, deny) = materialization_test_shared(false);
21969        let (_address, record_id, materialization_id, _materialization_root) =
21970            bind_record_owned_materialization_fixture(&shared, &receipt, 206);
21971        deny.store(true, Ordering::Release);
21972
21973        let error = match shared.resolve_record_materialization(
21974            &record_id,
21975            &agent("claude"),
21976            SessionMode::Pty,
21977            Some(&receipt),
21978            None,
21979            None,
21980        ) {
21981            Ok(_) => panic!("denied transient resolver unexpectedly succeeded"),
21982            Err(error) => error,
21983        };
21984        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21985        assert_eq!(
21986            shared
21987                .materializations
21988                .lock()
21989                .unwrap_or_else(|poisoned| poisoned.into_inner())
21990                .get(&materialization_id)
21991                .unwrap()
21992                .state(),
21993            MaterializationState::Ready,
21994        );
21995        drop(shared);
21996        drop(runtime);
21997        std::fs::remove_dir_all(root).unwrap();
21998    }
21999
22000    #[test]
22001    fn startup_missing_record_materialization_is_not_resumable() {
22002        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
22003        let record_id = SessionRecordId::new("sr-missing-materialization").unwrap();
22004        shared
22005            .insert_record(ManagedSessionRecord {
22006                record_id: record_id.clone(),
22007                display_name: "missing materialization".to_owned(),
22008                provider: agent("claude"),
22009                mode: SessionMode::Pty,
22010                state: ManagedSessionState::Dormant,
22011                workspace_id: WorkspaceId::new("primary").unwrap(),
22012                canonical_root: opaque_windows_path(
22013                    std::env::current_dir().unwrap().to_string_lossy().into_owned(),
22014                ),
22015                provider_session: Some(gate4agent_types::ProviderSessionIdentity {
22016                    key: gate4agent_types::ProviderSessionKey::SessionId,
22017                    id: "provider-session-missing-materialization".to_owned(),
22018                    transcript_path: None,
22019                }),
22020                active_session: None,
22021                environment_profile: Some(receipt),
22022                bundle: None,
22023                context_id: None,
22024                context: None,
22025                exported_context: None,
22026                task_binding: None,
22027                created_at_unix_ms: 1,
22028                updated_at_unix_ms: 1,
22029                last_error: None,
22030            })
22031            .unwrap();
22032
22033        shared.reconcile_materializations();
22034        let record = shared.record(&record_id).unwrap();
22035        assert_eq!(record.state, ManagedSessionState::Unavailable);
22036        assert!(record.provider_session.is_none());
22037        assert_eq!(
22038            record.last_error.as_deref(),
22039            Some("environment-profile-unavailable"),
22040        );
22041        drop(shared);
22042        drop(runtime);
22043        std::fs::remove_dir_all(root).unwrap();
22044    }
22045
22046    #[tokio::test]
22047    async fn managed_worktree_cleanup_waits_for_session_environment_cleanup() {
22048        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
22049        let lease = install_managed_lease(&shared);
22050        let address = SessionAddress {
22051            workspace_id: WorkspaceId::new("primary").unwrap(),
22052            session: SessionKey {
22053                instance_id: AgentInstanceId(204),
22054                generation: SessionGeneration(1),
22055            },
22056        };
22057        let (overlay, materialization_guard) = shared
22058            .prepare_session_materialization(
22059                &address,
22060                &agent("claude"),
22061                SessionMode::Pty,
22062                Some(&receipt),
22063                None,
22064                None,
22065                Some(lease.lease_id.clone()),
22066            )
22067            .unwrap()
22068            .unwrap();
22069        let materialization_id = materialization_guard.id().unwrap().clone();
22070        let materialization_root = shared
22071            .materializations
22072            .lock()
22073            .unwrap_or_else(|poisoned| poisoned.into_inner())
22074            .get(&materialization_id)
22075            .unwrap()
22076            .root()
22077            .to_path_buf();
22078        let selection = shared
22079            .select_environment_profile(
22080                address.session.instance_id,
22081                &agent("claude"),
22082                SessionMode::Pty,
22083                Some(&receipt),
22084            )
22085            .unwrap()
22086            .unwrap();
22087        assert!(shared
22088            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
22089            .unwrap()
22090            .is_none());
22091        shared.bind_session_with_materialization(
22092            &address,
22093            ProviderRuntimePolicy::raw_pty(),
22094            Some(receipt),
22095            None,
22096            None,
22097            Some(materialization_id),
22098        );
22099        selection.retain();
22100        materialization_guard.retain();
22101
22102        let blocked = shared
22103            .cleanup_managed_worktree(&lease.lease_id, false)
22104            .await
22105            .unwrap_err();
22106        assert_eq!(blocked.code, NodeFailureCode::ManagedWorktreeRecoveryRequired);
22107        assert!(materialization_root.is_dir());
22108        let removed = shared.remove_binding(&address).unwrap();
22109        shared
22110            .cleanup_session_owned_materialization(&address, &removed)
22111            .unwrap();
22112        assert!(!materialization_root.exists());
22113        drop(shared);
22114        drop(runtime);
22115        std::fs::remove_dir_all(root).unwrap();
22116    }
22117
22118    #[tokio::test]
22119    async fn raw_managed_record_is_inventory_only_and_does_not_block_session_cleanup() {
22120        let git_root = temporary_workspace_root("raw-managed-inventory");
22121        let source = git_root.join("source");
22122        let allocation = git_root.join("allocation");
22123        std::fs::create_dir_all(&source).unwrap();
22124        std::fs::create_dir_all(&allocation).unwrap();
22125        run_test_git(&source, &["init"]);
22126        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
22127        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
22128        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
22129        run_test_git(&source, &["add", "seed.txt"]);
22130        run_test_git(&source, &["commit", "-m", "seed"]);
22131        let profile = ManagedWorktreeProfile::new(
22132            WorktreeProfileId::new("default").unwrap(),
22133            crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
22134            &allocation,
22135            "gate4agent",
22136            "HEAD",
22137            ManagedWorktreeRetention::RemoveWhenReleased,
22138        )
22139        .unwrap();
22140        let workspace = WorkspaceConfig::new(
22141            WorkspaceId::new("primary").unwrap(),
22142            &source,
22143        )
22144        .unwrap()
22145        .with_worktree_service_mode(WorktreeServiceMode::Managed)
22146        .with_managed_worktree_profile(profile.clone())
22147        .unwrap();
22148        let (shared, mut runtime, receipt, materialization_fixture_root, _deny) =
22149            materialization_test_shared_with_workspace(false, workspace);
22150        let target = PathBuf::from(profile.allocation_root()).join("mw-raw-inventory");
22151        let source_text = source.to_string_lossy().into_owned();
22152        let target_text = target.to_string_lossy().into_owned();
22153        let created = create_git_worktree(
22154            &source_text,
22155            &target_text,
22156            "gate4agent/mw-raw-inventory",
22157            Some("HEAD"),
22158        )
22159        .await
22160        .unwrap();
22161        let lease = ManagedWorktreeLeaseRecord {
22162            lease_id: ManagedWorktreeLeaseId::new("mw-raw-inventory").unwrap(),
22163            source_workspace_id: WorkspaceId::new("primary").unwrap(),
22164            workspace_id: WorkspaceId::new("managed-raw-inventory").unwrap(),
22165            profile_id: profile.profile_id().clone(),
22166            profile_revision: profile.revision().clone(),
22167            target_root: created.path.clone(),
22168            branch: "gate4agent/mw-raw-inventory".to_owned(),
22169            base_commit: created.head.clone(),
22170            expected_head: Some(created.head.clone()),
22171            retention: ManagedWorktreeRetention::RemoveWhenReleased,
22172            state: ManagedWorktreeLeaseState::Ready,
22173            session_holders: Vec::new(),
22174            record_holders: Vec::new(),
22175            cleanup_failure: None,
22176            created_at_unix_ms: 1,
22177            updated_at_unix_ms: 1,
22178        };
22179        shared
22180            .workspaces
22181            .write()
22182            .unwrap_or_else(|poisoned| poisoned.into_inner())
22183            .insert(lease.workspace_id.clone(), created.path.clone());
22184        *shared
22185            .managed_worktrees
22186            .lock()
22187            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22188            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
22189        let address = SessionAddress {
22190            workspace_id: lease.workspace_id.clone(),
22191            session: SessionKey {
22192                instance_id: AgentInstanceId(205),
22193                generation: SessionGeneration(1),
22194            },
22195        };
22196        let (overlay, materialization_guard) = shared
22197            .prepare_session_materialization(
22198                &address,
22199                &agent("claude"),
22200                SessionMode::Pty,
22201                Some(&receipt),
22202                None,
22203                None,
22204                Some(lease.lease_id.clone()),
22205            )
22206            .unwrap()
22207            .unwrap();
22208        let materialization_id = materialization_guard.id().unwrap().clone();
22209        let materialization_root = shared
22210            .materializations
22211            .lock()
22212            .unwrap_or_else(|poisoned| poisoned.into_inner())
22213            .get(&materialization_id)
22214            .unwrap()
22215            .root()
22216            .to_path_buf();
22217        let selection = shared
22218            .select_environment_profile(
22219                address.session.instance_id,
22220                &agent("claude"),
22221                SessionMode::Pty,
22222                Some(&receipt),
22223            )
22224            .unwrap()
22225            .unwrap();
22226        assert!(shared
22227            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
22228            .unwrap()
22229            .is_none());
22230        let record_id = shared
22231            .bind_spawn_session_with_materialization(
22232                &address,
22233                agent("claude"),
22234                SessionMode::Pty,
22235                ProviderRuntimePolicy::raw_pty(),
22236                SpawnRecordPolicy::Always,
22237                Some(receipt.clone()),
22238                None,
22239                None,
22240                Some(materialization_id.clone()),
22241            )
22242            .unwrap()
22243            .unwrap();
22244        selection.retain();
22245        materialization_guard.retain();
22246        let record = shared.record(&record_id).unwrap();
22247        assert_eq!(record.state, ManagedSessionState::Live);
22248        assert!(record.provider_session.is_none());
22249        assert!(record.environment_profile.is_none());
22250        assert!(record.bundle.is_none());
22251        assert!(record.context_id.is_none());
22252        assert!(record.context.is_none());
22253        assert_eq!(record.active_session.as_ref(), Some(&address));
22254        assert_eq!(
22255            shared
22256                .materializations
22257                .lock()
22258                .unwrap_or_else(|poisoned| poisoned.into_inner())
22259                .get(&materialization_id)
22260                .unwrap()
22261                .owner(),
22262            &MaterializationOwner::Session {
22263                incarnation_id: shared.incarnation_id,
22264                instance_id: address.session.instance_id,
22265                generation: address.session.generation,
22266            },
22267        );
22268        {
22269            let mut bindings = shared
22270                .session_bindings
22271                .lock()
22272                .unwrap_or_else(|poisoned| poisoned.into_inner());
22273            bindings
22274                .get_mut(&address.session.instance_id)
22275                .unwrap()
22276                .managed_worktree_lease_id = Some(lease.lease_id.clone());
22277        }
22278        let lease_snapshot = shared
22279            .managed_worktrees
22280            .lock()
22281            .unwrap_or_else(|poisoned| poisoned.into_inner())
22282            .bind_session(
22283                &lease.lease_id,
22284                ManagedWorktreeSessionHolder {
22285                    incarnation_id: shared.incarnation_id,
22286                    instance_id: address.session.instance_id,
22287                    generation: address.session.generation,
22288                },
22289                None,
22290                unix_time_ms(),
22291            )
22292            .unwrap();
22293        assert_eq!(lease_snapshot.active_session_count, 1);
22294        assert_eq!(lease_snapshot.managed_record_count, 0);
22295        shared.persist_state().unwrap();
22296        assert!(shared
22297            .managed_worktrees
22298            .lock()
22299            .unwrap_or_else(|poisoned| poisoned.into_inner())
22300            .get(&lease.lease_id)
22301            .unwrap()
22302            .record_holders
22303            .is_empty());
22304        shared
22305            .handle
22306            .dispatch(shared.prepare_command(ControlCommand::Register {
22307                instance_id: address.session.instance_id,
22308                agent_id: agent("claude"),
22309                transport: TransportKind::Pty,
22310            }))
22311            .unwrap();
22312        runtime.tick().await;
22313        shared.reconcile_managed_record(
22314            &record_id,
22315            &address,
22316            &ControlEvent {
22317                sequence: 1,
22318                command_id: None,
22319                instance_id: address.session.instance_id,
22320                generation: address.session.generation,
22321                event: ControlEventKind::Removed,
22322            },
22323            false,
22324            false,
22325        );
22326        let remove = shared.remove_session(&address);
22327        let drive_runtime = async {
22328            runtime.tick().await;
22329        };
22330        let (removed, ()) = tokio::join!(remove, drive_runtime);
22331        removed.unwrap();
22332
22333        assert!(!materialization_root.exists());
22334        assert!(!Path::new(&created.path).exists());
22335        assert_eq!(
22336            shared
22337                .managed_worktrees
22338                .lock()
22339                .unwrap_or_else(|poisoned| poisoned.into_inner())
22340                .get(&lease.lease_id)
22341                .unwrap()
22342                .state,
22343            ManagedWorktreeLeaseState::Removed,
22344        );
22345        let renamed = shared
22346            .rename_session_record(&record_id, "raw managed inventory".to_owned())
22347            .unwrap();
22348        assert_eq!(renamed.record_id, record_id);
22349        assert_eq!(renamed.display_name, "raw managed inventory");
22350        assert_eq!(renamed.state, ManagedSessionState::Unavailable);
22351        assert!(renamed.active_session.is_none());
22352
22353        drop(shared);
22354        drop(runtime);
22355        std::fs::remove_dir_all(materialization_fixture_root).unwrap();
22356        std::fs::remove_dir_all(git_root).unwrap();
22357    }
22358
22359    fn install_managed_lease(shared: &NodeShared) -> ManagedWorktreeLeaseRecord {
22360        let root = std::env::current_dir().unwrap()
22361            .join("managed-test-target")
22362            .to_string_lossy()
22363            .into_owned();
22364        let lease = ManagedWorktreeLeaseRecord {
22365            lease_id: ManagedWorktreeLeaseId::new("mw-test").unwrap(),
22366            source_workspace_id: WorkspaceId::new("primary").unwrap(),
22367            workspace_id: WorkspaceId::new("managed-test").unwrap(),
22368            profile_id: WorktreeProfileId::new("default").unwrap(),
22369            profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
22370            target_root: root.clone(),
22371            branch: "gate4agent/mw-test".to_owned(),
22372            base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
22373            expected_head: Some("0123456789abcdef0123456789abcdef01234567".to_owned()),
22374            retention: ManagedWorktreeRetention::RemoveWhenReleased,
22375            state: ManagedWorktreeLeaseState::Ready,
22376            session_holders: Vec::new(),
22377            record_holders: Vec::new(),
22378            cleanup_failure: None,
22379            created_at_unix_ms: 1,
22380            updated_at_unix_ms: 1,
22381        };
22382        shared.workspaces.write()
22383            .unwrap_or_else(|poisoned| poisoned.into_inner())
22384            .insert(lease.workspace_id.clone(), root);
22385        *shared.managed_worktrees.lock()
22386            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22387            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
22388        lease
22389    }
22390
22391    #[cfg(feature = "fixture")]
22392    #[test]
22393    fn monitoring_fixture_managed_spawn_retains_effective_record_holder_and_converges_live() {
22394        let mut shared = terminal_test_shared();
22395        shared.fixture_semantic_hook_policy = true;
22396        let lease = install_managed_lease(&shared);
22397        let address = SessionAddress {
22398            workspace_id: lease.workspace_id.clone(),
22399            session: SessionKey {
22400                instance_id: AgentInstanceId(206),
22401                generation: SessionGeneration(1),
22402            },
22403        };
22404        let pre_admission_policy = ProviderRuntimePolicy::raw_pty();
22405        let effective_runtime_policy =
22406            shared.effective_spawn_runtime_policy(pre_admission_policy);
22407        assert_ne!(effective_runtime_policy, pre_admission_policy);
22408        assert!(effective_runtime_policy.provider_session_identity);
22409
22410        let record_id = shared
22411            .bind_spawn_session(
22412                &address,
22413                agent("claude"),
22414                SessionMode::Pty,
22415                effective_runtime_policy,
22416                SpawnRecordPolicy::Always,
22417                None,
22418            )
22419            .unwrap()
22420            .unwrap();
22421        assert_eq!(
22422            shared.record(&record_id).unwrap().state,
22423            ManagedSessionState::IdentityPending,
22424        );
22425        let (record_holder, raw_inventory_record_id) = {
22426            let mut bindings = shared
22427                .session_bindings
22428                .lock()
22429                .unwrap_or_else(|poisoned| poisoned.into_inner());
22430            let binding = bindings.get_mut(&address.session.instance_id).unwrap();
22431            binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
22432            managed_spawn_record_ownership(binding, effective_runtime_policy)
22433        };
22434        assert_eq!(record_holder.as_ref(), Some(&record_id));
22435        assert!(raw_inventory_record_id.is_none());
22436        let snapshot = shared
22437            .managed_worktrees
22438            .lock()
22439            .unwrap_or_else(|poisoned| poisoned.into_inner())
22440            .bind_session(
22441                &lease.lease_id,
22442                ManagedWorktreeSessionHolder {
22443                    incarnation_id: shared.incarnation_id,
22444                    instance_id: address.session.instance_id,
22445                    generation: address.session.generation,
22446                },
22447                record_holder,
22448                unix_time_ms(),
22449            )
22450            .unwrap();
22451        assert_eq!(snapshot.managed_record_count, 1);
22452        assert_eq!(
22453            shared
22454                .managed_worktrees
22455                .lock()
22456                .unwrap_or_else(|poisoned| poisoned.into_inner())
22457                .get(&lease.lease_id)
22458                .unwrap()
22459                .record_holders,
22460            vec![record_id.clone()],
22461        );
22462
22463        let identity = ProviderSessionIdentity {
22464            key: ProviderSessionKey::SessionId,
22465            id: "monitoring-fixture-managed-session".to_owned(),
22466            transcript_path: None,
22467        };
22468        shared.publish_control(ControlEvent {
22469            sequence: 1,
22470            command_id: None,
22471            instance_id: address.session.instance_id,
22472            generation: address.session.generation,
22473            event: ControlEventKind::ProviderEvent {
22474                sequence: 1,
22475                source: gate4agent_types::ProviderSource {
22476                    family: AdapterFamily::Hook,
22477                    binding: AdapterBinding::new(
22478                        gate4agent_types::AdapterId::new("claude-code").unwrap(),
22479                        "fixture/v1",
22480                        gate4agent_types::AdapterVerification::SyntheticFixture,
22481                    )
22482                    .unwrap(),
22483                },
22484                source_sequence: 1,
22485                event: ProviderEvent::SessionIdentityObserved {
22486                    identity: identity.clone(),
22487                },
22488            },
22489        });
22490
22491        let live = shared.record(&record_id).unwrap();
22492        assert_eq!(live.state, ManagedSessionState::Live);
22493        assert_eq!(live.provider_session.as_ref(), Some(&identity));
22494        assert_eq!(live.active_session.as_ref(), Some(&address));
22495        assert_eq!(
22496            shared
22497                .managed_worktrees
22498                .lock()
22499                .unwrap_or_else(|poisoned| poisoned.into_inner())
22500                .get(&lease.lease_id)
22501                .unwrap()
22502                .record_holders,
22503            vec![record_id],
22504        );
22505    }
22506
22507    fn managed_v2_test_shared(
22508        allocation_root: &Path,
22509        state_path: Option<PathBuf>,
22510        profile_revision: &str,
22511    ) -> NodeShared {
22512        let profile = ManagedWorktreeProfile::new(
22513            WorktreeProfileId::new("default").unwrap(),
22514            WorktreeProfileRevision::new(profile_revision).unwrap(),
22515            allocation_root,
22516            "gate4agent",
22517            "HEAD",
22518            ManagedWorktreeRetention::RemoveWhenReleased,
22519        )
22520        .unwrap();
22521        let workspace = WorkspaceConfig::new(
22522            WorkspaceId::new("primary").unwrap(),
22523            std::env::current_dir().unwrap(),
22524        )
22525        .unwrap()
22526        .with_worktree_service_mode(WorktreeServiceMode::Managed)
22527        .with_managed_worktree_profile(profile)
22528        .unwrap();
22529        let catalog = active_registry().unwrap();
22530        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22531        let mut shared = NodeShared::new(
22532            handle,
22533            "fixture-token".to_owned(),
22534            NodeId::new("node-terminal-test").unwrap(),
22535            vec![workspace],
22536            vec![agent("claude")],
22537        );
22538        shared.state_path = state_path;
22539        shared
22540    }
22541
22542    fn managed_v2_request(idempotency_key: &str) -> ManagedWorktreeSpawnRequestV2 {
22543        let mut spawn_spec = spawn_spec_fixture();
22544        spawn_spec.idempotency_key = SpawnIdempotencyKey::new(idempotency_key).unwrap();
22545        ManagedWorktreeSpawnRequestV2 {
22546            spawn_spec,
22547            worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
22548            expected_profile_revision: WorktreeProfileRevision::new("v1").unwrap(),
22549        }
22550    }
22551
22552    #[test]
22553    fn managed_spawn_receipt_rebinds_allocated_worktree_and_roundtrips_wire() {
22554        let shared = terminal_test_shared();
22555        let mut lease = install_managed_lease(&shared);
22556        let session = SessionAddress {
22557            workspace_id: lease.workspace_id.clone(),
22558            session: SessionKey {
22559                instance_id: AgentInstanceId(73),
22560                generation: SessionGeneration(4),
22561            },
22562        };
22563        lease.state = ManagedWorktreeLeaseState::InUse;
22564        lease.session_holders.push(ManagedWorktreeSessionHolder {
22565            incarnation_id: shared.incarnation_id,
22566            instance_id: session.session.instance_id,
22567            generation: session.session.generation,
22568        });
22569        let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
22570        assert!(resolved.target.worktree_id.is_none());
22571
22572        let receipt = managed_spawn_receipt(
22573            &resolved,
22574            shared.incarnation_id,
22575            session.clone(),
22576            lease.snapshot(),
22577            None,
22578            None,
22579            None,
22580        );
22581        assert!(resolved.target.worktree_id.is_none());
22582        assert_eq!(receipt.spawn.target.workspace_id, lease.source_workspace_id);
22583        assert_eq!(receipt.spawn.target.worktree_id.as_ref(), Some(&lease.workspace_id));
22584        assert_eq!(receipt.spawn.session, session);
22585        assert_eq!(receipt.lease.workspace_id, lease.workspace_id);
22586        let encoded = serde_json::to_vec(&receipt).unwrap();
22587        let decoded: ManagedWorktreeSpawnReceipt = serde_json::from_slice(&encoded).unwrap();
22588        assert_eq!(decoded, receipt);
22589    }
22590
22591    #[tokio::test]
22592    async fn managed_spawn_v2_rejects_profile_revision_before_allocation() {
22593        let root = temporary_workspace_root("managed-v2-revision");
22594        let allocation = root.join("allocation");
22595        std::fs::create_dir_all(&allocation).unwrap();
22596        let shared = managed_v2_test_shared(
22597            &allocation,
22598            Some(root.join("node-state.json")),
22599            "v1",
22600        );
22601        let mut request = managed_v2_request("managed-v2-revision");
22602        request.expected_profile_revision = WorktreeProfileRevision::new("v2").unwrap();
22603
22604        let failure = shared.spawn_managed_worktree_v2(request).await.unwrap_err();
22605
22606        assert_eq!(
22607            failure.code,
22608            NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
22609        );
22610        assert!(shared
22611            .managed_worktrees
22612            .lock()
22613            .unwrap_or_else(|poisoned| poisoned.into_inner())
22614            .records()
22615            .is_empty());
22616        assert!(shared
22617            .managed_spawn_replays
22618            .lock()
22619            .unwrap_or_else(|poisoned| poisoned.into_inner())
22620            .is_empty());
22621        std::fs::remove_dir_all(root).unwrap();
22622    }
22623
22624    #[test]
22625    fn managed_spawn_v2_concurrent_same_key_has_one_durable_reservation() {
22626        let root = temporary_workspace_root("managed-v2-concurrent");
22627        let allocation = root.join("allocation");
22628        std::fs::create_dir_all(&allocation).unwrap();
22629        let shared = Arc::new(managed_v2_test_shared(
22630            &allocation,
22631            Some(root.join("node-state.json")),
22632            "v1",
22633        ));
22634        let request = managed_v2_request("managed-v2-concurrent");
22635        let barrier = Arc::new(std::sync::Barrier::new(3));
22636        let mut joins = Vec::new();
22637        for _ in 0..2 {
22638            let shared = Arc::clone(&shared);
22639            let request = request.clone();
22640            let barrier = Arc::clone(&barrier);
22641            joins.push(std::thread::spawn(move || {
22642                let digest = managed_spawn_request_digest_v2(&request).unwrap();
22643                barrier.wait();
22644                shared.acquire_durable_managed_spawn_v2(&request, digest)
22645            }));
22646        }
22647        barrier.wait();
22648        let results = joins
22649            .into_iter()
22650            .map(|join| join.join().unwrap())
22651            .collect::<Vec<_>>();
22652        assert_eq!(
22653            results
22654                .iter()
22655                .filter(|result| {
22656                    matches!(result, Ok(DurableManagedSpawnReplayDecision::Reserved))
22657                })
22658                .count(),
22659            1,
22660        );
22661        assert_eq!(
22662            results
22663                .iter()
22664                .filter(|result| {
22665                    matches!(result, Err(error) if error.code == NodeFailureCode::BackendBusy)
22666                })
22667                .count(),
22668            1,
22669        );
22670        assert_eq!(
22671            shared
22672                .managed_spawn_replays
22673                .lock()
22674                .unwrap_or_else(|poisoned| poisoned.into_inner())
22675                .len(),
22676            1,
22677        );
22678        assert!(shared
22679            .managed_worktrees
22680            .lock()
22681            .unwrap_or_else(|poisoned| poisoned.into_inner())
22682            .records()
22683            .is_empty());
22684        drop(shared);
22685        std::fs::remove_dir_all(root).unwrap();
22686    }
22687
22688    #[tokio::test]
22689    async fn managed_spawn_v2_linked_pending_reopen_fails_closed_without_reallocation() {
22690        let root = temporary_workspace_root("managed-v2-linked-pending");
22691        let allocation = root.join("allocation");
22692        std::fs::create_dir_all(&allocation).unwrap();
22693        let state_path = root.join("node-state.json");
22694        let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
22695        let lease = install_managed_lease(&shared);
22696        let request = managed_v2_request("managed-v2-linked-pending");
22697        assert!(matches!(
22698            shared
22699                .acquire_durable_managed_spawn_v2(
22700                    &request,
22701                    managed_spawn_request_digest_v2(&request).unwrap(),
22702                )
22703                .unwrap(),
22704            DurableManagedSpawnReplayDecision::Reserved,
22705        ));
22706        {
22707            let _transaction = shared
22708                .state_transaction
22709                .lock()
22710                .unwrap_or_else(|poisoned| poisoned.into_inner());
22711            shared
22712                .link_durable_managed_spawn_v2_lease_locked(
22713                    &request.spawn_spec.idempotency_key,
22714                    &lease.lease_id,
22715                )
22716                .unwrap();
22717            shared.persist_state_locked().unwrap();
22718        }
22719        drop(shared);
22720
22721        let mut loaded = session_registry::load(
22722            Some(&state_path),
22723            &NodeId::new("node-terminal-test").unwrap(),
22724        )
22725        .unwrap();
22726        let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22727        *reopened
22728            .managed_worktrees
22729            .lock()
22730            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22731            ManagedWorktreeRegistry::from_records(
22732                std::mem::take(&mut loaded.managed_worktrees),
22733                std::mem::take(&mut loaded.managed_worktree_tombstones),
22734            )
22735            .unwrap();
22736        *reopened
22737            .managed_spawn_replays
22738            .lock()
22739            .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22740            .managed_spawn_replays
22741            .into_iter()
22742            .map(|record| (record.idempotency_key.clone(), record))
22743            .collect();
22744        let lease_count = reopened
22745            .managed_worktrees
22746            .lock()
22747            .unwrap_or_else(|poisoned| poisoned.into_inner())
22748            .records()
22749            .len();
22750
22751        let failure = reopened
22752            .spawn_managed_worktree_v2(request)
22753            .await
22754            .unwrap_err();
22755
22756        assert_eq!(
22757            failure.code,
22758            NodeFailureCode::ManagedWorktreeRecoveryRequired,
22759        );
22760        assert_eq!(
22761            reopened
22762                .managed_worktrees
22763                .lock()
22764                .unwrap_or_else(|poisoned| poisoned.into_inner())
22765                .records()
22766                .len(),
22767            lease_count,
22768        );
22769        std::fs::remove_dir_all(root).unwrap();
22770    }
22771
22772    #[tokio::test]
22773    async fn managed_spawn_v2_replays_exact_receipt_after_durable_reopen_and_conflicts() {
22774        let root = temporary_workspace_root("managed-v2-reopen");
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-reopen");
22781        let session = SessionAddress {
22782            workspace_id: lease.workspace_id.clone(),
22783            session: SessionKey {
22784                instance_id: AgentInstanceId(91),
22785                generation: SessionGeneration(1),
22786            },
22787        };
22788        let snapshot = {
22789            let mut registry = shared
22790                .managed_worktrees
22791                .lock()
22792                .unwrap_or_else(|poisoned| poisoned.into_inner());
22793            let current = registry.get_mut(&lease.lease_id).unwrap();
22794            current.state = ManagedWorktreeLeaseState::InUse;
22795            current.session_holders.push(ManagedWorktreeSessionHolder {
22796                incarnation_id: shared.incarnation_id,
22797                instance_id: session.session.instance_id,
22798                generation: session.session.generation,
22799            });
22800            current.snapshot()
22801        };
22802        let resolved = shared.resolve_spawn_spec(&request.spawn_spec).unwrap();
22803        let receipt = managed_spawn_receipt(
22804            &resolved,
22805            shared.incarnation_id,
22806            session,
22807            snapshot,
22808            None,
22809            None,
22810            None,
22811        );
22812        assert!(matches!(
22813            shared
22814                .acquire_durable_managed_spawn_v2(
22815                    &request,
22816                    managed_spawn_request_digest_v2(&request).unwrap(),
22817                )
22818                .unwrap(),
22819            DurableManagedSpawnReplayDecision::Reserved,
22820        ));
22821        {
22822            let _transaction = shared
22823                .state_transaction
22824                .lock()
22825                .unwrap_or_else(|poisoned| poisoned.into_inner());
22826            shared
22827                .link_durable_managed_spawn_v2_lease_locked(
22828                    &request.spawn_spec.idempotency_key,
22829                    &lease.lease_id,
22830                )
22831                .unwrap();
22832            shared.persist_state_locked().unwrap();
22833        }
22834        shared
22835            .commit_durable_managed_spawn_v2(&request, receipt.clone())
22836            .unwrap();
22837        let legacy_same_key = ManagedWorktreeSpawnRequest {
22838            spawn_spec: request.spawn_spec.clone(),
22839            worktree_profile_id: request.worktree_profile_id.clone(),
22840        };
22841        shared.remember_managed_spawn_attempt(
22842            Some(&request.spawn_spec.idempotency_key),
22843            legacy_same_key.clone(),
22844            Ok(receipt.clone()),
22845            Instant::now(),
22846        );
22847        assert!(shared
22848            .replay_managed_spawn(&legacy_same_key, Instant::now())
22849            .unwrap()
22850            .is_none());
22851        drop(shared);
22852
22853        let mut loaded = session_registry::load(
22854            Some(&state_path),
22855            &NodeId::new("node-terminal-test").unwrap(),
22856        )
22857        .unwrap();
22858        assert_eq!(loaded.managed_spawn_replays.len(), 1);
22859        let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22860        *reopened
22861            .managed_worktrees
22862            .lock()
22863            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22864            ManagedWorktreeRegistry::from_records(
22865                std::mem::take(&mut loaded.managed_worktrees),
22866                std::mem::take(&mut loaded.managed_worktree_tombstones),
22867            )
22868            .unwrap();
22869        *reopened
22870            .managed_spawn_replays
22871            .lock()
22872            .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22873            .managed_spawn_replays
22874            .into_iter()
22875            .map(|record| (record.idempotency_key.clone(), record))
22876            .collect();
22877        let lease_count_before = reopened
22878            .managed_worktrees
22879            .lock()
22880            .unwrap_or_else(|poisoned| poisoned.into_inner())
22881            .records()
22882            .len();
22883
22884        assert_eq!(
22885            reopened
22886                .spawn_managed_worktree_v2(request.clone())
22887                .await
22888                .unwrap(),
22889            receipt,
22890        );
22891        assert_eq!(
22892            reopened
22893                .managed_worktrees
22894                .lock()
22895                .unwrap_or_else(|poisoned| poisoned.into_inner())
22896                .records()
22897                .len(),
22898            lease_count_before,
22899        );
22900        let mut changed = request;
22901        changed.spawn_spec.deadline_ms = crate::protocol::SpawnDeadlineMs::new(29_000).unwrap();
22902        let failure = reopened
22903            .spawn_managed_worktree_v2(changed)
22904            .await
22905            .unwrap_err();
22906        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22907        assert_eq!(
22908            reopened
22909                .managed_worktrees
22910                .lock()
22911                .unwrap_or_else(|poisoned| poisoned.into_inner())
22912                .records()
22913                .len(),
22914            lease_count_before,
22915        );
22916        let mut new_key = managed_v2_request("managed-v2-new-after-profile-change");
22917        new_key.expected_profile_revision = WorktreeProfileRevision::new("v1").unwrap();
22918        let failure = reopened
22919            .spawn_managed_worktree_v2(new_key)
22920            .await
22921            .unwrap_err();
22922        assert_eq!(
22923            failure.code,
22924            NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
22925        );
22926        assert_eq!(
22927            reopened
22928                .managed_worktrees
22929                .lock()
22930                .unwrap_or_else(|poisoned| poisoned.into_inner())
22931                .records()
22932                .len(),
22933            lease_count_before,
22934        );
22935        assert_eq!(
22936            reopened
22937                .managed_spawn_replays
22938                .lock()
22939                .unwrap_or_else(|poisoned| poisoned.into_inner())
22940                .len(),
22941            1,
22942        );
22943        std::fs::remove_dir_all(root).unwrap();
22944    }
22945
22946    #[test]
22947    fn legacy_managed_spawn_idempotency_remains_transient_and_exact() {
22948        let shared = terminal_test_shared();
22949        let request = ManagedWorktreeSpawnRequest {
22950            spawn_spec: spawn_spec_fixture(),
22951            worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
22952        };
22953        let expected = failure(NodeFailureCode::BackendBusy, "legacy fixture failure");
22954        shared.remember_managed_spawn(
22955            request.clone(),
22956            Err(expected.clone()),
22957            Instant::now(),
22958        );
22959
22960        assert_eq!(
22961            shared
22962                .replay_managed_spawn(&request, Instant::now())
22963                .unwrap()
22964                .unwrap(),
22965            Err(expected),
22966        );
22967        assert!(shared
22968            .managed_spawn_replays
22969            .lock()
22970            .unwrap_or_else(|poisoned| poisoned.into_inner())
22971            .is_empty());
22972    }
22973
22974    #[tokio::test]
22975    async fn ordinary_spawn_and_spawn_spec_cannot_bypass_managed_lease_holder_fence() {
22976        let shared = terminal_test_shared();
22977        let lease = install_managed_lease(&shared);
22978        let raw = shared.spawn_session(
22979            lease.workspace_id.clone(),
22980            agent("claude"),
22981            SessionMode::Pty,
22982            gate4agent_types::TerminalSize { rows: 24, columns: 80 },
22983            None,
22984        ).await.unwrap_err();
22985        assert_eq!(raw.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22986        let mut spec = spawn_spec_fixture();
22987        spec.target.workspace_id = lease.workspace_id.clone();
22988        let specified = shared.spawn_from_spec(spec).await.unwrap_err();
22989        assert_eq!(specified.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22990        let mut selected = spawn_spec_fixture();
22991        selected.idempotency_key = SpawnIdempotencyKey::new("managed-selected-bypass").unwrap();
22992        selected.target.worktree_id = Some(lease.workspace_id);
22993        let selected = shared.spawn_from_spec(selected).await.unwrap_err();
22994        assert_eq!(selected.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22995        assert!(shared.handle.snapshot().sessions.is_empty());
22996    }
22997
22998    #[test]
22999    fn legacy_projection_omits_managed_snapshot_events_and_path_bearing_diagnostics() {
23000        let shared = terminal_test_shared();
23001        let lease = install_managed_lease(&shared);
23002        let snapshot = snapshot_for_wire(
23003            &shared, true, true, true, false, true, true, false, false, true,
23004        );
23005        assert!(snapshot.managed_worktrees.is_empty());
23006        assert!(snapshot.workspaces.iter().all(|workspace| {
23007            workspace.managed_worktree_profiles.is_none()
23008        }));
23009        let mut reply = ResponseEnvelope {
23010            request_id: 1,
23011            result: Ok(NodeResponse::Resync {
23012                event_sequence: 1,
23013                oldest_available_sequence: 1,
23014                snapshot: shared.snapshot(),
23015                events: vec![NodeEventEnvelope {
23016                    sequence: 1,
23017                    event: NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() },
23018                }],
23019            }),
23020        };
23021        project_response_without_managed_worktrees(&mut reply);
23022        let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
23023            panic!("projection changed the response kind");
23024        };
23025        assert!(snapshot.managed_worktrees.is_empty());
23026        assert!(events.is_empty());
23027        let mut git = git_snapshot_for_parser();
23028        git.branch = Some(lease.branch.clone());
23029        git.managed_worktree = Some(crate::protocol::ManagedWorktreeGitScope {
23030            lease_id: lease.lease_id.clone(),
23031            source_workspace_id: lease.source_workspace_id.clone(),
23032            branch: lease.branch.clone(),
23033            base_commit: GitObjectId::new(lease.base_commit.clone()).unwrap(),
23034            active_session_count: 1,
23035            managed_record_count: 0,
23036        });
23037        let mut inspection_reply = ResponseEnvelope {
23038            request_id: 2,
23039            result: Ok(NodeResponse::WorkspaceInspected {
23040                inspection: WorkspaceInspection {
23041                    workspace_id: lease.workspace_id.clone(),
23042                    entries: Vec::new(),
23043                    tree_truncated: false,
23044                    git,
23045                    truncation: None,
23046                },
23047            }),
23048        };
23049        project_response_without_managed_worktrees(&mut inspection_reply);
23050        let Ok(NodeResponse::WorkspaceInspected { inspection }) = inspection_reply.result else {
23051            panic!("projection changed the inspection response kind");
23052        };
23053        assert!(inspection.git.managed_worktree.is_none());
23054        let failure = managed_git_worktree_failure(GitWorktreeError {
23055            kind: GitWorktreeErrorKind::Failed,
23056            message: r"git failed at C:\private\managed-target".to_owned(),
23057        });
23058        assert!(!failure.message.contains("private"));
23059        assert!(!failure.message.contains("managed-target"));
23060    }
23061
23062    #[tokio::test]
23063    async fn restart_reconciliation_retains_exact_owned_worktree_and_explicit_cleanup_removes_it() {
23064        let root = temporary_workspace_root("managed-restart");
23065        let source = root.join("source");
23066        let allocation = root.join("allocation");
23067        std::fs::create_dir_all(&source).unwrap();
23068        std::fs::create_dir_all(&allocation).unwrap();
23069        run_test_git(&source, &["init"]);
23070        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
23071        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
23072        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
23073        run_test_git(&source, &["add", "seed.txt"]);
23074        run_test_git(&source, &["commit", "-m", "seed"]);
23075        let profile = ManagedWorktreeProfile::new(
23076            WorktreeProfileId::new("default").unwrap(),
23077            crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
23078            &allocation,
23079            "gate4agent",
23080            "HEAD",
23081            ManagedWorktreeRetention::Retain,
23082        ).unwrap();
23083        let workspace = WorkspaceConfig::new(
23084            WorkspaceId::new("primary").unwrap(),
23085            &source,
23086        ).unwrap()
23087            .with_worktree_service_mode(WorktreeServiceMode::Managed)
23088            .with_managed_worktree_profile(profile.clone()).unwrap();
23089        let target = PathBuf::from(profile.allocation_root()).join("mw-restart");
23090        let source_text = workspace.canonical_root().to_owned();
23091        let target_text = target.to_string_lossy().into_owned();
23092        let created = create_git_worktree(
23093            &source_text,
23094            &target_text,
23095            "gate4agent/mw-restart",
23096            Some("HEAD"),
23097        ).await.unwrap();
23098        let lease = ManagedWorktreeLeaseRecord {
23099            lease_id: ManagedWorktreeLeaseId::new("mw-restart").unwrap(),
23100            source_workspace_id: WorkspaceId::new("primary").unwrap(),
23101            workspace_id: WorkspaceId::new("managed-restart").unwrap(),
23102            profile_id: profile.profile_id().clone(),
23103            profile_revision: profile.revision().clone(),
23104            target_root: created.path.clone(),
23105            branch: "gate4agent/mw-restart".to_owned(),
23106            base_commit: created.head.clone(),
23107            expected_head: None,
23108            retention: ManagedWorktreeRetention::Retain,
23109            state: ManagedWorktreeLeaseState::Allocating,
23110            session_holders: Vec::new(),
23111            record_holders: Vec::new(),
23112            cleanup_failure: None,
23113            created_at_unix_ms: 1,
23114            updated_at_unix_ms: 1,
23115        };
23116        let catalog = active_registry().unwrap();
23117        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23118        let shared = NodeShared::new_with_incarnation(
23119            handle,
23120            "fixture-token".to_owned(),
23121            NodeId::new("node-managed-restart").unwrap(),
23122            NodeIncarnationId::from_bytes([7; crate::protocol::NODE_INCARNATION_ID_BYTES]),
23123            vec![workspace],
23124            vec![agent("claude")],
23125            ProviderRuntimeStatuses::default(),
23126            None,
23127            Vec::new(),
23128            Vec::new(),
23129            SpawnProfileRegistry::default(),
23130            None,
23131            None,
23132            None,
23133            None,
23134            Vec::new(),
23135            vec![lease.clone()],
23136            Vec::new(),
23137            Vec::new(),
23138            None,
23139            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
23140        );
23141        shared.reconcile_managed_worktrees().await;
23142        let retained = shared.managed_worktrees.lock()
23143            .unwrap_or_else(|poisoned| poisoned.into_inner())
23144            .get(&lease.lease_id).unwrap().clone();
23145        assert_eq!(retained.state, ManagedWorktreeLeaseState::Retained);
23146        assert!(shared.workspaces.read()
23147            .unwrap_or_else(|poisoned| poisoned.into_inner())
23148            .contains_key(&lease.workspace_id));
23149        let removed = shared.cleanup_managed_worktree(&lease.lease_id, true).await.unwrap();
23150        assert_eq!(removed.state, ManagedWorktreeLeaseState::Removed);
23151        assert!(!Path::new(&created.path).exists());
23152        std::fs::remove_dir_all(root).unwrap();
23153    }
23154
23155    fn run_test_git(root: &Path, arguments: &[&str]) {
23156        let output = std::process::Command::new("git")
23157            .arg("-C").arg(root).args(arguments).output().unwrap();
23158        assert!(
23159            output.status.success(),
23160            "git {:?} failed: {}",
23161            arguments,
23162            String::from_utf8_lossy(&output.stderr),
23163        );
23164    }
23165
23166    #[tokio::test]
23167    async fn spawn_spec_validation_fails_before_session_mutation() {
23168        let shared = terminal_test_shared();
23169        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23170            capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
23171        }));
23172        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23173            capability.as_str() == NODE_SPAWN_PROFILE_REVISION_CAPABILITY
23174        }));
23175        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23176            capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
23177        }));
23178
23179        let mut target_mismatch = spawn_spec_fixture();
23180        target_mismatch.target.node_id = NodeId::new("other-node").unwrap();
23181        let failure = shared.spawn_from_spec(target_mismatch).await.unwrap_err();
23182        assert_eq!(failure.code, NodeFailureCode::SpawnTargetMismatch);
23183
23184        let mut unknown_profile = spawn_spec_fixture();
23185        unknown_profile.idempotency_key = SpawnIdempotencyKey::new("unknown-profile").unwrap();
23186        unknown_profile.profile_id = crate::protocol::SpawnProfileId::new("missing").unwrap();
23187        let failure = shared.spawn_from_spec(unknown_profile).await.unwrap_err();
23188        assert_eq!(failure.code, NodeFailureCode::UnknownSpawnProfile);
23189
23190        let mut revision_mismatch = spawn_spec_fixture();
23191        revision_mismatch.idempotency_key =
23192            SpawnIdempotencyKey::new("profile-revision-mismatch").unwrap();
23193        revision_mismatch.expected_profile_revision =
23194            crate::protocol::SpawnProfileRevision::new("test-r2").unwrap();
23195        let failure = shared.spawn_from_spec(revision_mismatch).await.unwrap_err();
23196        assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
23197
23198        let mut unsupported_materializer = spawn_spec_fixture();
23199        unsupported_materializer.idempotency_key =
23200            SpawnIdempotencyKey::new("unsupported-materializer").unwrap();
23201        unsupported_materializer.overrides.bundle_id = crate::protocol::SpawnOverride::Set {
23202            value: crate::protocol::SpawnBundleId::new("bundle-v1").unwrap(),
23203        };
23204        let failure = shared
23205            .spawn_from_spec(unsupported_materializer)
23206            .await
23207            .unwrap_err();
23208        assert_eq!(failure.code, NodeFailureCode::UnknownBundle);
23209
23210        let failure = shared
23211            .spawn_session_with_deadline(
23212                WorkspaceId::new("primary").unwrap(),
23213                agent("claude"),
23214                SessionMode::Pty,
23215                gate4agent_types::TerminalSize {
23216                    rows: 24,
23217                    columns: 80,
23218                },
23219                None,
23220                None,
23221                None,
23222                None,
23223                None,
23224                None,
23225                SpawnRecordPolicy::ProviderIdentityOnly,
23226                Some(Instant::now()),
23227                &[],
23228                None,
23229                ApprovalLevel::default(),
23230                None,
23231                None,
23232            )
23233            .await
23234            .unwrap_err();
23235        assert_eq!(failure.code, NodeFailureCode::SpawnDeadlineExceeded);
23236        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
23237        assert!(shared.handle.snapshot().sessions.is_empty());
23238    }
23239
23240    #[test]
23241    fn worktree_selection_requires_its_separate_negotiated_capability() {
23242        let mut spec = spawn_spec_fixture();
23243        spec.target.worktree_id = Some(WorkspaceId::new("selected-worktree").unwrap());
23244        let request = NodeRequest::SpawnSpec { spec };
23245        let spawn_spec_capability =
23246            CapabilityId::new(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY).unwrap();
23247        assert!(request_uses_unnegotiated_capability(
23248            &request,
23249            &[spawn_spec_capability.clone()],
23250        ));
23251        let worktree_capability =
23252            CapabilityId::new(NODE_WORKTREE_SELECTION_CAPABILITY).unwrap();
23253        assert!(request_uses_unnegotiated_capability(
23254            &request,
23255            &[spawn_spec_capability.clone(), worktree_capability.clone()],
23256        ));
23257        let profile_revision_capability =
23258            CapabilityId::new(NODE_SPAWN_PROFILE_REVISION_CAPABILITY).unwrap();
23259        assert!(request_uses_unnegotiated_capability(
23260            &request,
23261            &[
23262                spawn_spec_capability.clone(),
23263                worktree_capability.clone(),
23264                profile_revision_capability.clone(),
23265            ],
23266        ));
23267        // Default `SpawnOverrides` leaves `bundle_id`/`context_id` at
23268        // `Inherit`, not `Clear` — the profile's own defaults might still
23269        // materialize a bundle or a context pack, so both capabilities stay
23270        // conservatively required until negotiated (see
23271        // `requires_session_bundle_materialization_capability` /
23272        // `requires_history_context_pack_capability`).
23273        let bundle_materialization_capability =
23274            CapabilityId::new(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY).unwrap();
23275        assert!(request_uses_unnegotiated_capability(
23276            &request,
23277            &[
23278                spawn_spec_capability.clone(),
23279                worktree_capability.clone(),
23280                profile_revision_capability.clone(),
23281                bundle_materialization_capability.clone(),
23282            ],
23283        ));
23284        let history_context_pack_capability =
23285            CapabilityId::new(NODE_HISTORY_CONTEXT_PACK_CAPABILITY).unwrap();
23286        assert!(!request_uses_unnegotiated_capability(
23287            &request,
23288            &[
23289                spawn_spec_capability,
23290                worktree_capability,
23291                profile_revision_capability,
23292                bundle_materialization_capability,
23293                history_context_pack_capability,
23294            ],
23295        ));
23296    }
23297
23298    #[tokio::test]
23299    async fn spawn_spec_idempotency_replays_receipt_and_conflicts_before_session_mutation() {
23300        let mut shared = terminal_test_shared();
23301        let spec = spawn_spec_fixture();
23302        let receipt = shared
23303            .resolve_spawn_spec(&spec)
23304            .unwrap()
23305            .receipt(shared.incarnation_id, terminal_address(1));
23306        shared.remember_spawn_spec(spec.clone(), Ok(receipt.clone()), Instant::now());
23307
23308        let mut revised = shared.spawn_profiles.get(&spec.profile_id).unwrap().clone();
23309        revised.revision = crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
23310        shared.spawn_profiles = SpawnProfileRegistry::new([revised]).unwrap();
23311
23312        assert_eq!(shared.spawn_from_spec(spec.clone()).await.unwrap(), receipt);
23313
23314        let mut revision_conflicting = spec.clone();
23315        revision_conflicting.expected_profile_revision =
23316            crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
23317        let failure = shared.spawn_from_spec(revision_conflicting).await.unwrap_err();
23318        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
23319
23320        let mut stale_cache_miss = spec.clone();
23321        stale_cache_miss.idempotency_key =
23322            SpawnIdempotencyKey::new("stale-profile-cache-miss").unwrap();
23323        let failure = shared.spawn_from_spec(stale_cache_miss).await.unwrap_err();
23324        assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
23325
23326        let mut conflicting = spec;
23327        conflicting.deadline_ms = crate::protocol::SpawnDeadlineMs::new(31_000).unwrap();
23328        let failure = shared.spawn_from_spec(conflicting).await.unwrap_err();
23329        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
23330        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
23331        assert!(shared.handle.snapshot().sessions.is_empty());
23332    }
23333
23334    #[tokio::test]
23335    async fn off_worktree_service_rejects_selection_and_mutation_before_git() {
23336        let shared = terminal_test_shared_with_mode(WorktreeServiceMode::Off);
23337        let mut spec = spawn_spec_fixture();
23338        spec.target.worktree_id = Some(WorkspaceId::new("unregistered-worktree").unwrap());
23339        let failure = shared.spawn_from_spec(spec).await.unwrap_err();
23340        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
23341
23342        let failure = shared
23343            .create_worktree(
23344                WorkspaceId::new("primary").unwrap(),
23345                WorkspaceId::new("new-worktree").unwrap(),
23346                "not-an-absolute-path".to_owned(),
23347                "invalid branch".to_owned(),
23348                None,
23349            )
23350            .await
23351            .unwrap_err();
23352        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
23353
23354        let failure = shared
23355            .remove_worktree(
23356                WorkspaceId::new("primary").unwrap(),
23357                "not-an-absolute-path".to_owned(),
23358            )
23359            .await
23360            .unwrap_err();
23361        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
23362        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
23363        assert!(shared.handle.snapshot().sessions.is_empty());
23364    }
23365
23366    #[tokio::test]
23367    async fn manual_worktree_registration_failure_removes_the_created_worktree() {
23368        let root = temporary_workspace_root("manual-worktree-compensation");
23369        let source = root.join("source");
23370        let target = root.join("target");
23371        let invalid_state_parent = root.join("state-parent-is-a-file");
23372        let invalid_state_path = invalid_state_parent.join("state.json");
23373        std::fs::create_dir_all(&source).unwrap();
23374        std::fs::write(&invalid_state_parent, "not a directory").unwrap();
23375        run_test_git(&source, &["init"]);
23376        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
23377        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
23378        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
23379        run_test_git(&source, &["add", "seed.txt"]);
23380        run_test_git(&source, &["commit", "-m", "seed"]);
23381
23382        let catalog = active_registry().unwrap();
23383        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23384        let workspace = WorkspaceConfig::new(
23385            WorkspaceId::new("primary").unwrap(),
23386            &source,
23387        ).unwrap();
23388        let mut shared = NodeShared::new(
23389            handle,
23390            "fixture-token".to_owned(),
23391            NodeId::new("node-worktree-compensation").unwrap(),
23392            vec![workspace],
23393            vec![agent("claude")],
23394        );
23395        shared.state_path = Some(invalid_state_path);
23396
23397        let error = shared.create_worktree(
23398            WorkspaceId::new("primary").unwrap(),
23399            WorkspaceId::new("compensated").unwrap(),
23400            target.to_string_lossy().into_owned(),
23401            "gate4agent/compensated".to_owned(),
23402            Some("HEAD".to_owned()),
23403        ).await.unwrap_err();
23404
23405        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
23406        assert!(!target.exists());
23407        assert!(list_git_worktrees(source.to_str().unwrap()).await.unwrap()
23408            .iter()
23409            .all(|worktree| !worktree_paths_equal(&worktree.path, &target.to_string_lossy())));
23410        std::fs::remove_dir_all(root).unwrap();
23411    }
23412
23413    #[test]
23414    fn selected_worktree_requires_authoritative_non_main_non_bare_entry() {
23415        fn worktree(path: &str, is_main: bool, is_bare: bool) -> NativeGitWorktreeSnapshot {
23416            NativeGitWorktreeSnapshot {
23417                path: path.to_owned(),
23418                head: "0123456789abcdef".to_owned(),
23419                branch: Some("refs/heads/test".to_owned()),
23420                is_bare,
23421                is_main,
23422                locked: false,
23423                lock_reason: None,
23424                prunable: false,
23425                prunable_reason: None,
23426                workspace_id: None,
23427            }
23428        }
23429
23430        let source = r"C:\repo";
23431        let selected = r"C:\repo-worktree";
23432        let listed = vec![
23433            worktree(source, true, false),
23434            worktree(selected, false, false),
23435        ];
23436        assert!(validate_selected_worktree(source, selected, &listed).is_ok());
23437
23438        let main_failure = validate_selected_worktree(source, source, &listed).unwrap_err();
23439        assert_eq!(main_failure.code, NodeFailureCode::WorktreeProtected);
23440
23441        let bare = vec![
23442            worktree(source, true, false),
23443            worktree(selected, false, true),
23444        ];
23445        let bare_failure = validate_selected_worktree(source, selected, &bare).unwrap_err();
23446        assert_eq!(bare_failure.code, NodeFailureCode::WorktreeProtected);
23447
23448        let foreign_failure =
23449            validate_selected_worktree(source, r"C:\foreign", &listed).unwrap_err();
23450        assert_eq!(foreign_failure.code, NodeFailureCode::WorktreeProtected);
23451    }
23452
23453    fn terminal_frame(sequence: u64) -> TerminalFrame {
23454        TerminalFrame {
23455            sequence,
23456            size: gate4agent_types::TerminalSize {
23457                rows: 24,
23458                columns: 80,
23459            },
23460            cursor_row: 2,
23461            cursor_column: 3,
23462            contents: format!("frame-{sequence}"),
23463            formatted: vec![1, 2, 3],
23464            scrollback_formatted: Vec::new(),
23465            alternate_screen: false,
23466            mouse_protocol_enabled: false,
23467            mouse_protocol_encoding: Default::default(),
23468            produced_at_unix_ms: 0,
23469            screen_state: gate4agent_types::PtyScreenState::default(),
23470            bracketed_paste: None,
23471        }
23472    }
23473
23474    #[test]
23475    fn terminal_frame_watermark_coalesces_and_resets_for_exact_session_address() {
23476        let shared = terminal_test_shared();
23477        let mut receiver = shared.terminal_event_tx.subscribe();
23478        let original = terminal_address(1);
23479
23480        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
23481        let first = receiver.try_recv().unwrap();
23482        assert_eq!(first.len(), 1);
23483        assert!(matches!(
23484            &first[0].event,
23485            NodeEvent::TerminalFrame { address, frame }
23486                if address == &original && frame.sequence == 7
23487        ));
23488
23489        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
23490        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(6))]);
23491        assert!(matches!(
23492            receiver.try_recv(),
23493            Err(broadcast::error::TryRecvError::Empty)
23494        ));
23495
23496        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(8))]);
23497        let advanced = receiver.try_recv().unwrap();
23498        assert_eq!(advanced[0].sequence, 2);
23499
23500        let resumed = terminal_address(2);
23501        shared.publish_terminal_frame_candidates(vec![(resumed.clone(), terminal_frame(1))]);
23502        let reset = receiver.try_recv().unwrap();
23503        assert_eq!(reset[0].sequence, 3);
23504        assert!(matches!(
23505            &reset[0].event,
23506            NodeEvent::TerminalFrame { address, frame }
23507                if address == &resumed && frame.sequence == 1
23508        ));
23509        shared.clear_terminal_frame_watermark(&resumed);
23510        assert!(!shared
23511            .terminal_frame_watermarks
23512            .lock()
23513            .unwrap_or_else(|poisoned| poisoned.into_inner())
23514            .contains_key(&resumed.session.instance_id));
23515    }
23516
23517    #[test]
23518    fn terminal_frame_batches_replace_without_entering_node_event_history() {
23519        let shared = terminal_test_shared();
23520        let mut receiver = shared.terminal_event_tx.subscribe();
23521        let address = terminal_address(1);
23522        let count = NODE_EVENT_HISTORY_MAX as u64 + 17;
23523
23524        for sequence in 1..=count {
23525            shared.publish_terminal_frame_candidates(vec![(
23526                address.clone(),
23527                terminal_frame(sequence),
23528            )]);
23529        }
23530
23531        let history = shared
23532            .history
23533            .lock()
23534            .unwrap_or_else(|poisoned| poisoned.into_inner());
23535        assert!(history.events.is_empty());
23536        assert_eq!(history.last_sequence, count);
23537        assert_eq!(history.replay_floor_sequence, 1);
23538        drop(history);
23539        let NodeResponse::Resync {
23540            event_sequence,
23541            oldest_available_sequence,
23542            events,
23543            ..
23544        } = shared.resync(0) else {
23545            unreachable!("resync helper returned another response")
23546        };
23547        assert_eq!(event_sequence, count);
23548        assert_eq!(oldest_available_sequence, 1);
23549        assert!(events.is_empty());
23550        assert!(matches!(
23551            resync_required_event(&shared).event,
23552            NodeEvent::ResyncRequired {
23553                oldest_available_sequence: 1,
23554            }
23555        ));
23556        assert!(matches!(
23557            receiver.try_recv(),
23558            Err(broadcast::error::TryRecvError::Lagged(skipped)) if skipped == count - 1
23559        ));
23560        let latest = receiver.try_recv().unwrap();
23561        assert_eq!(latest.len(), 1);
23562        assert_eq!(latest[0].sequence, count);
23563    }
23564
23565    #[test]
23566    fn real_durable_eviction_advances_authoritative_replay_floor() {
23567        let shared = terminal_test_shared();
23568        for _ in 0..=NODE_EVENT_HISTORY_MAX {
23569            shared.publish(NodeEvent::ControllerChanged { controller: None });
23570        }
23571
23572        let NodeResponse::Resync {
23573            event_sequence,
23574            oldest_available_sequence,
23575            events,
23576            ..
23577        } = shared.resync(0) else {
23578            unreachable!("resync helper returned another response")
23579        };
23580        assert_eq!(event_sequence, NODE_EVENT_HISTORY_MAX as u64 + 1);
23581        assert_eq!(oldest_available_sequence, 2);
23582        assert_eq!(events.first().map(|event| event.sequence), Some(2));
23583        assert_eq!(events.len(), NODE_EVENT_HISTORY_MAX);
23584        assert!(matches!(
23585            resync_required_event(&shared).event,
23586            NodeEvent::ResyncRequired {
23587                oldest_available_sequence: 2,
23588            }
23589        ));
23590    }
23591
23592    #[test]
23593    fn legacy_terminal_frame_connection_has_no_live_subscription() {
23594        let shared = terminal_test_shared();
23595        let without_capability = terminal_event_subscription(&shared, false);
23596        let with_capability = terminal_event_subscription(&shared, true);
23597        assert!(without_capability.is_none());
23598        assert!(with_capability.is_some());
23599        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23600            capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
23601        }));
23602    }
23603
23604    #[test]
23605    fn continuous_terminal_output_leaves_request_service_after_bounded_event_burst() {
23606        let mut pending = Vec::new();
23607        let mut event_sequence = 0_u64;
23608        for frame_sequence in 1..=4 {
23609            for instance in 1..=(NODE_CONNECTION_EVENT_BURST_MAX as u64 * 2) {
23610                event_sequence += 1;
23611                let mut address = terminal_address(1);
23612                address.session.instance_id = AgentInstanceId(instance);
23613                queue_connection_event(
23614                    &mut pending,
23615                    NodeEventEnvelope {
23616                        sequence: event_sequence,
23617                        event: NodeEvent::TerminalFrame {
23618                            address,
23619                            frame: terminal_frame(frame_sequence),
23620                        },
23621                    },
23622                    0,
23623                );
23624            }
23625        }
23626        assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX * 2);
23627        pending.sort_unstable_by_key(|event| event.sequence);
23628
23629        let mut uninterrupted = pending.clone();
23630        let first_burst = connection_event_burst_len(uninterrupted.len());
23631        uninterrupted.drain(..first_burst);
23632        let second_burst = connection_event_burst_len(uninterrupted.len());
23633        uninterrupted.drain(..second_burst);
23634        assert!(uninterrupted.is_empty());
23635
23636        let (frame_tx, mut frame_rx) =
23637            mpsc::channel::<Result<ClientFrame, FrameError>>(1);
23638        frame_tx
23639            .try_send(Ok(ClientFrame::Request(RequestEnvelope {
23640                request_id: 77,
23641                request: NodeRequest::Snapshot,
23642            })))
23643            .unwrap();
23644        let burst_len = connection_event_burst_len(pending.len());
23645        assert_eq!(burst_len, NODE_CONNECTION_EVENT_BURST_MAX);
23646        pending.drain(..burst_len);
23647        assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX);
23648        assert!(matches!(
23649            frame_rx.try_recv(),
23650            Ok(Ok(ClientFrame::Request(RequestEnvelope {
23651                request_id: 77,
23652                request: NodeRequest::Snapshot,
23653            })))
23654        ));
23655    }
23656
23657    #[test]
23658    fn terminal_lag_watermark_discards_retained_and_replayed_batches() {
23659        let mut pending = Vec::new();
23660        for sequence in [7_u64, 8] {
23661            let mut address = terminal_address(1);
23662            address.session.instance_id = AgentInstanceId(sequence);
23663            queue_connection_event(
23664                &mut pending,
23665                NodeEventEnvelope {
23666                    sequence,
23667                    event: NodeEvent::TerminalFrame {
23668                        address,
23669                        frame: terminal_frame(sequence),
23670                    },
23671                },
23672                0,
23673            );
23674        }
23675        let mut discard_through = 0;
23676        apply_connection_resync_watermark(&mut pending, &mut discard_through, 8);
23677        assert!(pending.is_empty());
23678
23679        let replayed = NodeEventEnvelope {
23680            sequence: 8,
23681            event: NodeEvent::TerminalFrame {
23682                address: terminal_address(1),
23683                frame: terminal_frame(8),
23684            },
23685        };
23686        let advanced = NodeEventEnvelope {
23687            sequence: 9,
23688            event: NodeEvent::TerminalFrame {
23689                address: terminal_address(2),
23690                frame: terminal_frame(1),
23691            },
23692        };
23693        queue_connection_event_batch(
23694            &mut pending,
23695            &[replayed, advanced.clone()],
23696            discard_through,
23697        );
23698        assert_eq!(pending, vec![advanced]);
23699    }
23700
23701    fn record(provider: &str, record_id: &str) -> ManagedSessionRecord {
23702        ManagedSessionRecord {
23703            record_id: SessionRecordId::new(record_id).unwrap(),
23704            display_name: format!("{provider} record"),
23705            provider: agent(provider),
23706            mode: SessionMode::Pty,
23707            state: ManagedSessionState::Unavailable,
23708            workspace_id: WorkspaceId::new("primary").unwrap(),
23709            canonical_root: opaque_windows_path(
23710                std::env::current_dir().unwrap().to_string_lossy().into_owned(),
23711            ),
23712            provider_session: None,
23713            active_session: None,
23714            environment_profile: None,
23715            bundle: None,
23716            context_id: None,
23717            context: None,
23718            exported_context: None,
23719            task_binding: None,
23720            created_at_unix_ms: 1,
23721            updated_at_unix_ms: 1,
23722            last_error: None,
23723        }
23724    }
23725
23726    fn retention_test_record(
23727        workspace_id: &str,
23728        record_id: &str,
23729        state: ManagedSessionState,
23730        updated_at_unix_ms: u64,
23731    ) -> ManagedSessionRecord {
23732        ManagedSessionRecord {
23733            record_id: SessionRecordId::new(record_id).unwrap(),
23734            display_name: format!("{record_id} display"),
23735            provider: agent("claude"),
23736            mode: SessionMode::Pty,
23737            state,
23738            workspace_id: WorkspaceId::new(workspace_id).unwrap(),
23739            canonical_root: opaque_windows_path(
23740                std::env::current_dir().unwrap().to_string_lossy().into_owned(),
23741            ),
23742            provider_session: None,
23743            active_session: None,
23744            environment_profile: None,
23745            bundle: None,
23746            context_id: None,
23747            context: None,
23748            exported_context: None,
23749            task_binding: None,
23750            created_at_unix_ms: updated_at_unix_ms,
23751            updated_at_unix_ms,
23752            last_error: None,
23753        }
23754    }
23755
23756    /// Two workspaces of three `Unavailable` records each, staggered by
23757    /// age, plus one never-touchable decoy per non-`Unavailable` state in
23758    /// `workspace-a` (all as old as that workspace's oldest real record --
23759    /// if the sweep's state gate ever leaked, these would be the first
23760    /// thing it wrongly retired). Ages, oldest to newest:
23761    /// `workspace-a`: a1 (500s), a2 (300s), a3 (100s).
23762    /// `workspace-b`: b1 (400s), b2 (200s), b3 (50s).
23763    fn retention_fixture_records(now: u64) -> Vec<ManagedSessionRecord> {
23764        vec![
23765            retention_test_record("workspace-a", "a1", ManagedSessionState::Unavailable, now - 500_000),
23766            retention_test_record("workspace-a", "a2", ManagedSessionState::Unavailable, now - 300_000),
23767            retention_test_record("workspace-a", "a3", ManagedSessionState::Unavailable, now - 100_000),
23768            retention_test_record("workspace-b", "b1", ManagedSessionState::Unavailable, now - 400_000),
23769            retention_test_record("workspace-b", "b2", ManagedSessionState::Unavailable, now - 200_000),
23770            retention_test_record("workspace-b", "b3", ManagedSessionState::Unavailable, now - 50_000),
23771            retention_test_record("workspace-a", "decoy-live", ManagedSessionState::Live, now - 500_000),
23772            retention_test_record(
23773                "workspace-a",
23774                "decoy-pending",
23775                ManagedSessionState::IdentityPending,
23776                now - 500_000,
23777            ),
23778            retention_test_record(
23779                "workspace-a",
23780                "decoy-dormant",
23781                ManagedSessionState::Dormant,
23782                now - 500_000,
23783            ),
23784        ]
23785    }
23786
23787    fn retention_fixture_shared() -> (NodeShared, NativeRuntime, PathBuf, u64) {
23788        let root = temporary_workspace_root("session-record-retention");
23789        std::fs::create_dir_all(&root).unwrap();
23790        let catalog = active_registry().unwrap();
23791        let (handle, runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23792        let workspace_a =
23793            WorkspaceConfig::new(WorkspaceId::new("workspace-a").unwrap(), &root).unwrap();
23794        let workspace_b =
23795            WorkspaceConfig::new(WorkspaceId::new("workspace-b").unwrap(), &root).unwrap();
23796        let mut shared = NodeShared::new(
23797            handle,
23798            "fixture-token".to_owned(),
23799            NodeId::new("node-retention-test").unwrap(),
23800            vec![workspace_a, workspace_b],
23801            vec![agent("claude")],
23802        );
23803        shared.state_path = Some(root.join("node-state-v10.json"));
23804        let now = unix_time_ms();
23805        for record in retention_fixture_records(now) {
23806            shared.insert_record(record).unwrap();
23807        }
23808        shared
23809            .persist_state_locked()
23810            .expect("fixture-only, empty-history persist against a fresh temp state path");
23811        (shared, runtime, root, now)
23812    }
23813
23814    fn retention_removed_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23815        shared
23816            .history
23817            .lock()
23818            .unwrap_or_else(|poisoned| poisoned.into_inner())
23819            .events
23820            .iter()
23821            .filter_map(|envelope| match &envelope.event {
23822                NodeEvent::SessionRecordRemoved { record_id } => {
23823                    Some(record_id.as_str().to_owned())
23824                }
23825                _ => None,
23826            })
23827            .collect()
23828    }
23829
23830    fn retention_remaining_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23831        shared
23832            .session_records
23833            .lock()
23834            .unwrap_or_else(|poisoned| poisoned.into_inner())
23835            .records
23836            .keys()
23837            .map(|record_id| record_id.as_str().to_owned())
23838            .collect()
23839    }
23840
23841    fn owned(values: &[&str]) -> std::collections::BTreeSet<String> {
23842        values.iter().map(|value| (*value).to_owned()).collect()
23843    }
23844
23845    #[tokio::test]
23846    async fn session_record_retention_keep_n_only_keeps_the_newest_per_workspace() {
23847        let (mut shared, runtime, root, _now) = retention_fixture_shared();
23848        shared.session_record_retention = SessionRecordRetentionConfig {
23849            age_ms: 0,
23850            keep_per_workspace: 1,
23851        };
23852        shared.retire_unavailable_session_records().await;
23853
23854        assert_eq!(
23855            retention_remaining_record_ids(&shared),
23856            owned(&["a3", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23857        );
23858        assert_eq!(
23859            retention_removed_record_ids(&shared),
23860            owned(&["a1", "a2", "b1", "b2"]),
23861        );
23862        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 4);
23863
23864        drop(shared);
23865        drop(runtime);
23866        std::fs::remove_dir_all(root).unwrap();
23867    }
23868
23869    /// Age-only would retire {a1, a2, b1}; keep-N-only would retire
23870    /// {a1, a2, b1, b2}. With both flags set the sweep must retire exactly
23871    /// the intersection {a1, a2, b1} -- `b2` fails the age test (it is only
23872    /// 200s old against a 250s threshold) even though it fails the keep-N
23873    /// test too, proving retirement needs BOTH, not either.
23874    #[tokio::test]
23875    async fn session_record_retention_both_set_retires_only_the_intersection() {
23876        let (mut shared, runtime, root, _now) = retention_fixture_shared();
23877        shared.session_record_retention = SessionRecordRetentionConfig {
23878            age_ms: 250_000,
23879            keep_per_workspace: 1,
23880        };
23881        shared.retire_unavailable_session_records().await;
23882
23883        assert_eq!(
23884            retention_remaining_record_ids(&shared),
23885            owned(&["a3", "b2", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23886        );
23887        assert_eq!(retention_removed_record_ids(&shared), owned(&["a1", "a2", "b1"]));
23888        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 3);
23889
23890        drop(shared);
23891        drop(runtime);
23892        std::fs::remove_dir_all(root).unwrap();
23893    }
23894
23895    #[tokio::test]
23896    async fn session_record_retention_both_disabled_is_a_no_op() {
23897        let (mut shared, runtime, root, _now) = retention_fixture_shared();
23898        shared.session_record_retention = SessionRecordRetentionConfig::default();
23899        shared.retire_unavailable_session_records().await;
23900
23901        assert_eq!(
23902            retention_remaining_record_ids(&shared),
23903            owned(&[
23904                "a1", "a2", "a3", "b1", "b2", "b3",
23905                "decoy-live", "decoy-pending", "decoy-dormant",
23906            ]),
23907        );
23908        assert!(retention_removed_record_ids(&shared).is_empty());
23909        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 0);
23910
23911        drop(shared);
23912        drop(runtime);
23913        std::fs::remove_dir_all(root).unwrap();
23914    }
23915
23916    #[test]
23917    fn session_record_context_export_selects_one_exact_history_identity() {
23918        let candidates = vec![
23919            HistoryCandidateSummary {
23920                id: "candidate-other".to_owned(),
23921                session_id_hint: "provider-other".to_owned(),
23922                modified_at_unix_ms: Some(1),
23923            },
23924            HistoryCandidateSummary {
23925                id: "candidate-exact".to_owned(),
23926                session_id_hint: "provider-exact".to_owned(),
23927                modified_at_unix_ms: Some(2),
23928            },
23929        ];
23930        assert_eq!(
23931            select_history_candidate(&candidates, Some("provider-exact")).unwrap(),
23932            "candidate-exact",
23933        );
23934    }
23935
23936    #[test]
23937    fn session_record_context_export_rejects_missing_and_ambiguous_history_identity() {
23938        let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
23939            id: id.to_owned(),
23940            session_id_hint: hint.to_owned(),
23941            modified_at_unix_ms: None,
23942        };
23943        let missing = select_history_candidate(
23944            &[candidate("candidate-other", "provider-other")],
23945            Some("provider-exact"),
23946        )
23947        .unwrap_err();
23948        assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23949        let ambiguous = select_history_candidate(
23950            &[
23951                candidate("candidate-a", "provider-exact"),
23952                candidate("candidate-b", "provider-exact"),
23953            ],
23954            Some("provider-exact"),
23955        )
23956        .unwrap_err();
23957        assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
23958    }
23959
23960    /// The PTY-sidecar-observation shape (no fleet provider today, but
23961    /// capability-keyed rather than vendor-locked) never carries a verified
23962    /// `provider_session`; its record-bound export instead falls back to
23963    /// "exactly one discovered candidate" (see
23964    /// `export_context_pack_for_session_record_inner`). This is the same
23965    /// ambiguity refusal as the identity-filtered path, just without an
23966    /// identity to filter by.
23967    #[test]
23968    fn session_record_context_export_selects_sole_history_candidate_without_identity() {
23969        let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
23970            id: id.to_owned(),
23971            session_id_hint: hint.to_owned(),
23972            modified_at_unix_ms: None,
23973        };
23974        assert_eq!(
23975            select_history_candidate(&[candidate("candidate-sole", "sidecar-session")], None)
23976                .unwrap(),
23977            "candidate-sole",
23978        );
23979        let missing = select_history_candidate(&[], None).unwrap_err();
23980        assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23981        let ambiguous = select_history_candidate(
23982            &[
23983                candidate("candidate-a", "sidecar-session-a"),
23984                candidate("candidate-b", "sidecar-session-b"),
23985            ],
23986            None,
23987        )
23988        .unwrap_err();
23989        assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
23990    }
23991
23992    #[test]
23993    fn session_record_context_export_revalidation_rejects_record_change() {
23994        let session = terminal_address(1);
23995        let identity = ProviderSessionIdentity {
23996            key: ProviderSessionKey::SessionId,
23997            id: "provider-exact".to_owned(),
23998            transcript_path: Some(r"C:\private\provider.jsonl".to_owned()),
23999        };
24000        let mut expected = record("claude", "record-context-export");
24001        expected.state = ManagedSessionState::Live;
24002        expected.provider_session = Some(identity.clone());
24003        expected.active_session = Some(session.clone());
24004        let binding = SessionBinding {
24005            workspace_id: session.workspace_id.clone(),
24006            generation: session.session.generation,
24007            runtime_policy: ProviderRuntimePolicy::raw_pty(),
24008            pending_resume: None,
24009            record_id: Some(expected.record_id.clone()),
24010            managed_worktree_lease_id: None,
24011            environment_profile: None,
24012            bundle: None,
24013            context: None,
24014            materialization_id: None,
24015        };
24016        let current_root = windows_path_text(&expected.canonical_root).to_owned();
24017        assert!(session_record_context_export_binding_is_exact(
24018            &expected,
24019            &expected,
24020            Some(&identity),
24021            &session,
24022            &binding,
24023            &expected.provider,
24024            &current_root,
24025            false,
24026        ));
24027        let mut changed = expected.clone();
24028        changed.updated_at_unix_ms += 1;
24029        assert!(!session_record_context_export_binding_is_exact(
24030            &expected,
24031            &changed,
24032            Some(&identity),
24033            &session,
24034            &binding,
24035            &expected.provider,
24036            &current_root,
24037            false,
24038        ));
24039        // A same-instant timestamp bump alone (state and active_session
24040        // otherwise unchanged) is not the clean-detachment transition
24041        // either, even with the reactive flag on: only the exact Live ->
24042        // Dormant + active_session cleared shape qualifies.
24043        assert!(!session_record_context_export_binding_is_exact(
24044            &expected,
24045            &changed,
24046            Some(&identity),
24047            &session,
24048            &binding,
24049            &expected.provider,
24050            &current_root,
24051            true,
24052        ));
24053        let mut detached = expected.clone();
24054        detached.state = ManagedSessionState::Dormant;
24055        detached.active_session = None;
24056        detached.updated_at_unix_ms += 1;
24057        assert!(!session_record_context_export_binding_is_exact(
24058            &expected,
24059            &detached,
24060            Some(&identity),
24061            &session,
24062            &binding,
24063            &expected.provider,
24064            &current_root,
24065            false,
24066        ));
24067        assert!(session_record_context_export_binding_is_exact(
24068            &expected,
24069            &detached,
24070            Some(&identity),
24071            &session,
24072            &binding,
24073            &expected.provider,
24074            &current_root,
24075            true,
24076        ));
24077    }
24078
24079    /// Mirrors `session_record_context_export_revalidation_rejects_record_change`
24080    /// for a record whose provider never carries a verified identity (the
24081    /// PTY-sidecar-observation shape -- capability-keyed, no fleet provider
24082    /// declares one today): `identity` is `None` throughout, and the
24083    /// exactness check falls back to comparing `provider_session` itself,
24084    /// which must still be `None` on both sides.
24085    #[test]
24086    fn session_record_context_export_binding_is_exact_without_identity() {
24087        let session = terminal_address(1);
24088        let mut expected = record("pty-sidecar-fixture", "record-context-export-no-identity");
24089        expected.state = ManagedSessionState::Live;
24090        expected.active_session = Some(session.clone());
24091        let binding = SessionBinding {
24092            workspace_id: session.workspace_id.clone(),
24093            generation: session.session.generation,
24094            runtime_policy: ProviderRuntimePolicy::raw_pty(),
24095            pending_resume: None,
24096            record_id: Some(expected.record_id.clone()),
24097            managed_worktree_lease_id: None,
24098            environment_profile: None,
24099            bundle: None,
24100            context: None,
24101            materialization_id: None,
24102        };
24103        let current_root = windows_path_text(&expected.canonical_root).to_owned();
24104        assert!(session_record_context_export_binding_is_exact(
24105            &expected,
24106            &expected,
24107            None,
24108            &session,
24109            &binding,
24110            &expected.provider,
24111            &current_root,
24112            false,
24113        ));
24114        // A record that gained an identity between the initial lookup and
24115        // this recheck no longer matches the `None` this shape expects to
24116        // keep seeing -- treated the same as any other mid-export drift.
24117        let mut drifted = expected.clone();
24118        drifted.provider_session = Some(ProviderSessionIdentity {
24119            key: ProviderSessionKey::SessionId,
24120            id: "unexpected-identity".to_owned(),
24121            transcript_path: None,
24122        });
24123        assert!(!session_record_context_export_binding_is_exact(
24124            &expected,
24125            &drifted,
24126            None,
24127            &session,
24128            &binding,
24129            &expected.provider,
24130            &current_root,
24131            false,
24132        ));
24133    }
24134
24135    #[test]
24136    fn session_record_context_export_preflight_requires_live_usable_source() {
24137        let mut source = record("claude", "record-context-export-preflight");
24138        source.state = ManagedSessionState::Live;
24139        assert!(session_record_context_export_source_is_usable(
24140            &source,
24141            &SessionStatus::Running,
24142            false,
24143        ));
24144        assert!(session_record_context_export_source_is_usable(
24145            &source,
24146            &SessionStatus::Exited { exit_code: Some(0) },
24147            false,
24148        ));
24149
24150        source.state = ManagedSessionState::Dormant;
24151        assert!(!session_record_context_export_source_is_usable(
24152            &source,
24153            &SessionStatus::Running,
24154            false,
24155        ));
24156        // The reactive auto-export-at-exit path (allow_clean_detachment) is
24157        // the only caller that may treat a Dormant, cleanly-exited record as
24158        // still usable — the on-demand path above never does.
24159        assert!(session_record_context_export_source_is_usable(
24160            &source,
24161            &SessionStatus::Exited { exit_code: Some(0) },
24162            true,
24163        ));
24164        assert!(!session_record_context_export_source_is_usable(
24165            &source,
24166            &SessionStatus::Exited { exit_code: Some(0) },
24167            false,
24168        ));
24169        source.state = ManagedSessionState::Live;
24170        assert!(!session_record_context_export_source_is_usable(
24171            &source,
24172            &SessionStatus::Starting,
24173            false,
24174        ));
24175    }
24176
24177    #[test]
24178    fn session_record_context_export_final_conflict_leaves_catalog_empty() {
24179        let catalog = active_registry().unwrap();
24180        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24181        let workspace = WorkspaceConfig::new(
24182            WorkspaceId::new("primary").unwrap(),
24183            std::env::current_dir().unwrap(),
24184        )
24185        .unwrap();
24186        let shared = NodeShared::new(
24187            handle,
24188            "fixture-token".to_owned(),
24189            NodeId::new("node-context-export-conflict").unwrap(),
24190            vec![workspace],
24191            vec![agent("claude")],
24192        );
24193        let history = HistorySessionRecord {
24194            session_id: "provider-exact".to_owned(),
24195            title: None,
24196            cwd: None,
24197            model: None,
24198            message_count: 1,
24199            completed_turn_count: None,
24200            total_tokens: 0,
24201            messages: vec![gate4agent_types::HistoryMessageRecord {
24202                role: gate4agent_types::HistoryMessageRole::User,
24203                text: "bounded context".to_owned(),
24204            }],
24205        };
24206        let pack = NodeContextPack::export(
24207            ContextPackLineageReceipt {
24208                source_node_id: shared.node_id.clone(),
24209                source_session: terminal_address(1),
24210                source_provider: agent("claude"),
24211            },
24212            &history,
24213        )
24214        .unwrap();
24215        let context_id = pack.receipt().id.clone();
24216        let session = terminal_address(1);
24217        let identity = ProviderSessionIdentity {
24218            key: ProviderSessionKey::SessionId,
24219            id: history.session_id.clone(),
24220            transcript_path: None,
24221        };
24222        let mut expected = record("claude", "record-context-export-final-conflict");
24223        expected.state = ManagedSessionState::Live;
24224        expected.provider_session = Some(identity.clone());
24225        expected.active_session = Some(session.clone());
24226        let mut changed = expected.clone();
24227        changed.updated_at_unix_ms += 1;
24228        shared.insert_record(changed).unwrap();
24229
24230        let error = shared
24231            .commit_context_pack_for_session_record(
24232                &expected,
24233                Some(&identity),
24234                &session,
24235                "candidate-exact",
24236                &history,
24237                pack,
24238                false,
24239            )
24240            .unwrap_err();
24241
24242        assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
24243        assert!(shared
24244            .context_catalog
24245            .read()
24246            .unwrap_or_else(|poisoned| poisoned.into_inner())
24247            .get(&context_id)
24248            .is_none());
24249    }
24250
24251    #[tokio::test]
24252    async fn session_task_binding_mints_assigns_clears_with_cas_and_persists_v9() {
24253        let root = temporary_workspace_root("session-task-binding");
24254        std::fs::create_dir_all(&root).unwrap();
24255        let state_path = root.join("node-state.json");
24256        let catalog = active_registry().unwrap();
24257        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24258        let workspace = WorkspaceConfig::new(WorkspaceId::new("primary").unwrap(), &root).unwrap();
24259        let node_id = NodeId::new("node-1").unwrap();
24260        let mut shared = NodeShared::new(
24261            handle,
24262            "fixture-token".to_owned(),
24263            node_id.clone(),
24264            vec![workspace],
24265            vec![agent("claude")],
24266        );
24267        shared.state_path = Some(state_path.clone());
24268        let record = record("claude", "sr-task");
24269        let record_id = record.record_id.clone();
24270        shared.insert_record(record).unwrap();
24271        shared.persist_state().unwrap();
24272        shared
24273            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
24274            .unwrap();
24275
24276        let minted = process_request_inner(
24277            &shared,
24278            77,
24279            ClientRole::Operator,
24280            NodeRequest::SetSessionTask {
24281                record_id: record_id.clone(),
24282                expected_revision: 0,
24283                target: SessionTaskTargetV1::New,
24284            },
24285        )
24286        .await
24287        .unwrap();
24288        let NodeResponse::SessionRecordUpdated { record: minted } = minted else {
24289            panic!("session task mutation returned another response");
24290        };
24291        let minted_binding = minted.task_binding.clone().unwrap();
24292        let minted_task_id = minted_binding.task_id.clone().unwrap();
24293        assert_eq!(minted_binding.revision, 1);
24294        assert!(minted_task_id.as_str().starts_with("task-"));
24295        assert_eq!(minted_task_id.as_str().len(), 29);
24296
24297        let event_count = shared.current_sequence();
24298        let repeated = process_request_inner(
24299            &shared,
24300            77,
24301            ClientRole::Operator,
24302            NodeRequest::SetSessionTask {
24303                record_id: record_id.clone(),
24304                expected_revision: 1,
24305                target: SessionTaskTargetV1::Existing {
24306                    task_id: minted_task_id.clone(),
24307                },
24308            },
24309        )
24310        .await
24311        .unwrap();
24312        assert_eq!(repeated, NodeResponse::SessionRecordUpdated { record: minted.clone() });
24313        assert_eq!(shared.current_sequence(), event_count);
24314
24315        let stale = process_request_inner(
24316            &shared,
24317            77,
24318            ClientRole::Operator,
24319            NodeRequest::SetSessionTask {
24320                record_id: record_id.clone(),
24321                expected_revision: 0,
24322                target: SessionTaskTargetV1::Existing {
24323                    task_id: minted_task_id,
24324                },
24325            },
24326        )
24327        .await
24328        .unwrap_err();
24329        assert_eq!(stale.code, NodeFailureCode::SessionRecordConflict);
24330
24331        let assigned_id: TaskId = "task-ffeeddccbbaa998877665544".parse().unwrap();
24332        let assigned = process_request_inner(
24333            &shared,
24334            77,
24335            ClientRole::Operator,
24336            NodeRequest::SetSessionTask {
24337                record_id: record_id.clone(),
24338                expected_revision: 1,
24339                target: SessionTaskTargetV1::Existing {
24340                    task_id: assigned_id.clone(),
24341                },
24342            },
24343        )
24344        .await
24345        .unwrap();
24346        let NodeResponse::SessionRecordUpdated { record: assigned } = assigned else {
24347            panic!("session task assignment returned another response");
24348        };
24349        assert_eq!(assigned.task_binding.as_ref().unwrap().revision, 2);
24350        assert_eq!(assigned.task_binding.as_ref().unwrap().task_id.as_ref(), Some(&assigned_id));
24351
24352        let cleared = process_request_inner(
24353            &shared,
24354            77,
24355            ClientRole::Operator,
24356            NodeRequest::SetSessionTask {
24357                record_id: record_id.clone(),
24358                expected_revision: 2,
24359                target: SessionTaskTargetV1::Clear,
24360            },
24361        )
24362        .await
24363        .unwrap();
24364        let NodeResponse::SessionRecordUpdated { record: cleared } = cleared else {
24365            panic!("session task clear returned another response");
24366        };
24367        assert_eq!(cleared.task_binding.as_ref().unwrap().revision, 3);
24368        assert!(cleared.task_binding.as_ref().unwrap().task_id.is_none());
24369        assert!(cleared.task_binding.as_ref().unwrap().changed_at_unix_ms > 0);
24370        assert_eq!(
24371            cleared.task_binding.as_ref().unwrap().changed_at_unix_ms,
24372            cleared.updated_at_unix_ms,
24373        );
24374
24375        let persisted = std::fs::read(&state_path).unwrap();
24376        assert_eq!(serde_json::from_slice::<serde_json::Value>(&persisted).unwrap()["version"], NODE_STATE_SCHEMA_V10);
24377        let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
24378        assert_eq!(loaded.records[0].task_binding, cleared.task_binding);
24379        std::fs::remove_dir_all(root).unwrap();
24380    }
24381
24382    #[test]
24383    fn session_task_capability_strips_snapshots_events_and_replies() {
24384        let mut bound = record("claude", "sr-task-projection");
24385        bound.updated_at_unix_ms = 2;
24386        bound.task_binding = Some(SessionTaskBindingV1 {
24387            revision: 1,
24388            task_id: Some("task-00112233445566778899aaff".parse().unwrap()),
24389            changed_at_unix_ms: 2,
24390        });
24391        let request = NodeRequest::SetSessionTask {
24392            record_id: bound.record_id.clone(),
24393            expected_revision: 1,
24394            target: SessionTaskTargetV1::Clear,
24395        };
24396        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
24397            capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
24398        }));
24399        assert!(!request_uses_unnegotiated_capability(
24400            &request,
24401            &baseline_capabilities().unwrap(),
24402        ));
24403        assert!(request_uses_unnegotiated_capability(&request, &[]));
24404
24405        let mut snapshot = terminal_test_shared().snapshot();
24406        snapshot.session_records.push(bound.clone());
24407        let mut reply = ResponseEnvelope {
24408            request_id: 1,
24409            result: Ok(NodeResponse::Resync {
24410                event_sequence: 1,
24411                oldest_available_sequence: 1,
24412                snapshot,
24413                events: vec![NodeEventEnvelope {
24414                    sequence: 1,
24415                    event: NodeEvent::SessionRecordUpserted {
24416                        record: bound.clone(),
24417                    },
24418                }],
24419            }),
24420        };
24421        project_response_without_session_task_binding(&mut reply);
24422        let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
24423            panic!("task projection changed the response kind");
24424        };
24425        assert!(snapshot.session_records[0].task_binding.is_none());
24426        let NodeEvent::SessionRecordUpserted { record } = &events[0].event else {
24427            panic!("task projection changed the event kind");
24428        };
24429        assert!(record.task_binding.is_none());
24430
24431        let projected = project_event_without_session_task_binding(NodeEventEnvelope {
24432            sequence: 2,
24433            event: NodeEvent::SessionRecordUpserted { record: bound.clone() },
24434        });
24435        let NodeEvent::SessionRecordUpserted { record } = projected.event else {
24436            panic!("task event projection changed the event kind");
24437        };
24438        assert!(record.task_binding.is_none());
24439
24440        let mut direct = ResponseEnvelope {
24441            request_id: 2,
24442            result: Ok(NodeResponse::SessionRecordUpdated { record: bound }),
24443        };
24444        project_response_without_session_task_binding(&mut direct);
24445        let Ok(NodeResponse::SessionRecordUpdated { record }) = direct.result else {
24446            panic!("task reply projection changed the response kind");
24447        };
24448        assert!(record.task_binding.is_none());
24449    }
24450
24451    #[test]
24452    fn windows_runtime_default_node_endpoint_is_exact_and_valid() {
24453        assert_eq!(DEFAULT_NODE_ENDPOINT, r"\\.\pipe\gate4agent-node");
24454        let workspace = WorkspaceConfig::new(
24455            WorkspaceId::new("primary").unwrap(),
24456            std::env::current_dir().unwrap(),
24457        )
24458        .unwrap();
24459        let config = NodeServerConfig::new(
24460            DEFAULT_NODE_ENDPOINT,
24461            "fixture-token",
24462            NodeId::new("node-1").unwrap(),
24463            [workspace],
24464        )
24465        .unwrap();
24466        assert_eq!(config.endpoint, DEFAULT_NODE_ENDPOINT);
24467    }
24468
24469    #[cfg(all(feature = "fixture", windows))]
24470    #[test]
24471    fn provider_bundle_argv_hold_fixture_is_parent_bounded_and_waits_after_proof() {
24472        let root = temporary_workspace_root("provider-bundle-argv-hold");
24473        let outside = temporary_workspace_root("provider-bundle-argv-hold-outside");
24474        std::fs::create_dir_all(&root).unwrap();
24475        std::fs::create_dir_all(&outside).unwrap();
24476        let proof_path = root.join("provider.proof");
24477        let release_signal = root.join("release.signal");
24478        let config = NodeServerConfig::new(
24479            format!(
24480                r"\\.\pipe\gate4agent-provider-bundle-hold-{}",
24481                std::process::id(),
24482            ),
24483            "fixture-token",
24484            NodeId::new("provider-bundle-hold-node").unwrap(),
24485            [WorkspaceConfig::new(
24486                WorkspaceId::new("primary").unwrap(),
24487                std::env::current_dir().unwrap(),
24488            )
24489            .unwrap()],
24490        )
24491        .unwrap()
24492        .with_spawn_profiles(SpawnProfileRegistry::new([
24493            crate::protocol::SpawnProfileDefaults {
24494                profile_id: crate::protocol::SpawnProfileId::new("kimi-bundle-hold").unwrap(),
24495                revision: crate::protocol::SpawnProfileRevision::new("kimi-bundle-hold-r1")
24496                    .unwrap(),
24497                provider: agent("kimi"),
24498                mode: SessionMode::Pty,
24499                terminal_size: gate4agent_types::TerminalSize {
24500                    rows: 24,
24501                    columns: 80,
24502                },
24503                prompt: None,
24504                bundle_id: None,
24505                context_id: None,
24506                environment_profile_id: None,
24507            },
24508        ])
24509        .unwrap());
24510        let server = NodeServer::new_provider_bundle_argv_hold_fixture(
24511            config,
24512            agent("kimi"),
24513            proof_path.clone(),
24514            release_signal.clone(),
24515        )
24516        .unwrap();
24517        assert_eq!(server.shared.enabled_providers, [agent("kimi")]);
24518
24519        let spec = NodeServer::provider_bundle_argv_fixture_spec(
24520            agent("kimi"),
24521            proof_path.clone(),
24522            Some(release_signal.clone()),
24523        )
24524        .unwrap();
24525        let wrapper = &spec.launch.fixed_args[spec.launch.fixed_args.len() - 3];
24526        let proof_write = wrapper.find("WriteAllLines($proofPath").unwrap();
24527        let release_wait = wrapper
24528            .find("while (-not (Test-Path -LiteralPath $releaseSignal -PathType Leaf))")
24529            .unwrap();
24530        let fixture_ready = wrapper.find("fixture-ready>").unwrap();
24531        assert!(proof_write < release_wait);
24532        assert!(release_wait < fixture_ready);
24533        assert!(wrapper.contains("[DateTime]::UtcNow.AddSeconds(45)"));
24534        assert!(wrapper.contains("Start-Sleep -Milliseconds 20"));
24535        assert_eq!(
24536            &spec.launch.fixed_args[spec.launch.fixed_args.len() - 2..],
24537            &[
24538                proof_path.to_string_lossy().into_owned(),
24539                release_signal.to_string_lossy().into_owned(),
24540            ],
24541        );
24542
24543        let error = NodeServer::provider_bundle_argv_fixture_spec(
24544            agent("kimi"),
24545            proof_path,
24546            Some(outside.join("release.signal")),
24547        )
24548        .unwrap_err();
24549        assert!(matches!(
24550            error,
24551            NodeServerError::Registry(message)
24552                if message == "provider bundle argv release path must share the proof parent"
24553        ));
24554        drop(server);
24555        std::fs::remove_dir_all(root).unwrap();
24556        std::fs::remove_dir_all(outside).unwrap();
24557    }
24558
24559    #[cfg(feature = "fixture")]
24560    #[test]
24561    fn four_provider_context_fixture_registry_is_unambiguous_and_history_bound() {
24562        let root = temporary_workspace_root("context-fixture-registry");
24563        std::fs::create_dir_all(&root).unwrap();
24564        let proof_path = root.join("context.proof");
24565        let config = NodeServerConfig::new(
24566            format!(
24567                r"\\.\pipe\gate4agent-context-fixture-registry-{}",
24568                std::process::id(),
24569            ),
24570            "fixture-token",
24571            NodeId::new("context-fixture-node").unwrap(),
24572            [WorkspaceConfig::new(
24573                WorkspaceId::new("primary").unwrap(),
24574                std::env::current_dir().unwrap(),
24575            )
24576            .unwrap()],
24577        )
24578        .unwrap();
24579        let server = NodeServer::new_context_pack_fixture(config, proof_path).unwrap();
24580        let mut enabled = server
24581            .shared
24582            .enabled_providers
24583            .iter()
24584            .map(AgentId::as_str)
24585            .collect::<Vec<_>>();
24586        enabled.sort_unstable();
24587        assert_eq!(enabled, ["claude", "codex", "grok", "kimi"]);
24588        for provider in &server.shared.enabled_providers {
24589            assert!(server
24590                .shared
24591                .provider_adapter_contracts
24592                .iter()
24593                .any(|contract| {
24594                    &contract.provider == provider
24595                        && contract.family == AdapterFamily::History
24596                }));
24597        }
24598        drop(server);
24599        std::fs::remove_dir_all(root).unwrap();
24600    }
24601
24602    #[cfg(feature = "fixture")]
24603    #[test]
24604    fn monitoring_context_fixture_combines_claude_and_codex_hooks_with_history() {
24605        let root = temporary_workspace_root("monitoring-context-fixture");
24606        std::fs::create_dir_all(&root).unwrap();
24607        let proof_path = root.join("context.proof");
24608        let standard = NodeServer::context_pack_fixture_catalog(
24609            proof_path.clone(),
24610            None,
24611            false,
24612        ).unwrap();
24613        let monitoring = NodeServer::context_pack_fixture_catalog(
24614            proof_path.clone(),
24615            None,
24616            true,
24617        ).unwrap();
24618        let claude = AgentId::new("claude").unwrap();
24619        let standard_claude = standard.get(&claude).unwrap();
24620        let monitoring_claude = monitoring.get(&claude).unwrap();
24621        // Lifecycle hooks are retired (owner ruling 2026-09-25): neither the
24622        // standard nor the monitoring fixture declares a hook adapter
24623        // anymore.
24624        assert!(monitoring_claude.capabilities.adapters.hook.is_none());
24625        assert_eq!(
24626            monitoring_claude.capabilities.adapters.history,
24627            standard_claude.capabilities.adapters.history,
24628        );
24629        assert_ne!(monitoring_claude.launch, standard_claude.launch);
24630        let codex = AgentId::new("codex").unwrap();
24631        let standard_codex = standard.get(&codex).unwrap();
24632        let monitoring_codex = monitoring.get(&codex).unwrap();
24633        assert!(monitoring_codex.capabilities.adapters.hook.is_none());
24634        assert_eq!(
24635            monitoring_codex.capabilities.adapters.history,
24636            standard_codex.capabilities.adapters.history,
24637        );
24638        assert_ne!(monitoring_codex.launch, standard_codex.launch);
24639        let codex_validation = monitoring_codex.launch.fixed_args.iter()
24640            .find(|argument| argument.contains("F7_CODEX_BUNDLE_CONTEXT_VALIDATED"))
24641            .unwrap();
24642        for required in [
24643            "$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')",
24644            "$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')",
24645            "@('role', 'text')",
24646            "g4a-private-provider-session-canary",
24647            "private-provider-login-identity",
24648            "$rawProof",
24649            "$env:GATE4AGENT_HOOK_URL",
24650            "g4a-private-provider-session-canary",
24651        ] {
24652            assert!(codex_validation.contains(required), "missing privacy validation: {required}");
24653        }
24654        for provider in ["grok", "kimi"] {
24655            let provider = AgentId::new(provider).unwrap();
24656            assert_eq!(monitoring.get(&provider), standard.get(&provider));
24657        }
24658
24659        let config = NodeServerConfig::new(
24660            format!(
24661                r"\\.\pipe\gate4agent-monitoring-context-fixture-{}",
24662                std::process::id(),
24663            ),
24664            "fixture-token",
24665            NodeId::new("monitoring-context-fixture-node").unwrap(),
24666            [WorkspaceConfig::new(
24667                WorkspaceId::new("primary").unwrap(),
24668                std::env::current_dir().unwrap(),
24669            )
24670            .unwrap()],
24671        )
24672        .unwrap();
24673        let server = NodeServer::new_monitoring_context_pack_fixture(config, proof_path).unwrap();
24674        assert!(server.shared.fixture_semantic_hook_policy);
24675        drop(server);
24676        std::fs::remove_dir_all(root).unwrap();
24677    }
24678
24679    #[cfg(feature = "fixture")]
24680    #[test]
24681    fn kimi_context_only_fixture_is_bounded_and_preserves_codex_validation() {
24682        let root = temporary_workspace_root("kimi-context-only-fixture");
24683        std::fs::create_dir_all(&root).unwrap();
24684        let proof_path = root.join("context.proof");
24685        let standard = NodeServer::context_pack_fixture_catalog(
24686            proof_path.clone(),
24687            None,
24688            false,
24689        ).unwrap();
24690        let kimi = AgentId::new("kimi").unwrap();
24691        let context_only = NodeServer::context_pack_fixture_catalog(
24692            proof_path,
24693            Some(&kimi),
24694            false,
24695        )
24696        .unwrap();
24697
24698        let codex = AgentId::new("codex").unwrap();
24699        assert_eq!(
24700            standard.get(&codex).unwrap().launch.fixed_args,
24701            context_only.get(&codex).unwrap().launch.fixed_args,
24702        );
24703        let script = context_only
24704            .get(&kimi)
24705            .unwrap()
24706            .launch
24707            .fixed_args
24708            .iter()
24709            .find(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED"))
24710            .expect("Kimi context-only validation script is missing");
24711        for required in [
24712            "$bundleArgs.Count -ne 0",
24713            "GATE4AGENT_CONTEXT_ROOT",
24714            "context-pack.json",
24715            "$entries.Count -ne 1",
24716            "g4a-context-pack-v1",
24717            "$document.PSObject.Properties['cwd']",
24718            "'claude', 'codex', 'grok', 'kimi'",
24719            "$hasUser",
24720            "$hasAssistant",
24721            "SHA256",
24722            "context-only",
24723        ] {
24724            assert!(script.contains(required), "missing validation: {required}");
24725        }
24726        for forbidden in ["CODEX_HOME", "USERPROFILE", "auth", "token", "global config"] {
24727            assert!(!script.contains(forbidden), "forbidden home/auth access: {forbidden}");
24728        }
24729        assert!(!standard
24730            .get(&kimi)
24731            .unwrap()
24732            .launch
24733            .fixed_args
24734            .iter()
24735            .any(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED")));
24736
24737        std::fs::remove_dir_all(root).unwrap();
24738    }
24739
24740    #[cfg(all(feature = "fixture", windows))]
24741    #[tokio::test]
24742    async fn kimi_context_only_fixture_spawns_catalogued_context_pack_through_spawn_spec() {
24743        let root = temporary_workspace_root("kimi-context-only-spawn");
24744        let workspace_root = root.join("workspace");
24745        std::fs::create_dir_all(&workspace_root).unwrap();
24746        let materialization_root = root.join("private-materializations");
24747        let proof_path = root.join("context.proof");
24748        let node_id = NodeId::new("node-kimi-context-only-spawn").unwrap();
24749        let workspace_id = WorkspaceId::new("primary").unwrap();
24750        let profile_id = crate::protocol::SpawnProfileId::new("target-kimi").unwrap();
24751        let endpoint = format!(
24752            r"\\.\pipe\gate4agent-kimi-context-only-spawn-{}-{}",
24753            std::process::id(),
24754            unix_time_ms(),
24755        );
24756        let spawn_profiles = SpawnProfileRegistry::new([
24757            crate::protocol::SpawnProfileDefaults {
24758                profile_id: profile_id.clone(),
24759                revision: crate::protocol::SpawnProfileRevision::new("target-kimi-r1").unwrap(),
24760                provider: agent("kimi"),
24761                mode: SessionMode::Pty,
24762                terminal_size: gate4agent_types::TerminalSize {
24763                    rows: 30,
24764                    columns: 120,
24765                },
24766                prompt: None,
24767                bundle_id: None,
24768                context_id: None,
24769                environment_profile_id: None,
24770            },
24771        ])
24772        .unwrap();
24773        let mut config = NodeServerConfig::new(
24774            &endpoint,
24775            "fixture-token",
24776            node_id.clone(),
24777            [WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap()],
24778        )
24779        .unwrap()
24780        .with_spawn_profiles(spawn_profiles)
24781        .with_session_environment_materialization(
24782            materialization_root,
24783            Arc::new(FixtureSecretResolver {
24784                deny: Arc::new(AtomicBool::new(false)),
24785            }),
24786        )
24787        .unwrap();
24788        config.fixture_raw_pty_runtime = true;
24789        let server = NodeServer::new_context_only_proof_fixture(
24790            config,
24791            agent("kimi"),
24792            proof_path.clone(),
24793        )
24794        .unwrap();
24795        let shared = Arc::clone(&server.shared);
24796        let shutdown = server.shutdown_handle();
24797
24798        let history = HistorySessionRecord {
24799            session_id: "context-only-source-history".to_owned(),
24800            title: None,
24801            cwd: None,
24802            model: None,
24803            message_count: 2,
24804            completed_turn_count: None,
24805            total_tokens: 0,
24806            messages: vec![
24807                gate4agent_types::HistoryMessageRecord {
24808                    role: gate4agent_types::HistoryMessageRole::User,
24809                    text: "continue the bounded parent run through native C2".to_owned(),
24810                },
24811                gate4agent_types::HistoryMessageRecord {
24812                    role: gate4agent_types::HistoryMessageRole::Assistant,
24813                    text: "the exact parent context is ready for the child run".to_owned(),
24814                },
24815            ],
24816        };
24817        let pack = NodeContextPack::export(
24818            ContextPackLineageReceipt {
24819                source_node_id: node_id.clone(),
24820                source_session: SessionAddress {
24821                    workspace_id: workspace_id.clone(),
24822                    session: SessionKey {
24823                        instance_id: AgentInstanceId(71),
24824                        generation: SessionGeneration(1),
24825                    },
24826                },
24827                source_provider: agent("codex"),
24828            },
24829            &history,
24830        )
24831        .unwrap();
24832        let expected_context_hash = digest(&SHA256, pack.bytes())
24833            .as_ref()
24834            .iter()
24835            .map(|byte| format!("{byte:02x}"))
24836            .collect::<String>();
24837        let context = shared
24838            .context_catalog
24839            .write()
24840            .unwrap_or_else(|poisoned| poisoned.into_inner())
24841            .insert(pack)
24842            .unwrap();
24843        let spec = SpawnSpec {
24844            target: crate::protocol::SpawnTarget {
24845                node_id,
24846                workspace_id: workspace_id.clone(),
24847                worktree_id: None,
24848            },
24849            profile_id,
24850            expected_profile_revision: crate::protocol::SpawnProfileRevision::new(
24851                "target-kimi-r1",
24852            ).unwrap(),
24853            overrides: crate::protocol::SpawnOverrides {
24854                context_id: crate::protocol::SpawnOverride::Set {
24855                    value: context.id.clone(),
24856                },
24857                ..crate::protocol::SpawnOverrides::default()
24858            },
24859            deadline_ms: crate::protocol::SpawnDeadlineMs::new(10_000).unwrap(),
24860            idempotency_key: SpawnIdempotencyKey::new("kimi-context-only-spawn").unwrap(),
24861            required_capabilities: SpawnRequiredCapabilities::default(),
24862        };
24863        let resolved = match shared.resolve_spawn_spec(&spec) {
24864            Ok(resolved) => resolved,
24865            Err(error) => panic!(
24866                "Kimi context-only resolve rejected: exact_code={:?} message={}",
24867                error.code,
24868                error.message,
24869            ),
24870        };
24871        let resolved_context = match shared.resolve_context(&resolved) {
24872            Ok(Some(resolved_context)) => resolved_context,
24873            Ok(None) => panic!("Kimi context-only resolve omitted the exact ContextPack"),
24874            Err(error) => panic!(
24875                "Kimi context catalog resolve rejected: exact_code={:?} message={}",
24876                error.code,
24877                error.message,
24878            ),
24879        };
24880        assert_eq!(resolved_context, context);
24881        let runtime_policy = match shared
24882            .admit_provider_runtime(&agent("kimi"), ProviderRuntimeRequirement::RawPty)
24883            .await
24884        {
24885            Ok(runtime_policy) => runtime_policy,
24886            Err(error) => panic!(
24887                "Kimi RawPty admission rejected: exact_code={:?} message={}",
24888                error.code,
24889                error.message,
24890            ),
24891        };
24892        assert!(runtime_policy.raw_pty_lifecycle);
24893
24894        let launch_control = shared.native_launch_profile_control.as_ref().unwrap();
24895        let generic_overlay = NativeLaunchEnvironmentOverlay::new(
24896            agent("kimi"),
24897            TransportKind::Pty,
24898            vec![EnvMutation {
24899                key: OsString::from("GATE4AGENT_UNPROFILED_GENERIC"),
24900                value: Some(OsString::from("must-remain-rejected")),
24901            }],
24902        )
24903        .unwrap();
24904        assert_eq!(
24905            launch_control
24906                .install_native_launch_environment_overlay(
24907                    AgentInstanceId(72),
24908                    generic_overlay,
24909                )
24910                .unwrap_err(),
24911            gate4agent_runtime_native::NativeLaunchProfileError::EnvironmentOverlaySelectionMissing,
24912        );
24913
24914        let diagnostic_address = SessionAddress {
24915            workspace_id: workspace_id.clone(),
24916            session: SessionKey {
24917                instance_id: AgentInstanceId(73),
24918                generation: SessionGeneration(1),
24919            },
24920        };
24921        let (diagnostic_overlay, diagnostic_materialization) = match shared
24922            .prepare_session_materialization(
24923                &diagnostic_address,
24924                &agent("kimi"),
24925                SessionMode::Pty,
24926                None,
24927                None,
24928                Some(&resolved_context),
24929                None,
24930            )
24931        {
24932            Ok(Some(prepared)) => prepared,
24933            Ok(None) => panic!("Kimi context-only materialization produced no launch overlay"),
24934            Err(error) => panic!(
24935                "Kimi context-only materialization rejected: exact_code={:?} message={}",
24936                error.code,
24937                error.message,
24938            ),
24939        };
24940        let diagnostic_overlay = diagnostic_overlay
24941            .expect("Kimi context-only materialization omitted its environment overlay");
24942        assert!(matches!(
24943            &diagnostic_overlay,
24944            PreparedNativeLaunchOverlay::Environment(_),
24945        ));
24946        match shared.install_prepared_launch_overlay(
24947            diagnostic_address.session.instance_id,
24948            diagnostic_overlay,
24949        ) {
24950            Ok(None) => {}
24951            Ok(Some(_)) => panic!("Kimi context-only overlay unexpectedly retained argv authority"),
24952            Err(error) => panic!(
24953                "Kimi context-only overlay installation rejected: exact_code={:?} message={}",
24954                error.code,
24955                error.message,
24956            ),
24957        }
24958        assert!(launch_control.clear_native_instance_launch_overlay(
24959            diagnostic_address.session.instance_id,
24960        ));
24961        drop(diagnostic_materialization);
24962        assert!(shared
24963            .materializations
24964            .lock()
24965            .unwrap_or_else(|poisoned| poisoned.into_inner())
24966            .is_empty());
24967
24968        let node_task = tokio::spawn(server.run());
24969        let ready_client = timeout(Duration::from_secs(5), async {
24970            loop {
24971                match gate4agent_node_wire::LocalNodeClient::connect(
24972                    &endpoint,
24973                    &shared.node_id,
24974                    ClientRole::Observer,
24975                    "fixture-token",
24976                )
24977                .await
24978                {
24979                    Ok(client) => break client,
24980                    Err(_) if !node_task.is_finished() => sleep(Duration::from_millis(10)).await,
24981                    Err(error) => panic!("Kimi context-only Node stopped before readiness: {error}"),
24982                }
24983            }
24984        })
24985        .await
24986        .expect("Kimi context-only Node did not become ready");
24987        assert_eq!(
24988            ready_client
24989                .hello()
24990                .snapshot
24991                .provider_runtime_statuses
24992                .iter()
24993                .find(|status| status.provider().as_str() == "kimi")
24994                .expect("Kimi runtime status is missing")
24995                .mode(),
24996            crate::protocol::ProviderRuntimeMode::RawPassthrough,
24997        );
24998        drop(ready_client);
24999
25000        let receipt = match shared.spawn_from_spec(spec).await {
25001            Ok(receipt) => receipt,
25002            Err(error) => panic!(
25003                "Kimi context-only SpawnSpec rejected: exact_code={:?} message={}",
25004                error.code,
25005                error.message,
25006            ),
25007        };
25008        assert_eq!(receipt.provider, agent("kimi"));
25009        assert_eq!(receipt.context.as_ref(), Some(&context));
25010        assert!(receipt.context_binding_is_valid());
25011        timeout(Duration::from_secs(5), async {
25012            while !proof_path.is_file() {
25013                sleep(Duration::from_millis(10)).await;
25014            }
25015        })
25016        .await
25017        .expect("Kimi context-only fixture did not write its proof");
25018
25019        let proof = std::fs::read_to_string(&proof_path).unwrap();
25020        let lines = proof.lines().collect::<Vec<_>>();
25021        assert_eq!(lines.len(), 7);
25022        assert_eq!(lines[0], "context-only");
25023        assert!(Path::new(lines[1]).is_absolute());
25024        assert_ne!(
25025            std::fs::canonicalize(Path::new(lines[1])).unwrap(),
25026            std::fs::canonicalize(&workspace_root).unwrap(),
25027        );
25028        assert_eq!(
25029            std::fs::canonicalize(Path::new(lines[2])).unwrap(),
25030            std::fs::canonicalize(&workspace_root).unwrap(),
25031        );
25032        assert_eq!(lines[3], expected_context_hash);
25033        assert_eq!(lines[4], "g4a-context-pack-v1");
25034        assert_eq!(lines[5], "codex");
25035        assert_eq!(lines[6], "2");
25036        assert!(shared.handle.snapshot().sessions.iter().any(|session| {
25037            session.instance_id == receipt.session.session.instance_id
25038                && session.agent_id.as_str() == "kimi"
25039        }));
25040
25041        shutdown.request_shutdown().await.unwrap();
25042        timeout(Duration::from_secs(5), node_task)
25043            .await
25044            .unwrap()
25045            .unwrap()
25046            .unwrap();
25047        drop(shutdown);
25048        drop(shared);
25049        std::fs::remove_dir_all(root).unwrap();
25050    }
25051
25052    #[cfg(feature = "fixture")]
25053    #[test]
25054    fn clean_exit_fixture_constructor_is_fixture_scoped_and_path_bounded() {
25055        let root = temporary_workspace_root("clean-exit-constructor");
25056        std::fs::create_dir_all(&root).unwrap();
25057        let config = construction_test_config("clean-exit-constructor")
25058            .with_state_path(root.join("node-state.json"))
25059            .unwrap();
25060        let server = NodeServer::new_clean_exit_fixture(
25061            config,
25062            root.clone(),
25063            root.join("started.marker"),
25064            root.join("release.signal"),
25065        )
25066        .unwrap();
25067        assert_eq!(
25068            server
25069                .shared
25070                .enabled_providers
25071                .iter()
25072                .map(AgentId::as_str)
25073                .collect::<Vec<_>>(),
25074            ["claude"],
25075        );
25076        drop(server);
25077        std::fs::remove_dir_all(root).unwrap();
25078    }
25079
25080    #[test]
25081    fn production_registry_provider_contract_manifest_is_exact() {
25082        let registry = active_registry().unwrap();
25083        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
25084        assert_eq!(
25085            providers
25086                .iter()
25087                .map(|contract| (contract.provider.as_str(), contract.revision.as_str()))
25088                .collect::<Vec<_>>(),
25089            vec![
25090                ("claude", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25091                ("codex", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25092                ("grok", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25093                ("kimi", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25094            ]
25095        );
25096        assert_eq!(
25097            adapters
25098                .iter()
25099                .map(|contract| (
25100                    contract.provider.as_str(),
25101                    contract.family,
25102                    contract.adapter_id.as_str(),
25103                    contract.revision.as_str(),
25104                ))
25105                .collect::<Vec<_>>(),
25106            vec![
25107                ("claude", AdapterFamily::PtySemantic, "claude-code", "gate4agent-adapter/v1"),
25108                ("claude", AdapterFamily::Pipe, "claude-code", "gate4agent-adapter/v1"),
25109                ("claude", AdapterFamily::Acp, "claude-code", "gate4agent-adapter/v1"),
25110                ("claude", AdapterFamily::OneShot, "claude", "gate4agent-inline/claude-code-2.1/v1"),
25111                ("claude", AdapterFamily::History, "claude-code", "gate4agent-adapter/v1"),
25112                ("claude", AdapterFamily::Resume, "claude-code", "gate4agent-adapter/v1"),
25113                ("claude", AdapterFamily::SessionOptions, "claude-code", "gate4agent-session-options/orca-d8629c4/v1"),
25114                ("codex", AdapterFamily::PtySemantic, "codex", "gate4agent-adapter/v1"),
25115                ("codex", AdapterFamily::Pipe, "codex", "gate4agent-adapter/v1"),
25116                ("codex", AdapterFamily::Acp, "codex", "gate4agent-adapter/v1"),
25117                ("codex", AdapterFamily::OneShot, "codex", "gate4agent-inline/codex-cli-0.144/v1"),
25118                ("codex", AdapterFamily::History, "codex", "gate4agent-adapter/v1"),
25119                ("codex", AdapterFamily::Resume, "codex", "gate4agent-adapter/v1"),
25120                ("codex", AdapterFamily::SessionOptions, "codex", "gate4agent-session-options/orca-d8629c4/v1"),
25121                ("grok", AdapterFamily::Acp, "grok", "gate4agent-adapter/v1"),
25122                ("grok", AdapterFamily::History, "grok", "gate4agent-adapter/v1"),
25123                ("grok", AdapterFamily::Resume, "grok", "gate4agent-adapter/v1"),
25124                ("kimi", AdapterFamily::PtySemantic, "kimi", "gate4agent-adapter/v1"),
25125                ("kimi", AdapterFamily::Pipe, "kimi", "gate4agent-adapter/v1"),
25126                ("kimi", AdapterFamily::Acp, "kimi", "gate4agent-adapter/v1"),
25127                ("kimi", AdapterFamily::OneShot, "kimi", "gate4agent-inline/kimi-code-0.31/v1"),
25128                ("kimi", AdapterFamily::History, "kimi", "gate4agent-adapter/v1"),
25129                ("kimi", AdapterFamily::Resume, "kimi", "gate4agent-adapter/v1"),
25130            ]
25131        );
25132    }
25133
25134    fn construction_test_config(name: &str) -> NodeServerConfig {
25135        let workspace = WorkspaceConfig::new(
25136            WorkspaceId::new("primary").unwrap(),
25137            std::env::current_dir().unwrap(),
25138        )
25139        .unwrap();
25140        NodeServerConfig::new(
25141            format!(r"\\.\pipe\gate4agent-{name}"),
25142            "fixture-token",
25143            NodeId::new(name).unwrap(),
25144            [workspace],
25145        )
25146        .unwrap()
25147    }
25148
25149    fn maximum_size_provider_manifest_registry() -> AgentRegistry {
25150        let adapter = AdapterBinding::new(
25151            gate4agent_types::AdapterId::new("a".repeat(96)).unwrap(),
25152            "r".repeat(crate::protocol::MAX_ADAPTER_CONTRACT_REVISION_BYTES),
25153            gate4agent_types::AdapterVerification::SyntheticFixture,
25154        )
25155        .unwrap();
25156        let agent_ids = ["claude", "codex", "kimi"]
25157            .into_iter()
25158            .map(|agent_id| AgentId::new(agent_id).unwrap())
25159            .collect::<Vec<_>>();
25160        let adapters = gate4agent_catalog::AdapterRegistry::new(
25161            [
25162                AdapterFamily::PtySemantic,
25163                AdapterFamily::Pipe,
25164                AdapterFamily::OneShot,
25165                AdapterFamily::Acp,
25166                AdapterFamily::Hook,
25167                AdapterFamily::ManagedHook,
25168                AdapterFamily::History,
25169                AdapterFamily::Resume,
25170                AdapterFamily::SessionOptions,
25171                AdapterFamily::CapabilityProbe,
25172            ]
25173            .into_iter()
25174            .map(|family| gate4agent_catalog::AdapterDescriptor {
25175                family,
25176                binding: adapter.clone(),
25177                agents: agent_ids.clone(),
25178            }),
25179        )
25180        .unwrap();
25181        let specs = agent_ids
25182            .iter()
25183            .map(|agent_id| {
25184                let agent_id = agent_id.as_str();
25185                let mut spec = builtin_registry().get_by_id(agent_id).unwrap().clone();
25186                spec.revision =
25187                    "r".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES);
25188                spec.capabilities.transports.pty_adapter = Some(adapter.clone());
25189                spec.capabilities.transports.pipe = Some(gate4agent_types::PipeTransportSpec {
25190                    adapter: adapter.clone(),
25191                    protocol: gate4agent_types::PipeProtocol::SemanticNdjson,
25192                    launch_override: None,
25193                    prompt_delivery: gate4agent_types::PipePromptDelivery::None,
25194                });
25195                spec.capabilities.transports.acp = Some(gate4agent_types::AcpTransportSpec {
25196                    adapter: adapter.clone(),
25197                    launch_override: None,
25198                });
25199                spec.capabilities.adapters.hook = Some(adapter.clone());
25200                spec.capabilities.adapters.managed_hook = Some(adapter.clone());
25201                spec.capabilities.adapters.one_shot = Some(adapter.clone());
25202                spec.capabilities.adapters.history = Some(adapter.clone());
25203                spec.capabilities.adapters.resume = Some(adapter.clone());
25204                spec.capabilities.adapters.session_options = Some(adapter.clone());
25205                spec.capabilities.adapters.capability_probe = Some(adapter.clone());
25206                spec
25207            })
25208            .collect::<Vec<_>>();
25209        AgentRegistry::new_with_adapters(specs, &adapters).unwrap()
25210    }
25211
25212    /// The real fleet's manifest is accepted by a real server construction.
25213    ///
25214    /// The count is asserted against `active_registry` itself rather than a
25215    /// literal, so this stays a test that construction succeeds for whatever
25216    /// the fleet is -- which is the property worth guarding -- instead of a
25217    /// second place the fleet size is written down and has to be kept in
25218    /// step with the first.
25219    #[test]
25220    fn node_server_construction_accepts_the_production_manifest() {
25221        let registry = active_registry().unwrap();
25222        let (providers, _) = provider_contract_manifest(&registry).unwrap();
25223        assert_eq!(providers.len(), registry.iter().count());
25224        assert!(!providers.is_empty());
25225        let server = NodeServer::new_with_registry(
25226            construction_test_config("manifest-production-bound"),
25227            registry,
25228        )
25229        .unwrap();
25230        drop(server);
25231    }
25232
25233    #[test]
25234    fn node_server_construction_rejects_count_valid_manifest_over_handshake_capacity() {
25235        let registry = maximum_size_provider_manifest_registry();
25236        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
25237        assert_eq!(providers.len(), 3);
25238        assert_eq!(adapters.len(), 30);
25239        assert!(adapters.len() <= crate::protocol::MAX_PROVIDER_ADAPTER_CONTRACTS);
25240        let error = match NodeServer::new_with_registry(
25241            construction_test_config("manifest-handshake-overflow"),
25242            registry,
25243        ) {
25244            Ok(_) => panic!("oversized negotiated manifest was accepted"),
25245            Err(error) => error,
25246        };
25247        assert!(matches!(
25248            error,
25249            NodeServerError::ProviderContractManifest(message)
25250                if message.contains("authentication binding length")
25251                    && message.contains("8192-byte limit")
25252        ));
25253    }
25254
25255    #[test]
25256    fn provider_contract_manifest_rejects_wire_revision_overflow_before_startup() {
25257        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
25258        spec.revision = "x".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES + 1);
25259        let registry = AgentRegistry::new([spec]).unwrap();
25260        assert!(matches!(
25261            provider_contract_manifest(&registry),
25262            Err(NodeServerError::ProviderContractManifest(message))
25263                if message.contains("exceeds the 128-byte limit")
25264        ));
25265    }
25266
25267    #[test]
25268    fn provider_contract_manifest_rejects_invalid_wire_revision_before_startup() {
25269        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
25270        spec.revision = "invalid revision".to_owned();
25271        let registry = AgentRegistry::new([spec]).unwrap();
25272        assert!(matches!(
25273            provider_contract_manifest(&registry),
25274            Err(NodeServerError::ProviderContractManifest(message))
25275                if message.contains("bounded lowercase ASCII")
25276        ));
25277    }
25278
25279    #[test]
25280    fn provider_contract_manifest_rejects_conflicting_duplicate_family_before_startup() {
25281        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
25282        let transport_binding = spec.capabilities.adapters.one_shot.clone().unwrap();
25283        let conflicting_binding = builtin_registry()
25284            .get_by_id("codex")
25285            .unwrap()
25286            .capabilities
25287            .adapters
25288            .one_shot
25289            .clone()
25290            .unwrap();
25291        let pipe = spec.capabilities.transports.pipe.as_mut().unwrap();
25292        pipe.protocol = gate4agent_types::PipeProtocol::OneShotText;
25293        pipe.adapter = transport_binding;
25294        spec.capabilities.adapters.one_shot = Some(conflicting_binding);
25295        let registry = AgentRegistry::new([spec]).unwrap();
25296        assert!(matches!(
25297            provider_contract_manifest(&registry),
25298            Err(NodeServerError::ProviderContractManifest(message))
25299                if message.contains("conflicting OneShot adapter bindings")
25300        ));
25301    }
25302
25303    #[test]
25304    fn public_node_failures_expose_only_fixed_categories() {
25305        let provider = failure(
25306            NodeFailureCode::BackendOperationFailed,
25307            r"provider bearer-secret failed at C:\private\session.jsonl",
25308        );
25309        assert_eq!(provider.message, "backend-operation-failed");
25310        assert!(!provider.message.contains("bearer-secret"));
25311        assert!(!provider.message.contains(r"C:\private"));
25312        let persistence = persistence_failure(io::Error::new(
25313            io::ErrorKind::Other,
25314            r"state write failed at C:\private\state-v1.json",
25315        ));
25316        assert_eq!(persistence.message, DURABLE_STATE_COMMIT_FAILED_ERROR);
25317        assert!(!persistence.message.contains(r"C:\private"));
25318
25319        for kind in [io::ErrorKind::PermissionDenied, io::ErrorKind::Unsupported] {
25320            let generic = durable_state_load_error(io::Error::new(kind, "host I/O failure"));
25321            assert_eq!(
25322                generic.to_string(),
25323                "node durable state failed: durable-state-load-failed",
25324            );
25325        }
25326    }
25327
25328    #[test]
25329    fn terminal_byte_sequences_are_bounded_before_dispatch() {
25330        let sequence = b"\x1b[1;5D".to_vec();
25331        assert_eq!(
25332            terminal_bytes_action(sequence.clone()).unwrap(),
25333            InputAction::TerminalBytes(sequence),
25334        );
25335        assert_eq!(
25336            terminal_bytes_action(Vec::new()).unwrap_err().code,
25337            NodeFailureCode::InvalidRequest,
25338        );
25339        assert_eq!(
25340            terminal_bytes_action(vec![0; MAX_NODE_TERMINAL_BYTES + 1])
25341                .unwrap_err()
25342                .code,
25343            NodeFailureCode::InvalidRequest,
25344        );
25345    }
25346
25347    #[test]
25348    fn node_server_rejects_a_second_owner_of_the_same_durable_state_path() {
25349        let root = temporary_workspace_root("exclusive-state-owner");
25350        std::fs::create_dir_all(&root).unwrap();
25351        let workspace = WorkspaceConfig::new(
25352            WorkspaceId::new("primary").unwrap(),
25353            &root,
25354        )
25355        .unwrap();
25356        let state_path = root.join("state-v1.json");
25357        let endpoint = format!(
25358            r"\\.\pipe\gate4agent-node-state-lock-{}-{}",
25359            std::process::id(),
25360            std::time::SystemTime::now()
25361                .duration_since(std::time::UNIX_EPOCH)
25362                .unwrap()
25363                .as_nanos(),
25364        );
25365        let config = NodeServerConfig::new(
25366            endpoint,
25367            "fixture-token",
25368            NodeId::new("node-1").unwrap(),
25369            [workspace],
25370        )
25371        .unwrap()
25372        .with_state_path(&state_path)
25373        .unwrap();
25374
25375        let first = NodeServer::new(config.clone()).unwrap();
25376        let error = match NodeServer::new(config.clone()) {
25377            Ok(_) => panic!("a second node server acquired the same durable state path"),
25378            Err(error) => error,
25379        };
25380        assert!(matches!(
25381            &error,
25382            NodeServerError::DurableState(error)
25383                if error.kind() == io::ErrorKind::WouldBlock
25384        ));
25385        assert_eq!(
25386            error.to_string(),
25387            "node durable state failed: durable-state-lock-failed",
25388        );
25389        assert!(!error.to_string().contains(&state_path.to_string_lossy().into_owned()));
25390        drop(first);
25391        let second = NodeServer::new(config).unwrap();
25392        drop(second);
25393        std::fs::remove_dir_all(root).unwrap();
25394    }
25395
25396    #[test]
25397    fn session_binding_is_workspace_scoped_and_removed_fail_closed() {
25398        let catalog = active_registry().unwrap();
25399        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25400        let workspace_id = WorkspaceId::new("primary").unwrap();
25401        let workspace = WorkspaceConfig::new(
25402            workspace_id.clone(),
25403            std::env::current_dir().unwrap(),
25404        )
25405        .unwrap();
25406        let shared = NodeShared::new(
25407            handle,
25408            "fixture-token".to_owned(),
25409            NodeId::new("node-1").unwrap(),
25410            vec![workspace.clone()],
25411            vec![agent("claude"), agent("codex"), agent("kimi")],
25412        );
25413        let instance_id = AgentInstanceId(17);
25414        let generation = SessionGeneration(3);
25415        assert!(shared.address_for(instance_id, generation).is_none());
25416        shared.bind_session(instance_id, workspace_id.clone(), generation);
25417        let address = SessionAddress {
25418            workspace_id,
25419            session: SessionKey { instance_id, generation },
25420        };
25421        assert_eq!(
25422            shared.workspace_root(&address.workspace_id).unwrap(),
25423            workspace.canonical_root,
25424        );
25425        assert_eq!(shared.address_for(instance_id, generation), Some(address.clone()));
25426        shared.remove_binding(&address);
25427        assert!(shared.address_for(instance_id, generation).is_none());
25428    }
25429
25430    #[test]
25431    fn open_provider_request_gate_is_state_aware_and_fail_closed() {
25432        let address = SessionAddress {
25433            workspace_id: WorkspaceId::new("primary").unwrap(),
25434            session: SessionKey {
25435                instance_id: AgentInstanceId(7),
25436                generation: SessionGeneration(2),
25437            },
25438        };
25439        let record_id = SessionRecordId::new("record-a").unwrap();
25440        let size = gate4agent_types::TerminalSize {
25441            rows: 24,
25442            columns: 80,
25443        };
25444        let address_requests = [
25445            NodeRequest::Resume {
25446                session: address.clone(),
25447                terminal_size: size,
25448                initial_prompt: None,
25449            },
25450            NodeRequest::Prompt { session: address.clone(), text: "p".to_owned() },
25451            NodeRequest::Paste { session: address.clone(), text: "p".to_owned() },
25452            NodeRequest::Input { session: address.clone(), text: "p".to_owned() },
25453            NodeRequest::TerminalBytes { session: address.clone(), bytes: vec![1] },
25454            NodeRequest::TerminalControl {
25455                session: address.clone(),
25456                control: TerminalControl::Enter,
25457            },
25458            NodeRequest::Resize { session: address.clone(), size },
25459            NodeRequest::Interrupt { session: address.clone() },
25460            NodeRequest::Stop { session: address.clone(), force: false },
25461            NodeRequest::Remove { session: address },
25462        ];
25463        for request in &address_requests {
25464            assert!(request_requires_open_provider_ids_with(
25465                request,
25466                |_| Some(agent("third-party-agent")),
25467                |_| Some(agent("claude")),
25468            ));
25469            assert!(!request_requires_open_provider_ids_with(
25470                request,
25471                |_| Some(agent("claude")),
25472                |_| Some(agent("claude")),
25473            ));
25474            assert!(request_requires_open_provider_ids_with(
25475                request,
25476                |_| None,
25477                |_| Some(agent("claude")),
25478            ));
25479        }
25480
25481        let record_requests = [
25482            NodeRequest::RenameSessionRecord {
25483                record_id: record_id.clone(),
25484                display_name: "renamed".to_owned(),
25485            },
25486            NodeRequest::ResumeSessionRecord {
25487                record_id: record_id.clone(),
25488                terminal_size: size,
25489                initial_prompt: None,
25490            },
25491            NodeRequest::ForgetSessionRecord { record_id },
25492        ];
25493        for request in &record_requests {
25494            assert!(request_requires_open_provider_ids_with(
25495                request,
25496                |_| Some(agent("claude")),
25497                |_| Some(agent("grok")),
25498            ));
25499            assert!(!request_requires_open_provider_ids_with(
25500                request,
25501                |_| Some(agent("claude")),
25502                |_| Some(agent("kimi")),
25503            ));
25504            assert!(request_requires_open_provider_ids_with(
25505                request,
25506                |_| Some(agent("claude")),
25507                |_| None,
25508            ));
25509        }
25510
25511        assert!(request_requires_open_provider_ids_with(
25512            &NodeRequest::Spawn {
25513                workspace_id: WorkspaceId::new("primary").unwrap(),
25514                provider: agent("third-party-agent"),
25515                mode: SessionMode::Pty,
25516                terminal_size: size,
25517                initial_prompt: None,
25518            },
25519            |_| None,
25520            |_| None,
25521        ));
25522        assert!(!request_requires_open_provider_ids_with(
25523            &NodeRequest::Snapshot,
25524            |_| None,
25525            |_| None,
25526        ));
25527        assert!(request_requires_open_provider_ids_with(
25528            &NodeRequest::ForgetContextPack {
25529                context_id: SpawnContextId::new("ctx-forget").unwrap(),
25530            },
25531            |_| None,
25532            |_| None,
25533        ));
25534    }
25535
25536    #[test]
25537    fn legacy_projection_filters_open_record_replies_and_event_history() {
25538        let catalog = active_registry().unwrap();
25539        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25540        let workspace = WorkspaceConfig::new(
25541            WorkspaceId::new("primary").unwrap(),
25542            std::env::current_dir().unwrap(),
25543        )
25544        .unwrap();
25545        let shared = NodeShared::new(
25546            handle,
25547            "fixture-token".to_owned(),
25548            NodeId::new("node-1").unwrap(),
25549            vec![workspace],
25550            vec![agent("claude"), agent("grok")],
25551        );
25552        let legacy = record("claude", "legacy-record");
25553        let open = record("grok", "open-record");
25554        let legacy_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
25555            record: legacy.clone(),
25556        });
25557        let open_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
25558            record: open.clone(),
25559        });
25560        let legacy_removed = shared.publish(NodeEvent::SessionRecordRemoved {
25561            record_id: legacy.record_id.clone(),
25562        });
25563        let open_removed = shared.publish(NodeEvent::SessionRecordRemoved {
25564            record_id: open.record_id.clone(),
25565        });
25566        assert!(project_event_legacy_provider_ids(&shared, legacy_upsert.clone()).is_some());
25567        assert!(project_event_legacy_provider_ids(&shared, open_upsert.clone()).is_none());
25568        assert!(project_event_legacy_provider_ids(&shared, legacy_removed.clone()).is_some());
25569        assert!(project_event_legacy_provider_ids(&shared, open_removed.clone()).is_none());
25570
25571        let mut record_reply = ResponseEnvelope {
25572            request_id: 1,
25573            result: Ok(NodeResponse::SessionRecordUpdated { record: open }),
25574        };
25575        project_response_legacy_provider_ids(&shared, &mut record_reply);
25576        assert!(matches!(
25577            record_reply.result,
25578            Err(NodeFailure { code: NodeFailureCode::UnsupportedCapability, .. })
25579        ));
25580
25581        let mut resync = ResponseEnvelope {
25582            request_id: 2,
25583            result: Ok(NodeResponse::Resync {
25584                event_sequence: open_removed.sequence,
25585                oldest_available_sequence: 1,
25586                snapshot: shared.snapshot(),
25587                events: vec![legacy_upsert, open_upsert, legacy_removed, open_removed],
25588            }),
25589        };
25590        project_response_legacy_provider_ids(&shared, &mut resync);
25591        let Ok(NodeResponse::Resync { snapshot, events, .. }) = resync.result else {
25592            panic!("legacy projection changed the resync response kind");
25593        };
25594        assert!(snapshot.session_records.iter().all(|item| {
25595            provider_id_is_legacy(&item.provider)
25596        }));
25597        assert_eq!(events.len(), 2);
25598        assert!(events.iter().all(|event| matches!(
25599            &event.event,
25600            NodeEvent::SessionRecordUpserted { record }
25601                if provider_id_is_legacy(&record.provider)
25602        ) || matches!(&event.event, NodeEvent::SessionRecordRemoved { .. })));
25603    }
25604
25605    #[test]
25606    fn session_binding_capacity_is_bounded_by_the_control_session_limit() {
25607        let catalog = active_registry().unwrap();
25608        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25609        let workspace_id = WorkspaceId::new("primary").unwrap();
25610        let workspace = WorkspaceConfig::new(
25611            workspace_id.clone(),
25612            std::env::current_dir().unwrap(),
25613        )
25614        .unwrap();
25615        let shared = NodeShared::new(
25616            handle,
25617            "fixture-token".to_owned(),
25618            NodeId::new("node-1").unwrap(),
25619            vec![workspace],
25620            vec![agent("claude")],
25621        );
25622        for index in 0..CONTROL_SESSIONS_MAX {
25623            shared.bind_session(
25624                AgentInstanceId((index + 1) as u64),
25625                workspace_id.clone(),
25626                SessionGeneration(1),
25627            );
25628        }
25629        let error = shared.ensure_binding_capacity().unwrap_err();
25630        assert_eq!(error.code, NodeFailureCode::BackendBusy);
25631    }
25632
25633    #[tokio::test]
25634    async fn provider_spawn_admission_rejects_before_session_mutation() {
25635        let catalog = active_registry().unwrap();
25636        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25637        let workspace = WorkspaceConfig::new(
25638            WorkspaceId::new("primary").unwrap(),
25639            std::env::current_dir().unwrap(),
25640        )
25641        .unwrap();
25642        let mut shared = NodeShared::new(
25643            handle,
25644            "fixture-token".to_owned(),
25645            NodeId::new("node-provider-admission").unwrap(),
25646            vec![workspace],
25647            vec![agent("claude")],
25648        );
25649        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25650            crate::protocol::ProviderRuntimeStatus::unavailable(agent("claude")),
25651        ])
25652        .unwrap();
25653        let terminal_size = gate4agent_types::TerminalSize {
25654            rows: 24,
25655            columns: 80,
25656        };
25657
25658        let unavailable = shared
25659            .spawn_session(
25660                WorkspaceId::new("primary").unwrap(),
25661                agent("claude"),
25662                SessionMode::Pty,
25663                terminal_size,
25664                None,
25665            )
25666            .await
25667            .unwrap_err();
25668        assert_eq!(unavailable.code, NodeFailureCode::BackendOperationFailed);
25669        assert_eq!(unavailable.message, "backend-operation-failed");
25670
25671        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25672            crate::protocol::ProviderRuntimeStatus::raw_passthrough(
25673                agent("claude"),
25674                Some(crate::protocol::ProviderRuntimeVersion::new("999.0.0").unwrap()),
25675            ),
25676        ])
25677        .unwrap();
25678        let semantic = shared
25679            .spawn_session(
25680                WorkspaceId::new("primary").unwrap(),
25681                agent("claude"),
25682                SessionMode::Inline,
25683                terminal_size,
25684                None,
25685            )
25686            .await
25687            .unwrap_err();
25688        assert_eq!(semantic.code, NodeFailureCode::UnsupportedCapability);
25689        assert_eq!(semantic.message, "unsupported-capability");
25690
25691        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
25692        assert!(shared
25693            .session_bindings
25694            .lock()
25695            .unwrap_or_else(|poisoned| poisoned.into_inner())
25696            .is_empty());
25697        assert!(shared
25698            .session_records
25699            .lock()
25700            .unwrap_or_else(|poisoned| poisoned.into_inner())
25701            .records
25702            .is_empty());
25703        assert!(shared.handle.snapshot().sessions.is_empty());
25704    }
25705
25706    #[tokio::test]
25707    async fn raw_spawn_binding_is_visible_with_a_durable_live_record() {
25708        let catalog = active_registry().unwrap();
25709        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25710        let workspace_id = WorkspaceId::new("primary").unwrap();
25711        let workspace = WorkspaceConfig::new(
25712            workspace_id.clone(),
25713            std::env::current_dir().unwrap(),
25714        )
25715        .unwrap();
25716        let shared = NodeShared::new(
25717            handle,
25718            "fixture-token".to_owned(),
25719            NodeId::new("node-raw-spawn-record").unwrap(),
25720            vec![workspace],
25721            vec![agent("claude")],
25722        );
25723        let address = SessionAddress {
25724            workspace_id,
25725            session: SessionKey {
25726                instance_id: AgentInstanceId(71),
25727                generation: SessionGeneration::default(),
25728            },
25729        };
25730
25731        let record_id = shared
25732            .bind_spawn_session(
25733                &address,
25734                agent("claude"),
25735                SessionMode::Pty,
25736                ProviderRuntimePolicy::raw_pty(),
25737                SpawnRecordPolicy::Always,
25738                None,
25739            )
25740            .unwrap()
25741            .unwrap();
25742        shared
25743            .handle
25744            .dispatch(shared.prepare_command(ControlCommand::Register {
25745                instance_id: address.session.instance_id,
25746                agent_id: agent("claude"),
25747                transport: TransportKind::Pty,
25748            }))
25749            .unwrap();
25750        runtime.tick().await;
25751
25752        assert!(shared.handle.snapshot().sessions.iter().any(|session| {
25753            session.instance_id == address.session.instance_id
25754                && session.generation == address.session.generation
25755        }));
25756        let record = shared.record(&record_id).unwrap();
25757        assert_eq!(record.display_name, "claude #1");
25758        assert_eq!(record.state, ManagedSessionState::Live);
25759        assert_eq!(record.active_session.as_ref(), Some(&address));
25760        assert!(record.provider_session.is_none());
25761        let binding_record_id = shared
25762            .session_bindings
25763            .lock()
25764            .unwrap_or_else(|poisoned| poisoned.into_inner())
25765            .get(&address.session.instance_id)
25766            .and_then(|binding| binding.record_id.clone());
25767        assert_eq!(binding_record_id.as_ref(), Some(&record_id));
25768
25769        let renamed = shared
25770            .rename_session_record(&record_id, "raw review".to_owned())
25771            .unwrap();
25772        assert_eq!(renamed.record_id, record_id);
25773        assert_eq!(renamed.display_name, "raw review");
25774        shared.reconcile_managed_record(
25775            &record_id,
25776            &address,
25777            &ControlEvent {
25778                sequence: 1,
25779                command_id: None,
25780                instance_id: address.session.instance_id,
25781                generation: address.session.generation,
25782                event: ControlEventKind::Running { process_id: None },
25783            },
25784            false,
25785            false,
25786        );
25787        let running = shared.record(&record_id).unwrap();
25788        assert_eq!(running.state, ManagedSessionState::Live);
25789        assert_eq!(running.active_session.as_ref(), Some(&address));
25790        assert_eq!(running.display_name, "raw review");
25791
25792        shared.reconcile_managed_record(
25793            &record_id,
25794            &address,
25795            &ControlEvent {
25796                sequence: 2,
25797                command_id: None,
25798                instance_id: address.session.instance_id,
25799                generation: address.session.generation,
25800                event: ControlEventKind::Exited {
25801                    exit_code: Some(0),
25802                    forced: false,
25803                },
25804            },
25805            false,
25806            false,
25807        );
25808        let detached = shared.record(&record_id).unwrap();
25809        assert_eq!(detached.state, ManagedSessionState::Unavailable);
25810        assert!(detached.active_session.is_none());
25811        assert_eq!(detached.display_name, "raw review");
25812        let renamed_detached = shared
25813            .rename_session_record(&record_id, "raw review detached".to_owned())
25814            .unwrap();
25815        assert_eq!(renamed_detached.record_id, record_id);
25816        assert_eq!(renamed_detached.state, ManagedSessionState::Unavailable);
25817        assert!(renamed_detached.active_session.is_none());
25818        assert_eq!(renamed_detached.display_name, "raw review detached");
25819        assert_eq!(shared.snapshot().session_records, vec![renamed_detached]);
25820    }
25821
25822    #[test]
25823    fn legacy_raw_spawn_binding_remains_untracked() {
25824        let catalog = active_registry().unwrap();
25825        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25826        let workspace_id = WorkspaceId::new("primary").unwrap();
25827        let workspace = WorkspaceConfig::new(
25828            workspace_id.clone(),
25829            std::env::current_dir().unwrap(),
25830        )
25831        .unwrap();
25832        let shared = NodeShared::new(
25833            handle,
25834            "fixture-token".to_owned(),
25835            NodeId::new("node-legacy-raw-spawn").unwrap(),
25836            vec![workspace],
25837            vec![agent("claude")],
25838        );
25839        let address = SessionAddress {
25840            workspace_id,
25841            session: SessionKey {
25842                instance_id: AgentInstanceId(72),
25843                generation: SessionGeneration::default(),
25844            },
25845        };
25846
25847        let record_id = shared
25848            .bind_spawn_session(
25849                &address,
25850                agent("claude"),
25851                SessionMode::Pty,
25852                ProviderRuntimePolicy::raw_pty(),
25853                SpawnRecordPolicy::ProviderIdentityOnly,
25854                None,
25855            )
25856            .unwrap();
25857
25858        assert!(record_id.is_none());
25859        let binding_record_id = shared
25860            .session_bindings
25861            .lock()
25862            .unwrap_or_else(|poisoned| poisoned.into_inner())
25863            .get(&address.session.instance_id)
25864            .and_then(|binding| binding.record_id.clone());
25865        assert!(binding_record_id.is_none());
25866        assert!(shared.snapshot().session_records.is_empty());
25867    }
25868
25869    #[tokio::test]
25870    async fn raw_runtime_policy_rejects_prompt_paste_and_resume_before_mutation() {
25871        let catalog = active_registry().unwrap();
25872        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25873        let workspace_id = WorkspaceId::new("primary").unwrap();
25874        let workspace = WorkspaceConfig::new(
25875            workspace_id.clone(),
25876            std::env::current_dir().unwrap(),
25877        )
25878        .unwrap();
25879        let mut shared = NodeShared::new(
25880            handle,
25881            "fixture-token".to_owned(),
25882            NodeId::new("node-raw-semantic-gate").unwrap(),
25883            vec![workspace],
25884            vec![agent("claude")],
25885        );
25886        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25887            crate::protocol::ProviderRuntimeStatus::raw_passthrough(agent("claude"), None),
25888        ])
25889        .unwrap();
25890        shared
25891            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25892            .unwrap();
25893        let address = SessionAddress {
25894            workspace_id,
25895            session: SessionKey {
25896                instance_id: AgentInstanceId(72),
25897                generation: SessionGeneration::default(),
25898            },
25899        };
25900        shared.bind_session_with_policy(
25901            &address,
25902            ProviderRuntimePolicy::raw_pty(),
25903            None,
25904        );
25905        shared
25906            .handle
25907            .dispatch(shared.prepare_command(ControlCommand::Register {
25908                instance_id: address.session.instance_id,
25909                agent_id: agent("claude"),
25910                transport: TransportKind::Pty,
25911            }))
25912            .unwrap();
25913        runtime.tick().await;
25914        let command_id_before = shared.next_command_id.load(Ordering::Acquire);
25915        let revision_before = shared.handle.snapshot().revision;
25916        let history_sequence_before = shared.current_sequence();
25917        let terminal_size = gate4agent_types::TerminalSize {
25918            rows: 24,
25919            columns: 80,
25920        };
25921
25922        for request in [
25923            NodeRequest::Prompt {
25924                session: address.clone(),
25925                text: "prompt".to_owned(),
25926            },
25927            NodeRequest::Paste {
25928                session: address.clone(),
25929                text: "paste".to_owned(),
25930            },
25931            // `initial_prompt: Some(_)` selects `ResumeWithPrompt`, which needs
25932            // the same semantic capabilities as `Prompt`/`Paste`. A bare
25933            // (promptless) resume is a raw PTY relaunch and IS admitted under
25934            // `raw_pty()` — see `raw_pty_admits_provider_native_resume_without_prompt_only`
25935            // in `provider_runtime.rs`; it is deliberately not exercised here.
25936            NodeRequest::Resume {
25937                session: address.clone(),
25938                terminal_size,
25939                initial_prompt: Some("resume".to_owned()),
25940            },
25941        ] {
25942            let error = process_request_inner(
25943                &shared,
25944                77,
25945                ClientRole::Operator,
25946                request,
25947            )
25948            .await
25949            .unwrap_err();
25950            assert_eq!(error.code, NodeFailureCode::UnsupportedCapability);
25951        }
25952
25953        assert_eq!(shared.next_command_id.load(Ordering::Acquire), command_id_before);
25954        assert_eq!(shared.handle.snapshot().revision, revision_before);
25955        assert_eq!(shared.current_sequence(), history_sequence_before);
25956        let bindings = shared
25957            .session_bindings
25958            .lock()
25959            .unwrap_or_else(|poisoned| poisoned.into_inner());
25960        let binding = bindings.get(&address.session.instance_id).unwrap();
25961        assert_eq!(binding.generation, address.session.generation);
25962        assert!(binding.pending_resume.is_none());
25963        assert!(binding.record_id.is_none());
25964    }
25965
25966    #[tokio::test]
25967    async fn windows_rejects_unix_bytes_workspace_path_before_filesystem_access() {
25968        let catalog = active_registry().unwrap();
25969        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25970        let primary = WorkspaceConfig::new(
25971            WorkspaceId::new("primary").unwrap(),
25972            std::env::current_dir().unwrap(),
25973        )
25974        .unwrap();
25975        let shared = NodeShared::new(
25976            handle,
25977            "fixture-token".to_owned(),
25978            NodeId::new("node-1").unwrap(),
25979            vec![primary],
25980            vec![agent("claude")],
25981        );
25982        shared
25983            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25984            .unwrap();
25985        let before = shared.snapshot();
25986        let error = process_request_inner(
25987            &shared,
25988            77,
25989            ClientRole::Operator,
25990            NodeRequest::RegisterWorkspace {
25991                workspace_id: WorkspaceId::new("foreign").unwrap(),
25992                root: OpaqueHostPath::unix_bytes(b"/srv/repo".to_vec()).unwrap(),
25993            },
25994        )
25995        .await
25996        .unwrap_err();
25997        assert_eq!(error.code, NodeFailureCode::InvalidRequest);
25998        assert_eq!(error.message, "invalid-request");
25999        assert_eq!(shared.snapshot().workspaces, before.workspaces);
26000    }
26001
26002    #[tokio::test]
26003    async fn dynamic_workspaces_reject_duplicates_busy_and_last_then_publish_ordered_events() {
26004        let catalog = active_registry().unwrap();
26005        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26006        let primary_id = WorkspaceId::new("primary").unwrap();
26007        let primary = WorkspaceConfig::new(
26008            primary_id.clone(),
26009            std::env::current_dir().unwrap(),
26010        )
26011        .unwrap();
26012        let shared = NodeShared::new(
26013            handle,
26014            "fixture-token".to_owned(),
26015            NodeId::new("node-1").unwrap(),
26016            vec![primary.clone()],
26017            vec![agent("claude")],
26018        );
26019        let secondary_id = WorkspaceId::new("secondary").unwrap();
26020        let secondary = shared
26021            .register_workspace(
26022                secondary_id.clone(),
26023                std::env::temp_dir().to_string_lossy().into_owned(),
26024            )
26025            .await
26026            .unwrap();
26027        assert_eq!(secondary.workspace_id, secondary_id);
26028        assert!(secondary.sessions.is_empty());
26029        assert_eq!(shared.snapshot().workspaces.len(), 2);
26030
26031        let duplicate_id = shared
26032            .register_workspace(
26033                secondary_id.clone(),
26034                primary.canonical_root().to_owned(),
26035            )
26036            .await
26037            .unwrap_err();
26038        assert_eq!(duplicate_id.code, NodeFailureCode::DuplicateWorkspaceId);
26039        let duplicate_root = shared
26040            .register_workspace(
26041                WorkspaceId::new("duplicate-root").unwrap(),
26042                windows_path_text(&secondary.canonical_root).to_owned(),
26043            )
26044            .await
26045            .unwrap_err();
26046        assert_eq!(duplicate_root.code, NodeFailureCode::DuplicateWorkspaceRoot);
26047
26048        let address = SessionAddress {
26049            workspace_id: secondary_id.clone(),
26050            session: SessionKey {
26051                instance_id: AgentInstanceId(41),
26052                generation: SessionGeneration(1),
26053            },
26054        };
26055        shared.bind_session(
26056            address.session.instance_id,
26057            secondary_id.clone(),
26058            address.session.generation,
26059        );
26060        let busy = shared.unregister_workspace(&secondary_id).unwrap_err();
26061        assert_eq!(busy.code, NodeFailureCode::WorkspaceBusy);
26062        shared.remove_binding(&address);
26063        shared.unregister_workspace(&secondary_id).unwrap();
26064        assert_eq!(shared.snapshot().workspaces.len(), 1);
26065
26066        let last = shared.unregister_workspace(&primary_id).unwrap_err();
26067        assert_eq!(last.code, NodeFailureCode::LastWorkspace);
26068        let history = shared
26069            .history
26070            .lock()
26071            .unwrap_or_else(|poisoned| poisoned.into_inner());
26072        assert!(matches!(
26073            &history.events[0],
26074            NodeEventEnvelope {
26075                sequence: 1,
26076                event: NodeEvent::WorkspaceAdded { workspace },
26077            } if workspace.workspace_id == secondary_id
26078        ));
26079        assert!(matches!(
26080            &history.events[1],
26081            NodeEventEnvelope {
26082                sequence: 2,
26083                event: NodeEvent::WorkspaceRemoved { workspace_id },
26084            } if workspace_id == &secondary_id
26085        ));
26086    }
26087
26088    #[tokio::test]
26089    async fn provider_session_reference_index_is_controller_gated_and_idempotent() {
26090        let secondary_root = temporary_workspace_root("provider-session-reference-index");
26091        std::fs::create_dir_all(&secondary_root).unwrap();
26092        let catalog = active_registry().unwrap();
26093        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26094        let primary_id = WorkspaceId::new("primary").unwrap();
26095        let secondary_id = WorkspaceId::new("secondary").unwrap();
26096        let shared = NodeShared::new(
26097            handle,
26098            "fixture-token".to_owned(),
26099            NodeId::new("node-session-reference-index").unwrap(),
26100            vec![
26101                WorkspaceConfig::new(primary_id.clone(), std::env::current_dir().unwrap())
26102                    .unwrap(),
26103                WorkspaceConfig::new(secondary_id.clone(), &secondary_root).unwrap(),
26104            ],
26105            vec![agent("claude"), agent("codex")],
26106        );
26107        let identity = ProviderSessionIdentity {
26108            key: gate4agent_types::ProviderSessionKey::SessionId,
26109            id: "provider-session-42".to_owned(),
26110            transcript_path: None,
26111        };
26112        let index_request = |workspace_id: WorkspaceId,
26113                             provider: AgentId,
26114                             identity: ProviderSessionIdentity,
26115                             display_name: &str| {
26116            NodeRequest::IndexProviderSession {
26117                workspace_id,
26118                provider,
26119                identity,
26120                display_name: display_name.to_owned(),
26121            }
26122        };
26123        let request = index_request(
26124            primary_id.clone(),
26125            agent("claude"),
26126            identity.clone(),
26127            "release shepherd",
26128        );
26129        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
26130            capability.as_str() == NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY
26131        }));
26132        let observer = process_request_inner(
26133            &shared,
26134            77,
26135            ClientRole::Observer,
26136            request.clone(),
26137        )
26138        .await
26139        .unwrap_err();
26140        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26141        let uncontrolled = process_request_inner(
26142            &shared,
26143            77,
26144            ClientRole::Operator,
26145            request.clone(),
26146        )
26147        .await
26148        .unwrap_err();
26149        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
26150        shared
26151            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
26152            .unwrap();
26153
26154        let indexed = process_request_inner(
26155            &shared,
26156            77,
26157            ClientRole::Operator,
26158            request.clone(),
26159        )
26160        .await
26161        .unwrap();
26162        let NodeResponse::ProviderSessionIndexed { record } = indexed else {
26163            panic!("provider session reference index returned another response");
26164        };
26165        assert_eq!(record.state, ManagedSessionState::Dormant);
26166        assert_eq!(record.mode, SessionMode::Pty);
26167        assert_eq!(record.workspace_id, primary_id);
26168        assert_eq!(record.provider_session.as_ref(), Some(&identity));
26169        assert!(record.active_session.is_none());
26170        assert!(record.environment_profile.is_none());
26171        assert!(record.bundle.is_none());
26172        assert!(record.context.is_none());
26173
26174        let repeated = process_request_inner(
26175            &shared,
26176            77,
26177            ClientRole::Operator,
26178            index_request(
26179                primary_id.clone(),
26180                agent("claude"),
26181                identity.clone(),
26182                "ignored replacement",
26183            ),
26184        )
26185        .await
26186        .unwrap();
26187        assert!(matches!(
26188            repeated,
26189            NodeResponse::ProviderSessionIndexed { record: ref existing }
26190                if existing.record_id == record.record_id
26191                    && existing.display_name == "release shepherd"
26192        ));
26193        let conflicting_workspace = process_request_inner(
26194            &shared,
26195            77,
26196            ClientRole::Operator,
26197            index_request(
26198                secondary_id,
26199                agent("claude"),
26200                identity.clone(),
26201                "release shepherd",
26202            ),
26203        )
26204        .await
26205        .unwrap_err();
26206        assert_eq!(conflicting_workspace.code, NodeFailureCode::SessionRecordConflict);
26207        let conflicting_provider = process_request_inner(
26208            &shared,
26209            77,
26210            ClientRole::Operator,
26211            index_request(
26212                primary_id.clone(),
26213                agent("codex"),
26214                identity.clone(),
26215                "release shepherd",
26216            ),
26217        )
26218        .await
26219        .unwrap_err();
26220        assert_eq!(conflicting_provider.code, NodeFailureCode::SessionRecordConflict);
26221        let transcript_rejected = process_request_inner(
26222            &shared,
26223            77,
26224            ClientRole::Operator,
26225            index_request(
26226                primary_id,
26227                agent("claude"),
26228                ProviderSessionIdentity {
26229                    transcript_path: Some("C:/provider/transcript.jsonl".to_owned()),
26230                    ..identity
26231                },
26232                "release shepherd",
26233            ),
26234        )
26235        .await
26236        .unwrap_err();
26237        assert_eq!(transcript_rejected.code, NodeFailureCode::InvalidRequest);
26238        let upserts = shared
26239            .history
26240            .lock()
26241            .unwrap_or_else(|poisoned| poisoned.into_inner())
26242            .events
26243            .iter()
26244            .filter(|event| matches!(
26245                &event.event,
26246                NodeEvent::SessionRecordUpserted { record: published }
26247                    if published.record_id == record.record_id
26248            ))
26249            .count();
26250        assert_eq!(upserts, 1);
26251        std::fs::remove_dir_all(secondary_root).unwrap();
26252    }
26253
26254    #[tokio::test]
26255    async fn standalone_workspace_is_independent_registered_and_controller_gated() {
26256        let fixture_root = temporary_workspace_root("standalone-independent");
26257        let target = fixture_root.join("independent");
26258        std::fs::create_dir_all(&fixture_root).unwrap();
26259        let catalog = active_registry().unwrap();
26260        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26261        let selected = WorkspaceConfig::new(
26262            WorkspaceId::new("primary").unwrap(),
26263            std::env::current_dir().unwrap(),
26264        )
26265        .unwrap();
26266        let selected_root = selected.canonical_root().to_owned();
26267        let shared = NodeShared::new(
26268            handle,
26269            "fixture-token".to_owned(),
26270            NodeId::new("node-standalone").unwrap(),
26271            vec![selected],
26272            vec![agent("claude")],
26273        );
26274        let workspace_id = WorkspaceId::new("independent").unwrap();
26275        let request = NodeRequest::CreateStandaloneWorkspace {
26276            workspace_id: workspace_id.clone(),
26277            root: opaque_windows_path(target.to_string_lossy().into_owned()),
26278            initial_branch: Some("main".to_owned()),
26279        };
26280        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
26281            capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
26282        }));
26283        assert!(!request_uses_unnegotiated_capability(
26284            &request,
26285            &baseline_capabilities().unwrap(),
26286        ));
26287        assert!(request_uses_unnegotiated_capability(&request, &[]));
26288
26289        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
26290            .await
26291            .unwrap_err();
26292        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26293        assert!(!target.exists());
26294        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
26295            .await
26296            .unwrap_err();
26297        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
26298        assert!(!target.exists());
26299        shared
26300            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
26301            .unwrap();
26302
26303        let response = process_request_inner(&shared, 77, ClientRole::Operator, request)
26304            .await
26305            .unwrap();
26306        let NodeResponse::StandaloneWorkspaceCreated { workspace } = response else {
26307            panic!("standalone workspace creation returned another response");
26308        };
26309        assert_eq!(workspace.workspace_id, workspace_id);
26310        assert!(target.join(".git").is_dir());
26311        let branch = run_git_bounded(
26312            target.to_str().unwrap(),
26313            &["branch", "--show-current"],
26314            GIT_OUTPUT_MAX_BYTES,
26315        )
26316        .await
26317        .unwrap();
26318        assert!(branch.success);
26319        assert_eq!(String::from_utf8(branch.stdout).unwrap().trim(), "main");
26320        let worktrees = list_git_worktrees(target.to_str().unwrap()).await.unwrap();
26321        assert_eq!(worktrees.len(), 1);
26322        let listed_root = WorkspaceConfig::new(
26323            WorkspaceId::new("listed-independent").unwrap(),
26324            &worktrees[0].path,
26325        )
26326        .unwrap();
26327        assert!(platform::roots_equal(
26328            listed_root.canonical_root(),
26329            windows_path_text(&workspace.canonical_root),
26330        ));
26331        assert!(!platform::roots_equal(listed_root.canonical_root(), &selected_root));
26332        assert!(shared.snapshot().workspaces.iter().any(|snapshot| {
26333            snapshot.workspace_id == workspace_id
26334                && snapshot.canonical_root == workspace.canonical_root
26335        }));
26336        assert!(shared
26337            .history
26338            .lock()
26339            .unwrap_or_else(|poisoned| poisoned.into_inner())
26340            .events
26341            .iter()
26342            .any(|event| matches!(
26343                &event.event,
26344                NodeEvent::WorkspaceAdded { workspace }
26345                    if workspace.workspace_id == workspace_id
26346            )));
26347        std::fs::remove_dir_all(fixture_root).unwrap();
26348    }
26349
26350    #[tokio::test]
26351    async fn standalone_workspace_registration_failure_preserves_guarded_repository_for_recovery() {
26352        let fixture_root = temporary_workspace_root("standalone-compensation");
26353        let created_target = fixture_root.join("created-target");
26354        let existing_target = fixture_root.join("existing-target");
26355        let invalid_state_parent = fixture_root.join("state-parent-is-a-file");
26356        let invalid_state_path = invalid_state_parent.join("state.json");
26357        std::fs::create_dir_all(&existing_target).unwrap();
26358        std::fs::write(&invalid_state_parent, "not a directory").unwrap();
26359        let catalog = active_registry().unwrap();
26360        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26361        let primary = WorkspaceConfig::new(
26362            WorkspaceId::new("primary").unwrap(),
26363            std::env::current_dir().unwrap(),
26364        )
26365        .unwrap();
26366        let mut shared = NodeShared::new(
26367            handle,
26368            "fixture-token".to_owned(),
26369            NodeId::new("node-standalone-compensation").unwrap(),
26370            vec![primary],
26371            vec![agent("claude")],
26372        );
26373        shared.state_path = Some(invalid_state_path);
26374
26375        let created_recovery = shared
26376            .create_standalone_workspace(
26377                WorkspaceId::new("compensated").unwrap(),
26378                created_target.to_string_lossy().into_owned(),
26379                Some("main".to_owned()),
26380            )
26381            .await
26382            .unwrap_err();
26383        assert_eq!(
26384            created_recovery.code,
26385            NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
26386        );
26387        assert!(created_target.join(".git").is_dir());
26388        assert!(shared.snapshot().workspaces.iter().all(|workspace| {
26389            workspace.workspace_id.as_str() != "compensated"
26390        }));
26391
26392        let existing_recovery = shared
26393            .create_standalone_workspace(
26394                WorkspaceId::new("restored").unwrap(),
26395                existing_target.to_string_lossy().into_owned(),
26396                None,
26397            )
26398            .await
26399            .unwrap_err();
26400        assert_eq!(
26401            existing_recovery.code,
26402            NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
26403        );
26404        assert!(existing_target.is_dir());
26405        assert!(existing_target.join(".git").is_dir());
26406        assert!(shared.snapshot().workspaces.iter().all(|workspace| {
26407            workspace.workspace_id.as_str() != "restored"
26408        }));
26409
26410        let registered_empty = fixture_root.join("registered-empty");
26411        std::fs::create_dir(&registered_empty).unwrap();
26412        let catalog = active_registry().unwrap();
26413        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26414        let registered = WorkspaceConfig::new(
26415            WorkspaceId::new("registered-empty").unwrap(),
26416            &registered_empty,
26417        )
26418        .unwrap();
26419        let duplicate_shared = NodeShared::new(
26420            handle,
26421            "fixture-token".to_owned(),
26422            NodeId::new("node-standalone-duplicate-root").unwrap(),
26423            vec![registered],
26424            vec![agent("claude")],
26425        );
26426        let duplicate = duplicate_shared
26427            .create_standalone_workspace(
26428                WorkspaceId::new("duplicate-root").unwrap(),
26429                registered_empty.to_string_lossy().into_owned(),
26430                None,
26431            )
26432            .await
26433            .unwrap_err();
26434        assert_eq!(duplicate.code, NodeFailureCode::DuplicateWorkspaceRoot);
26435        assert!(std::fs::read_dir(&registered_empty).unwrap().next().is_none());
26436        std::fs::remove_dir_all(fixture_root).unwrap();
26437    }
26438
26439    #[test]
26440    fn managed_record_id_collision_preserves_the_existing_record() {
26441        let catalog = active_registry().unwrap();
26442        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26443        let workspace = WorkspaceConfig::new(
26444            WorkspaceId::new("primary").unwrap(),
26445            std::env::current_dir().unwrap(),
26446        )
26447        .unwrap();
26448        let shared = NodeShared::new(
26449            handle,
26450            "fixture-token".to_owned(),
26451            NodeId::new("node-1").unwrap(),
26452            vec![workspace.clone()],
26453            vec![agent("claude")],
26454        );
26455        let record_id = SessionRecordId::new("sr-collision").unwrap();
26456        let original = ManagedSessionRecord {
26457            record_id: record_id.clone(),
26458            display_name: "original".to_owned(),
26459            provider: agent("claude"),
26460            mode: SessionMode::Pty,
26461            state: ManagedSessionState::Dormant,
26462            workspace_id: workspace.workspace_id,
26463            canonical_root: opaque_windows_path(workspace.canonical_root),
26464            provider_session: None,
26465            active_session: None,
26466            environment_profile: None,
26467            bundle: None,
26468            context_id: None,
26469            context: None,
26470            exported_context: None,
26471            task_binding: None,
26472            created_at_unix_ms: 1,
26473            updated_at_unix_ms: 1,
26474            last_error: None,
26475        };
26476        shared.insert_record(original.clone()).unwrap();
26477        let mut collision = original.clone();
26478        collision.display_name = "replacement".to_owned();
26479        let error = shared.insert_record(collision).unwrap_err();
26480        assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
26481        assert_eq!(shared.record(&record_id).unwrap(), original);
26482    }
26483
26484    #[test]
26485    fn managed_record_runtime_errors_are_exposed_only_as_allowlisted_categories() {
26486        let catalog = active_registry().unwrap();
26487        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26488        let workspace = WorkspaceConfig::new(
26489            WorkspaceId::new("primary").unwrap(),
26490            std::env::current_dir().unwrap(),
26491        )
26492        .unwrap();
26493        let shared = NodeShared::new(
26494            handle,
26495            "fixture-token".to_owned(),
26496            NodeId::new("node-1").unwrap(),
26497            vec![workspace.clone()],
26498            vec![agent("claude")],
26499        );
26500        let record_id = SessionRecordId::new("sr-runtime-error").unwrap();
26501        shared
26502            .insert_record(ManagedSessionRecord {
26503                record_id: record_id.clone(),
26504                display_name: "runtime error".to_owned(),
26505                provider: agent("claude"),
26506                mode: SessionMode::Pty,
26507                state: ManagedSessionState::IdentityPending,
26508                workspace_id: workspace.workspace_id,
26509                canonical_root: opaque_windows_path(workspace.canonical_root),
26510                provider_session: None,
26511                active_session: None,
26512                environment_profile: None,
26513                bundle: None,
26514                context_id: None,
26515                context: None,
26516                exported_context: None,
26517                task_binding: None,
26518                created_at_unix_ms: 1,
26519                updated_at_unix_ms: 1,
26520                last_error: None,
26521            })
26522            .unwrap();
26523
26524        shared
26525            .mark_record_error(
26526                &record_id,
26527                r"provider failed with bearer-secret-123 at C:\private\session.jsonl",
26528            )
26529            .unwrap();
26530        let record = shared.record(&record_id).unwrap();
26531        assert_eq!(record.state, ManagedSessionState::Unavailable);
26532        assert_eq!(
26533            record.last_error.as_deref(),
26534            Some("provider-runtime-failed"),
26535        );
26536        let snapshot = shared.snapshot();
26537        assert_eq!(snapshot.session_records, vec![record]);
26538    }
26539
26540    /// Regression coverage for the "operator sees `deadline` instead of the
26541    /// kernel's own named rejection" defect: a spawn-dispatch waiter must be
26542    /// able to learn a command's asynchronous kernel rejection (here,
26543    /// specifically classified as `UnsupportedTransport`) exactly once, and
26544    /// must not still find it after that -- proving the fast path this
26545    /// mechanism exists for (`spawn_session_with_deadline`'s commit-wait
26546    /// loop) drains the reason instead of leaving it to be rediscovered (or
26547    /// missed) on a later poll.
26548    #[test]
26549    fn rejected_command_reason_is_classified_and_consumed_exactly_once() {
26550        let catalog = active_registry().unwrap();
26551        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26552        let workspace = WorkspaceConfig::new(
26553            WorkspaceId::new("primary").unwrap(),
26554            std::env::current_dir().unwrap(),
26555        )
26556        .unwrap();
26557        let shared = NodeShared::new(
26558            handle,
26559            "fixture-token".to_owned(),
26560            NodeId::new("node-1").unwrap(),
26561            vec![workspace],
26562            vec![agent("claude")],
26563        );
26564
26565        assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
26566
26567        shared.record_rejected_command(
26568            CommandId(1),
26569            "agent 'claude' does not support transport Acp".to_owned(),
26570            true,
26571        );
26572        // An unrelated command_id must not see another command's reason.
26573        assert!(shared.take_rejected_command_reason(CommandId(2)).is_none());
26574
26575        let reason = shared.take_rejected_command_reason(CommandId(1)).unwrap();
26576        assert_eq!(reason.message, "agent 'claude' does not support transport Acp");
26577        assert!(reason.unsupported_transport);
26578
26579        // Consumed: a second read for the same command_id finds nothing.
26580        assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
26581
26582        // A rejection unrelated to transport support keeps its own
26583        // classification distinct, so the reader can choose the generic
26584        // `BackendOperationFailed` code instead of misreporting
26585        // `UnsupportedTransport`.
26586        shared.record_rejected_command(
26587            CommandId(3),
26588            "backend busy".to_owned(),
26589            false,
26590        );
26591        let unclassified = shared.take_rejected_command_reason(CommandId(3)).unwrap();
26592        assert!(!unclassified.unsupported_transport);
26593    }
26594
26595    #[test]
26596    fn provider_identity_update_is_ignored_without_exact_session_policy() {
26597        let catalog = active_registry().unwrap();
26598        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26599        let workspace = WorkspaceConfig::new(
26600            WorkspaceId::new("primary").unwrap(),
26601            std::env::current_dir().unwrap(),
26602        )
26603        .unwrap();
26604        let shared = NodeShared::new(
26605            handle,
26606            "fixture-token".to_owned(),
26607            NodeId::new("node-identity-policy").unwrap(),
26608            vec![workspace.clone()],
26609            vec![agent("claude")],
26610        );
26611        let address = SessionAddress {
26612            workspace_id: workspace.workspace_id.clone(),
26613            session: SessionKey {
26614                instance_id: AgentInstanceId(90),
26615                generation: SessionGeneration(1),
26616            },
26617        };
26618        let record_id = SessionRecordId::new("sr-policy-denied").unwrap();
26619        shared
26620            .insert_record(ManagedSessionRecord {
26621                record_id: record_id.clone(),
26622                display_name: "policy denied".to_owned(),
26623                provider: agent("claude"),
26624                mode: SessionMode::Pty,
26625                state: ManagedSessionState::IdentityPending,
26626                workspace_id: workspace.workspace_id,
26627                canonical_root: opaque_windows_path(workspace.canonical_root),
26628                provider_session: None,
26629                active_session: Some(address.clone()),
26630                environment_profile: None,
26631                bundle: None,
26632                context_id: None,
26633                context: None,
26634                exported_context: None,
26635                task_binding: None,
26636                created_at_unix_ms: 1,
26637                updated_at_unix_ms: 1,
26638                last_error: None,
26639            })
26640            .unwrap();
26641        let event = ControlEvent {
26642            sequence: 1,
26643            command_id: None,
26644            instance_id: address.session.instance_id,
26645            generation: address.session.generation,
26646            event: ControlEventKind::ProviderEvent {
26647                sequence: 1,
26648                source: gate4agent_types::ProviderSource {
26649                    family: AdapterFamily::PtySemantic,
26650                    binding: AdapterBinding::new(
26651                        gate4agent_types::AdapterId::new("claude").unwrap(),
26652                        "fixture/v1",
26653                        gate4agent_types::AdapterVerification::SyntheticFixture,
26654                    )
26655                    .unwrap(),
26656                },
26657                source_sequence: 1,
26658                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26659                    identity: gate4agent_types::ProviderSessionIdentity {
26660                        key: gate4agent_types::ProviderSessionKey::SessionId,
26661                        id: "provider-session".to_owned(),
26662                        transcript_path: None,
26663                    },
26664                },
26665            },
26666        };
26667
26668        shared.reconcile_managed_record(&record_id, &address, &event, false, false);
26669
26670        let record = shared.record(&record_id).unwrap();
26671        assert!(record.provider_session.is_none());
26672        assert_eq!(record.state, ManagedSessionState::IdentityPending);
26673        assert_eq!(record.active_session.as_ref(), Some(&address));
26674    }
26675
26676    #[test]
26677    fn provider_identity_cannot_rebind_a_record_across_workspaces() {
26678        let secondary_root = temporary_workspace_root("identity-scope");
26679        std::fs::create_dir_all(&secondary_root).unwrap();
26680        let catalog = active_registry().unwrap();
26681        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26682        let primary = WorkspaceConfig::new(
26683            WorkspaceId::new("primary").unwrap(),
26684            std::env::current_dir().unwrap(),
26685        )
26686        .unwrap();
26687        let secondary = WorkspaceConfig::new(
26688            WorkspaceId::new("secondary").unwrap(),
26689            &secondary_root,
26690        )
26691        .unwrap();
26692        let shared = NodeShared::new(
26693            handle,
26694            "fixture-token".to_owned(),
26695            NodeId::new("node-1").unwrap(),
26696            vec![primary.clone(), secondary.clone()],
26697            vec![agent("claude")],
26698        );
26699        let identity = gate4agent_types::ProviderSessionIdentity {
26700            key: gate4agent_types::ProviderSessionKey::SessionId,
26701            id: "provider-session".to_owned(),
26702            transcript_path: None,
26703        };
26704        let dormant_id = SessionRecordId::new("sr-dormant").unwrap();
26705        shared
26706            .insert_record(ManagedSessionRecord {
26707                record_id: dormant_id.clone(),
26708                display_name: "dormant".to_owned(),
26709                provider: agent("claude"),
26710                mode: SessionMode::Pty,
26711                state: ManagedSessionState::Dormant,
26712                workspace_id: primary.workspace_id,
26713                canonical_root: opaque_windows_path(primary.canonical_root),
26714                provider_session: Some(identity.clone()),
26715                active_session: None,
26716                environment_profile: None,
26717                bundle: None,
26718                context_id: None,
26719                context: None,
26720                exported_context: None,
26721                task_binding: None,
26722                created_at_unix_ms: 1,
26723                updated_at_unix_ms: 1,
26724                last_error: None,
26725            })
26726            .unwrap();
26727        let address = SessionAddress {
26728            workspace_id: secondary.workspace_id.clone(),
26729            session: SessionKey {
26730                instance_id: AgentInstanceId(91),
26731                generation: SessionGeneration(1),
26732            },
26733        };
26734        let current_id = SessionRecordId::new("sr-current").unwrap();
26735        shared
26736            .insert_record(ManagedSessionRecord {
26737                record_id: current_id.clone(),
26738                display_name: "current".to_owned(),
26739                provider: agent("claude"),
26740                mode: SessionMode::Pty,
26741                state: ManagedSessionState::IdentityPending,
26742                workspace_id: secondary.workspace_id,
26743                canonical_root: opaque_windows_path(secondary.canonical_root),
26744                provider_session: None,
26745                active_session: Some(address.clone()),
26746                environment_profile: None,
26747                bundle: None,
26748                context_id: None,
26749                context: None,
26750                exported_context: None,
26751                task_binding: None,
26752                created_at_unix_ms: 2,
26753                updated_at_unix_ms: 2,
26754                last_error: None,
26755            })
26756            .unwrap();
26757        shared.bind_managed_session(
26758            &address,
26759            current_id.clone(),
26760            ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26761            None,
26762        );
26763        let event = ControlEvent {
26764            sequence: 1,
26765            command_id: None,
26766            instance_id: address.session.instance_id,
26767            generation: address.session.generation,
26768            event: ControlEventKind::ProviderEvent {
26769                sequence: 1,
26770                source: gate4agent_types::ProviderSource {
26771                    family: gate4agent_types::AdapterFamily::PtySemantic,
26772                    binding: gate4agent_types::AdapterBinding::new(
26773                        gate4agent_types::AdapterId::new("claude").unwrap(),
26774                        "fixture/v1",
26775                        gate4agent_types::AdapterVerification::SyntheticFixture,
26776                    )
26777                    .unwrap(),
26778                },
26779                source_sequence: 1,
26780                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26781                    identity,
26782                },
26783            },
26784        };
26785        shared.reconcile_managed_record(&current_id, &address, &event, true, false);
26786
26787        let binding = shared
26788            .session_bindings
26789            .lock()
26790            .unwrap_or_else(|poisoned| poisoned.into_inner())
26791            .get(&address.session.instance_id)
26792            .cloned()
26793            .unwrap();
26794        assert_eq!(binding.record_id.as_ref(), Some(&current_id));
26795        assert_eq!(shared.record(&dormant_id).unwrap().state, ManagedSessionState::Dormant);
26796        let current = shared.record(&current_id).unwrap();
26797        assert_eq!(current.state, ManagedSessionState::Unavailable);
26798        assert_eq!(current.active_session.as_ref(), Some(&address));
26799        assert_eq!(
26800            current.last_error.as_deref(),
26801            Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR),
26802        );
26803        std::fs::remove_dir_all(secondary_root).unwrap();
26804    }
26805
26806    #[tokio::test]
26807    async fn semantic_identity_with_transcript_path_reuses_record_and_survives_reload() {
26808        let root = temporary_workspace_root("semantic-provider-session");
26809        std::fs::create_dir_all(&root).unwrap();
26810        let state_path = root.join("state-v1.json");
26811        let catalog = active_registry().unwrap();
26812        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26813        let workspace = WorkspaceConfig::new(
26814            WorkspaceId::new("primary").unwrap(),
26815            &root,
26816        )
26817        .unwrap();
26818        let node_id = NodeId::new("node-semantic-identity").unwrap();
26819        let shared = NodeShared::new_with_incarnation(
26820            handle,
26821            "fixture-token".to_owned(),
26822            node_id.clone(),
26823            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
26824            vec![workspace.clone()],
26825            vec![agent("claude")],
26826            ProviderRuntimeStatuses::default(),
26827            None,
26828            Vec::new(),
26829            Vec::new(),
26830            SpawnProfileRegistry::default(),
26831            None,
26832            None,
26833            None,
26834            Some(state_path.clone()),
26835            Vec::new(),
26836            Vec::new(),
26837            Vec::new(),
26838            Vec::new(),
26839            None,
26840            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
26841        );
26842        let persisted_identity = gate4agent_types::ProviderSessionIdentity {
26843            key: gate4agent_types::ProviderSessionKey::SessionId,
26844            id: "provider-session".to_owned(),
26845            transcript_path: None,
26846        };
26847        let durable_id = SessionRecordId::new("sr-durable").unwrap();
26848        shared
26849            .insert_record(ManagedSessionRecord {
26850                record_id: durable_id.clone(),
26851                display_name: "durable".to_owned(),
26852                provider: agent("claude"),
26853                mode: SessionMode::Pty,
26854                state: ManagedSessionState::Dormant,
26855                workspace_id: workspace.workspace_id.clone(),
26856                canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26857                provider_session: Some(persisted_identity.clone()),
26858                active_session: None,
26859                environment_profile: None,
26860                bundle: None,
26861                context_id: None,
26862                context: None,
26863                exported_context: None,
26864                task_binding: None,
26865                created_at_unix_ms: 1,
26866                updated_at_unix_ms: 1,
26867                last_error: None,
26868            })
26869            .unwrap();
26870        let address = SessionAddress {
26871            workspace_id: workspace.workspace_id.clone(),
26872            session: SessionKey {
26873                instance_id: AgentInstanceId(92),
26874                generation: SessionGeneration(2),
26875            },
26876        };
26877        let pending_id = SessionRecordId::new("sr-pending").unwrap();
26878        shared
26879            .insert_record(ManagedSessionRecord {
26880                record_id: pending_id.clone(),
26881                display_name: "pending".to_owned(),
26882                provider: agent("claude"),
26883                mode: SessionMode::Pty,
26884                state: ManagedSessionState::IdentityPending,
26885                workspace_id: workspace.workspace_id.clone(),
26886                canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26887                provider_session: None,
26888                active_session: Some(address.clone()),
26889                environment_profile: None,
26890                bundle: None,
26891                context_id: None,
26892                context: None,
26893                exported_context: None,
26894                task_binding: None,
26895                created_at_unix_ms: 2,
26896                updated_at_unix_ms: 2,
26897                last_error: None,
26898            })
26899            .unwrap();
26900        shared.bind_managed_session(
26901            &address,
26902            pending_id.clone(),
26903            ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26904            None,
26905        );
26906        let observed_identity = gate4agent_types::ProviderSessionIdentity {
26907            transcript_path: Some(r"C:\private\provider-transcript.jsonl".to_owned()),
26908            ..persisted_identity
26909        };
26910        let event = ControlEvent {
26911            sequence: 1,
26912            command_id: None,
26913            instance_id: address.session.instance_id,
26914            generation: address.session.generation,
26915            event: ControlEventKind::ProviderEvent {
26916                sequence: 1,
26917                source: gate4agent_types::ProviderSource {
26918                    family: gate4agent_types::AdapterFamily::PtySemantic,
26919                    binding: gate4agent_types::AdapterBinding::new(
26920                        gate4agent_types::AdapterId::new("claude").unwrap(),
26921                        "fixture/v1",
26922                        gate4agent_types::AdapterVerification::SyntheticFixture,
26923                    )
26924                    .unwrap(),
26925                },
26926                source_sequence: 1,
26927                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26928                    identity: observed_identity.clone(),
26929                },
26930            },
26931        };
26932        shared.reconcile_managed_record(&pending_id, &address, &event, true, false);
26933
26934        let snapshot = shared.snapshot();
26935        assert_eq!(snapshot.session_records.len(), 1);
26936        let live = &snapshot.session_records[0];
26937        assert_eq!(live.record_id, durable_id);
26938        assert_eq!(live.state, ManagedSessionState::Live);
26939        assert_eq!(live.active_session.as_ref(), Some(&address));
26940        assert_eq!(live.provider_session.as_ref(), Some(&observed_identity));
26941        let binding = shared
26942            .session_bindings
26943            .lock()
26944            .unwrap_or_else(|poisoned| poisoned.into_inner())
26945            .get(&address.session.instance_id)
26946            .cloned()
26947            .unwrap();
26948        assert_eq!(binding.record_id.as_ref(), Some(&durable_id));
26949
26950        let mut duplicate = live.clone();
26951        duplicate.record_id = SessionRecordId::new("sr-duplicate").unwrap();
26952        duplicate.display_name = "duplicate".to_owned();
26953        duplicate.state = ManagedSessionState::Dormant;
26954        duplicate.active_session = None;
26955        duplicate.provider_session.as_mut().unwrap().transcript_path = None;
26956        let duplicate_error = shared.insert_record(duplicate).unwrap_err();
26957        assert_eq!(duplicate_error.code, NodeFailureCode::SessionRecordConflict);
26958        assert_eq!(shared.snapshot().session_records.len(), 1);
26959
26960        let terminal_size = gate4agent_types::TerminalSize {
26961            rows: 24,
26962            columns: 80,
26963        };
26964        let (first_busy, second_busy) = tokio::join!(
26965            shared.resume_session_record(&durable_id, terminal_size, None),
26966            shared.resume_session_record(&durable_id, terminal_size, None),
26967        );
26968        for busy in [first_busy.unwrap_err(), second_busy.unwrap_err()] {
26969            assert_eq!(busy.code, NodeFailureCode::SessionRecordBusy);
26970            assert_eq!(busy.message, "session-record-busy");
26971        }
26972
26973        let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
26974        assert_eq!(loaded.records.len(), 1);
26975        assert_eq!(loaded.records[0].record_id, durable_id);
26976        assert_eq!(loaded.records[0].state, ManagedSessionState::Dormant);
26977        assert_eq!(
26978            loaded.records[0]
26979                .provider_session
26980                .as_ref()
26981                .unwrap()
26982                .transcript_path,
26983            None,
26984        );
26985        let catalog = active_registry().unwrap();
26986        let (reloaded_handle, _reloaded_runtime) =
26987            NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26988        let reloaded = NodeShared::new_with_incarnation(
26989            reloaded_handle,
26990            "fixture-token".to_owned(),
26991            node_id,
26992            NodeIncarnationId::from_bytes([1; crate::protocol::NODE_INCARNATION_ID_BYTES]),
26993            vec![workspace],
26994            vec![agent("claude")],
26995            ProviderRuntimeStatuses::default(),
26996            None,
26997            Vec::new(),
26998            Vec::new(),
26999            SpawnProfileRegistry::default(),
27000            None,
27001            None,
27002            None,
27003            None,
27004            loaded.records,
27005            loaded.managed_worktrees,
27006            loaded.managed_worktree_tombstones,
27007            loaded.materializations,
27008            loaded.warning,
27009            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
27010        );
27011        assert_eq!(reloaded.snapshot().session_records.len(), 1);
27012        assert_eq!(reloaded.snapshot().session_records[0].record_id, durable_id);
27013        std::fs::remove_dir_all(root).unwrap();
27014    }
27015
27016    #[tokio::test]
27017    async fn workspace_reregistration_requires_the_recorded_root_and_restores_records() {
27018        let secondary_root = temporary_workspace_root("workspace-restore");
27019        let other_root = temporary_workspace_root("workspace-conflict");
27020        std::fs::create_dir_all(&secondary_root).unwrap();
27021        std::fs::create_dir_all(&other_root).unwrap();
27022        let catalog = active_registry().unwrap();
27023        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27024        let primary = WorkspaceConfig::new(
27025            WorkspaceId::new("primary").unwrap(),
27026            std::env::current_dir().unwrap(),
27027        )
27028        .unwrap();
27029        let secondary = WorkspaceConfig::new(
27030            WorkspaceId::new("secondary").unwrap(),
27031            &secondary_root,
27032        )
27033        .unwrap();
27034        let shared = NodeShared::new(
27035            handle,
27036            "fixture-token".to_owned(),
27037            NodeId::new("node-1").unwrap(),
27038            vec![primary, secondary.clone()],
27039            vec![agent("claude")],
27040        );
27041        let record_id = SessionRecordId::new("sr-workspace").unwrap();
27042        shared
27043            .insert_record(ManagedSessionRecord {
27044                record_id: record_id.clone(),
27045                display_name: "workspace record".to_owned(),
27046                provider: agent("claude"),
27047                mode: SessionMode::Pty,
27048                state: ManagedSessionState::Dormant,
27049                workspace_id: secondary.workspace_id.clone(),
27050                canonical_root: opaque_windows_path(secondary.canonical_root.clone()),
27051                provider_session: Some(gate4agent_types::ProviderSessionIdentity {
27052                    key: gate4agent_types::ProviderSessionKey::SessionId,
27053                    id: "provider-session".to_owned(),
27054                    transcript_path: None,
27055                }),
27056                active_session: None,
27057                environment_profile: None,
27058                bundle: None,
27059                context_id: None,
27060                context: None,
27061                exported_context: None,
27062                task_binding: None,
27063                created_at_unix_ms: 1,
27064                updated_at_unix_ms: 1,
27065                last_error: None,
27066            })
27067            .unwrap();
27068        shared.unregister_workspace(&secondary.workspace_id).unwrap();
27069        let unavailable = shared.record(&record_id).unwrap();
27070        assert_eq!(unavailable.state, ManagedSessionState::Unavailable);
27071        assert_eq!(windows_path_text(&unavailable.canonical_root), secondary.canonical_root);
27072
27073        let conflict = shared
27074            .register_workspace(
27075                secondary.workspace_id.clone(),
27076                other_root.to_string_lossy().into_owned(),
27077            )
27078            .await
27079            .unwrap_err();
27080        assert_eq!(conflict.code, NodeFailureCode::SessionRecordConflict);
27081        shared
27082            .register_workspace(
27083                secondary.workspace_id,
27084                secondary_root.to_string_lossy().into_owned(),
27085            )
27086            .await
27087            .unwrap();
27088        let restored = shared.record(&record_id).unwrap();
27089        assert_eq!(restored.state, ManagedSessionState::Dormant);
27090        assert!(restored.last_error.is_none());
27091        std::fs::remove_dir_all(secondary_root).unwrap();
27092        std::fs::remove_dir_all(other_root).unwrap();
27093    }
27094
27095    #[test]
27096    fn dynamic_workspace_root_is_rejected_before_filesystem_canonicalization() {
27097        let error = validate_workspace_request_root(
27098            &"x".repeat(MAX_WORKSPACE_ROOT_BYTES + 1),
27099        )
27100        .unwrap_err();
27101        assert_eq!(error.code, NodeFailureCode::InvalidWorkspaceRoot);
27102        let control = validate_workspace_request_root("C:\\repo\nchild").unwrap_err();
27103        assert_eq!(control.code, NodeFailureCode::InvalidWorkspaceRoot);
27104    }
27105
27106    #[test]
27107    fn concurrent_publish_and_resync_never_acknowledge_an_unappended_event() {
27108        let catalog = active_registry().unwrap();
27109        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27110        let workspace = WorkspaceConfig::new(
27111            WorkspaceId::new("primary").unwrap(),
27112            std::env::current_dir().unwrap(),
27113        )
27114        .unwrap();
27115        let shared = NodeShared::new(
27116            handle,
27117            "fixture-token".to_owned(),
27118            NodeId::new("node-1").unwrap(),
27119            vec![workspace],
27120            vec![agent("claude")],
27121        );
27122        let publishers = 4_u64;
27123        let events_per_publisher = 256_u64;
27124        let start = std::sync::Barrier::new(publishers as usize + 1);
27125        let finished = AtomicU64::new(0);
27126
27127        std::thread::scope(|scope| {
27128            for _ in 0..publishers {
27129                scope.spawn(|| {
27130                    start.wait();
27131                    for _ in 0..events_per_publisher {
27132                        shared.publish(NodeEvent::ControllerChanged { controller: None });
27133                    }
27134                    finished.fetch_add(1, Ordering::Release);
27135                });
27136            }
27137            start.wait();
27138            while finished.load(Ordering::Acquire) != publishers {
27139                assert_resync_history_is_linearized(shared.resync(0));
27140                std::thread::yield_now();
27141            }
27142        });
27143        let response = shared.resync(0);
27144        assert_resync_history_is_linearized(response.clone());
27145        let NodeResponse::Resync { event_sequence, events, .. } = response else {
27146            unreachable!("resync helper returned another response");
27147        };
27148        assert_eq!(event_sequence, publishers * events_per_publisher);
27149        assert_eq!(events.len() as u64, event_sequence);
27150    }
27151
27152    fn assert_resync_history_is_linearized(response: NodeResponse) {
27153        let NodeResponse::Resync { event_sequence, events, .. } = response else {
27154            unreachable!("resync helper returned another response");
27155        };
27156        assert!(events.windows(2).all(|pair| {
27157            pair[0].sequence.checked_add(1) == Some(pair[1].sequence)
27158        }));
27159        assert_eq!(events.last().map_or(0, |event| event.sequence), event_sequence);
27160    }
27161
27162    #[test]
27163    fn canonical_workspace_roots_are_safe_for_cmd_exe() {
27164        assert_eq!(
27165            platform::normalize_canonical_root(r"\\?\C:\repo\worktree".to_owned()),
27166            r"C:\repo\worktree",
27167        );
27168        assert_eq!(
27169            platform::normalize_canonical_root(r"\\?\UNC\server\share\repo".to_owned()),
27170            r"\\server\share\repo",
27171        );
27172        assert!(validate_workspace_root(
27173            &WorkspaceId::new("network").unwrap(),
27174            r"\\server\share\repo",
27175        )
27176        .is_err());
27177        let workspace = WorkspaceConfig::new(
27178            WorkspaceId::new("primary").unwrap(),
27179            std::env::current_dir().unwrap(),
27180        )
27181        .unwrap();
27182        assert!(!workspace.canonical_root.starts_with(r"\\?\"));
27183    }
27184
27185    #[test]
27186    fn resume_rebinds_only_on_the_expected_authorized_generation() {
27187        let catalog = active_registry().unwrap();
27188        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27189        let workspace_id = WorkspaceId::new("primary").unwrap();
27190        let workspace = WorkspaceConfig::new(
27191            workspace_id.clone(),
27192            std::env::current_dir().unwrap(),
27193        )
27194        .unwrap();
27195        let shared = NodeShared::new(
27196            handle,
27197            "fixture-token".to_owned(),
27198            NodeId::new("node-1").unwrap(),
27199            vec![workspace],
27200            vec![agent("claude")],
27201        );
27202        let instance_id = AgentInstanceId(17);
27203        let original = SessionAddress {
27204            workspace_id,
27205            session: SessionKey {
27206                instance_id,
27207                generation: SessionGeneration(3),
27208            },
27209        };
27210        shared.bind_session(
27211            instance_id,
27212            original.workspace_id.clone(),
27213            original.session.generation,
27214        );
27215        shared
27216            .arm_resume(
27217                &original,
27218                CommandId(41),
27219                ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
27220            )
27221            .unwrap();
27222
27223        shared.publish_control(ControlEvent {
27224            sequence: 1,
27225            command_id: None,
27226            instance_id,
27227            generation: SessionGeneration(4),
27228            event: ControlEventKind::Running { process_id: None },
27229        });
27230        assert_eq!(
27231            shared.address_for(instance_id, SessionGeneration(3)),
27232            Some(original.clone()),
27233        );
27234        assert!(shared.address_for(instance_id, SessionGeneration(4)).is_none());
27235
27236        shared.publish_control(ControlEvent {
27237            sequence: 2,
27238            command_id: Some(CommandId(42)),
27239            instance_id,
27240            generation: SessionGeneration(3),
27241            event: ControlEventKind::CommandRejected {
27242                message: "unrelated command".to_owned(),
27243            },
27244        });
27245
27246        shared.publish_control(ControlEvent {
27247            sequence: 3,
27248            command_id: None,
27249            instance_id,
27250            generation: SessionGeneration(4),
27251            event: ControlEventKind::ResumeAuthorized {
27252                session: gate4agent_types::ResumeSessionSummary {
27253                    key: gate4agent_types::ProviderSessionKey::SessionId,
27254                    id: "fixture-session".to_owned(),
27255                },
27256            },
27257        });
27258        assert!(shared.address_for(instance_id, SessionGeneration(3)).is_none());
27259        let rebound = shared.address_for(instance_id, SessionGeneration(4)).unwrap();
27260        assert_eq!(rebound.workspace_id, original.workspace_id);
27261        let history = shared
27262            .history
27263            .lock()
27264            .unwrap_or_else(|poisoned| poisoned.into_inner());
27265        assert!(history.events.iter().any(|envelope| matches!(
27266            &envelope.event,
27267            NodeEvent::Control { address, event }
27268                if address == &rebound
27269                    && matches!(&event.event, ControlEventKind::ResumeAuthorized { .. })
27270        )));
27271    }
27272
27273    #[test]
27274    fn all_node_pty_prompts_use_bracketed_paste_framing() {
27275        for agent in ["claude", "codex", "kimi"] {
27276            assert_eq!(
27277                prompt_framing(&AgentId::new(agent).unwrap()),
27278                PromptFraming::BracketedPaste
27279            );
27280        }
27281    }
27282
27283    fn generous_walk_deadline() -> Instant {
27284        Instant::now() + Duration::from_secs(30)
27285    }
27286
27287    #[test]
27288    fn workspace_tree_is_relative_and_skips_heavy_directories() {
27289        let root = temporary_workspace_root("tree");
27290        std::fs::create_dir_all(root.join("src")).unwrap();
27291        std::fs::create_dir_all(root.join(".git/objects")).unwrap();
27292        std::fs::create_dir_all(root.join("target/debug")).unwrap();
27293        std::fs::create_dir_all(root.join("node_modules/package")).unwrap();
27294        std::fs::write(root.join("src/lib.rs"), b"pub fn fixture() {}\n").unwrap();
27295        std::fs::write(root.join("README.md"), b"fixture\n").unwrap();
27296        // A renamed/agent-scoped build directory (`target-a2`-style, per the
27297        // real repository's own build layout) that carries the Cache
27298        // Directory Tagging Specification marker cargo writes into every
27299        // `--target-dir` it creates: must be skipped even though its name
27300        // does not exactly match `target`.
27301        std::fs::create_dir_all(root.join("target-a2/debug")).unwrap();
27302        std::fs::write(root.join("target-a2/CACHEDIR.TAG"), b"Signature: 8a477f597d28d172789f06886806bc55\n").unwrap();
27303        // A directory that merely *starts with* `target` but carries no
27304        // CACHEDIR.TAG: the new prefix-adjacent check must not false-match
27305        // on the name alone, so this stays in the response.
27306        std::fs::create_dir_all(root.join("target-notes")).unwrap();
27307        std::fs::write(root.join("target-notes/plan.md"), b"not a build directory\n").unwrap();
27308
27309        let (entries, truncated, _budget) =
27310            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
27311        assert!(!truncated);
27312        assert!(entries.iter().any(|entry| {
27313            entry.relative_path.as_utf8() == Some("src")
27314                && entry.kind == WorkspaceEntryKind::Directory
27315        }));
27316        assert!(entries.iter().any(|entry| {
27317            entry.relative_path.as_utf8() == Some("src/lib.rs")
27318                && entry.kind == WorkspaceEntryKind::File
27319        }));
27320        assert!(entries.iter().any(|entry| {
27321            entry.relative_path.as_utf8() == Some("target-notes")
27322                && entry.kind == WorkspaceEntryKind::Directory
27323        }));
27324        assert!(entries.iter().any(|entry| {
27325            entry.relative_path.as_utf8() == Some("target-notes/plan.md")
27326                && entry.kind == WorkspaceEntryKind::File
27327        }));
27328        assert!(entries.iter().all(|entry| {
27329            let path = entry.relative_path.as_utf8().unwrap();
27330            !path.starts_with(".git")
27331                && !(path == "target" || path.starts_with("target/"))
27332                && !(path == "target-a2" || path.starts_with("target-a2/"))
27333                && !path.starts_with("node_modules")
27334        }));
27335
27336        std::fs::remove_dir_all(&root).unwrap();
27337    }
27338
27339    #[test]
27340    fn workspace_walk_stops_at_its_entry_cap_and_marks_a_partial_result() {
27341        let root = temporary_workspace_root("tree-entry-cap");
27342        std::fs::create_dir_all(&root).unwrap();
27343        for index in 0..20 {
27344            std::fs::write(root.join(format!("file-{index:02}.txt")), b"fixture\n").unwrap();
27345        }
27346
27347        let (entries, truncated, budget) =
27348            collect_workspace_entries(&root, generous_walk_deadline(), 5);
27349        assert!(truncated);
27350        assert!(budget.entry_cap_exceeded);
27351        assert!(!budget.time_budget_exceeded);
27352        assert!(entries.len() <= 5);
27353
27354        let inspection_truncation = workspace_inspection_truncation(&budget, false, Duration::from_millis(1))
27355            .expect("an entry-cap-truncated walk must report a truncation marker");
27356        assert!(inspection_truncation.walk_entry_cap_exceeded);
27357        assert!(!inspection_truncation.walk_time_budget_exceeded);
27358        assert!(!inspection_truncation.git_time_budget_exceeded);
27359        assert!(inspection_truncation.entries_visited >= 5);
27360
27361        std::fs::remove_dir_all(&root).unwrap();
27362    }
27363
27364    #[test]
27365    fn workspace_walk_within_budget_reports_no_truncation() {
27366        let root = temporary_workspace_root("tree-no-truncation");
27367        std::fs::create_dir_all(&root).unwrap();
27368        std::fs::write(root.join("only-file.txt"), b"fixture\n").unwrap();
27369
27370        let (_entries, truncated, budget) =
27371            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
27372        assert!(!truncated);
27373        assert!(workspace_inspection_truncation(&budget, false, Duration::from_millis(1)).is_none());
27374
27375        std::fs::remove_dir_all(&root).unwrap();
27376    }
27377
27378    #[test]
27379    fn workspace_walk_emits_strict_global_byte_order_for_prefix_named_siblings() {
27380        let root = temporary_workspace_root("tree-prefix-siblings");
27381        std::fs::create_dir_all(root.join("gate4agent-c2/src")).unwrap();
27382        std::fs::write(root.join("gate4agent-c2/src/lib.rs"), b"fixture\n").unwrap();
27383        std::fs::create_dir_all(root.join("gate4agent-c2-client")).unwrap();
27384        std::fs::write(root.join("gate4agent-c2-client/lib.rs"), b"fixture\n").unwrap();
27385
27386        let (entries, truncated, _budget) =
27387            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
27388        assert!(!truncated);
27389        let paths = entries
27390            .iter()
27391            .map(|entry| entry.relative_path.as_utf8().unwrap().to_owned())
27392            .collect::<Vec<_>>();
27393        assert!(paths.windows(2).all(|pair| pair[0] < pair[1]));
27394        assert_eq!(
27395            paths,
27396            vec![
27397                "gate4agent-c2".to_owned(),
27398                "gate4agent-c2-client".to_owned(),
27399                "gate4agent-c2-client/lib.rs".to_owned(),
27400                "gate4agent-c2/src".to_owned(),
27401                "gate4agent-c2/src/lib.rs".to_owned(),
27402            ],
27403        );
27404
27405        std::fs::remove_dir_all(&root).unwrap();
27406    }
27407
27408    #[test]
27409    fn git_parsers_cap_status_and_commit_results() {
27410        let mut status_output = Vec::new();
27411        for index in 0..=GIT_STATUS_MAX_ENTRIES {
27412            status_output.extend_from_slice(format!(" M src/file-{index}.rs\0").as_bytes());
27413        }
27414        let mut snapshot = git_snapshot_for_parser();
27415        parse_git_status(&status_output, &mut snapshot);
27416        assert_eq!(snapshot.status.len(), GIT_STATUS_MAX_ENTRIES);
27417        assert!(snapshot.truncated);
27418        assert_eq!(snapshot.status[0].index_status, " ");
27419        assert_eq!(snapshot.status[0].worktree_status, "M");
27420
27421        snapshot.truncated = false;
27422        let commit_output = (0..=GIT_COMMIT_MAX_ENTRIES)
27423            .map(|index| format!("abc{index}\u{1f}commit {index}\n"))
27424            .collect::<String>();
27425        parse_git_commits(commit_output.as_bytes(), &mut snapshot);
27426        assert_eq!(snapshot.recent_commits.len(), GIT_COMMIT_MAX_ENTRIES);
27427        assert!(snapshot.truncated);
27428    }
27429
27430    #[test]
27431    fn porcelain_v1_z_parser_preserves_current_then_previous_rename_and_copy_order() {
27432        let mut snapshot = git_snapshot_for_parser();
27433        parse_git_status(
27434            b"R  src/current name.rs\0src/previous name.rs\0 C src/copy.rs\0src/original.rs\0?? src/line\nname.rs\0",
27435            &mut snapshot,
27436        );
27437
27438        assert!(!snapshot.truncated);
27439        assert_eq!(snapshot.status.len(), 3);
27440        assert_eq!(snapshot.status[0].index_status, "R");
27441        assert_eq!(snapshot.status[0].worktree_status, " ");
27442        assert_eq!(snapshot.status[0].path.as_utf8(), Some("src/current name.rs"));
27443        assert_eq!(
27444            snapshot.status[0]
27445                .previous_path
27446                .as_ref()
27447                .and_then(RepositoryPath::as_utf8),
27448            Some("src/previous name.rs"),
27449        );
27450        assert_eq!(snapshot.status[1].index_status, " ");
27451        assert_eq!(snapshot.status[1].worktree_status, "C");
27452        assert_eq!(snapshot.status[1].path.as_utf8(), Some("src/copy.rs"));
27453        assert_eq!(
27454            snapshot.status[1]
27455                .previous_path
27456                .as_ref()
27457                .and_then(RepositoryPath::as_utf8),
27458            Some("src/original.rs"),
27459        );
27460        assert_eq!(snapshot.status[2].path.as_utf8(), Some("src/line\nname.rs"));
27461        assert!(snapshot.status[2].previous_path.is_none());
27462    }
27463
27464    #[test]
27465    fn porcelain_v1_z_parser_never_invents_invalid_or_incomplete_path_identity() {
27466        let mut snapshot = git_snapshot_for_parser();
27467        parse_git_status(
27468            b"?? valid.rs\0?? invalid-\xff.rs\0?? invalid\\windows.rs\0R  current.rs\0",
27469            &mut snapshot,
27470        );
27471
27472        assert!(snapshot.truncated);
27473        assert_eq!(snapshot.status.len(), 1);
27474        assert_eq!(snapshot.status[0].path.as_utf8(), Some("valid.rs"));
27475        assert!(snapshot.status[0].previous_path.is_none());
27476    }
27477
27478    #[tokio::test]
27479    async fn observer_can_browse_host_directories_without_controller() {
27480        let root = temporary_workspace_root("host-directory-observer");
27481        std::fs::create_dir_all(root.join("directory-b")).unwrap();
27482        std::fs::create_dir_all(root.join("directory-a")).unwrap();
27483        std::fs::write(root.join("ordinary-file"), b"not a directory").unwrap();
27484        let workspace_id = WorkspaceId::new("primary").unwrap();
27485        let workspace = WorkspaceConfig::new(workspace_id, &root).unwrap();
27486        let catalog = active_registry().unwrap();
27487        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27488        let shared = NodeShared::new(
27489            handle,
27490            "fixture-token".to_owned(),
27491            NodeId::new("node-1").unwrap(),
27492            vec![workspace],
27493            vec![agent("claude")],
27494        );
27495        let request = NodeRequest::BrowseHostDirectories {
27496            directory: Some(OpaqueHostPath::utf8(root.to_string_lossy().into_owned()).unwrap()),
27497            after: None,
27498        };
27499        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
27500            capability.as_str() == CAPABILITY_HOST_DIRECTORY_BROWSE_V1
27501        }));
27502        assert!(!request_uses_unnegotiated_capability(
27503            &request,
27504            &baseline_capabilities().unwrap(),
27505        ));
27506        assert!(request_uses_unnegotiated_capability(&request, &[]));
27507
27508        let response = process_request_inner(&shared, 77, ClientRole::Observer, request)
27509            .await
27510            .unwrap();
27511        let NodeResponse::HostDirectoriesBrowsed { listing } = response else {
27512            panic!("host directory browse returned another response");
27513        };
27514        assert_eq!(
27515            listing
27516                .entries
27517                .iter()
27518                .map(|entry| entry.display_name.as_str())
27519                .collect::<Vec<_>>(),
27520            vec!["directory-a", "directory-b"],
27521        );
27522        assert!(listing.entries.iter().all(|entry| !entry.is_link));
27523        assert!(!listing.incomplete);
27524        std::fs::remove_dir_all(root).unwrap();
27525    }
27526
27527    #[tokio::test]
27528    async fn observer_can_inspect_only_a_registered_workspace() {
27529        let root = temporary_workspace_root("observer");
27530        std::fs::create_dir_all(root.join("src")).unwrap();
27531        std::fs::write(root.join("src/main.rs"), b"fn main() {}\n").unwrap();
27532        let workspace_id = WorkspaceId::new("primary").unwrap();
27533        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27534        let catalog = active_registry().unwrap();
27535        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27536        let shared = NodeShared::new(
27537            handle,
27538            "fixture-token".to_owned(),
27539            NodeId::new("node-1").unwrap(),
27540            vec![workspace],
27541            vec![agent("claude")],
27542        );
27543
27544        let response = process_request_inner(
27545            &shared,
27546            77,
27547            ClientRole::Observer,
27548            NodeRequest::InspectWorkspace {
27549                workspace_id: workspace_id.clone(),
27550            },
27551        )
27552        .await
27553        .unwrap();
27554        let NodeResponse::WorkspaceInspected { inspection } = response else {
27555            panic!("workspace inspection returned another response");
27556        };
27557        assert_eq!(inspection.workspace_id, workspace_id);
27558        assert!(inspection.entries.iter().any(|entry| {
27559            entry.relative_path.as_utf8() == Some("src/main.rs")
27560        }));
27561
27562        let unknown = process_request_inner(
27563            &shared,
27564            77,
27565            ClientRole::Observer,
27566            NodeRequest::InspectWorkspace {
27567                workspace_id: WorkspaceId::new("unknown").unwrap(),
27568            },
27569        )
27570        .await
27571        .unwrap_err();
27572        assert_eq!(unknown.code, NodeFailureCode::UnknownWorkspace);
27573        std::fs::remove_dir_all(&root).unwrap();
27574    }
27575
27576    #[test]
27577    fn context_pack_source_accepts_only_running_or_successful_exit() {
27578        assert!(context_pack_source_status_is_usable(&SessionStatus::Running));
27579        assert!(context_pack_source_status_is_usable(&SessionStatus::Exited {
27580            exit_code: Some(0),
27581        }));
27582        assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
27583            exit_code: Some(1),
27584        }));
27585        assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
27586            exit_code: None,
27587        }));
27588        assert!(!context_pack_source_status_is_usable(&SessionStatus::Failed {
27589            message: "provider failed".to_owned(),
27590        }));
27591        assert!(!context_pack_source_status_is_usable(&SessionStatus::Registered));
27592        assert!(!context_pack_source_status_is_usable(&SessionStatus::Starting));
27593        assert!(!context_pack_source_status_is_usable(&SessionStatus::Stopping));
27594    }
27595
27596    #[test]
27597    fn context_pack_repository_rejects_changed_head_with_same_git_snapshot() {
27598        let git = git_snapshot_for_parser();
27599        let first = ContextPackRepositoryObservation {
27600            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27601            git: git.clone(),
27602            files: Vec::new(),
27603        };
27604        let second = ContextPackRepositoryObservation {
27605            head: ContextPackRepositoryHead::Commit("b".repeat(40)),
27606            git,
27607            files: Vec::new(),
27608        };
27609
27610        let error = stable_context_pack_repository(first, second).unwrap_err();
27611
27612        assert_eq!(
27613            error.code,
27614            NodeFailureCode::ContextPackMaterializationFailed,
27615        );
27616    }
27617
27618    #[test]
27619    fn context_pack_repository_rejects_changed_file_with_same_git_snapshot() {
27620        let git = git_snapshot_for_parser();
27621        let first = ContextPackRepositoryObservation {
27622            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27623            git: git.clone(),
27624            files: vec![ContextPackRepositoryFileSource::utf8(
27625                "README.md",
27626                "first\n".to_owned(),
27627                6,
27628            )],
27629        };
27630        let second = ContextPackRepositoryObservation {
27631            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27632            git,
27633            files: vec![ContextPackRepositoryFileSource::utf8(
27634                "README.md",
27635                "other\n".to_owned(),
27636                6,
27637            )],
27638        };
27639
27640        let error = stable_context_pack_repository(first, second).unwrap_err();
27641
27642        assert_eq!(
27643            error.code,
27644            NodeFailureCode::ContextPackMaterializationFailed,
27645        );
27646    }
27647
27648    #[tokio::test]
27649    async fn native_session_catalog_zero_active_session_fixture() {
27650        let workspace_root = temporary_workspace_root("native-catalog-workspace");
27651        let history_root = temporary_workspace_root("native-catalog-history");
27652        let outside_root = temporary_workspace_root("native-catalog-outside");
27653        std::fs::create_dir_all(&workspace_root).unwrap();
27654        std::fs::create_dir_all(&history_root).unwrap();
27655        std::fs::create_dir_all(&outside_root).unwrap();
27656        let transcript = |session_id: &str, cwd: &Path, title: &str| {
27657            [
27658                serde_json::json!({
27659                    "type": "user",
27660                    "sessionId": session_id,
27661                    "cwd": cwd.to_str().unwrap(),
27662                    "message": { "content": "raw user text must not cross the catalog wire" }
27663                }),
27664                serde_json::json!({
27665                    "type": "assistant",
27666                    "sessionId": session_id,
27667                    "cwd": cwd.to_str().unwrap(),
27668                    "message": { "model": "claude-sonnet", "content": "raw answer" }
27669                }),
27670                serde_json::json!({
27671                    "type": "ai-title",
27672                    "sessionId": session_id,
27673                    "aiTitle": title
27674                }),
27675            ]
27676            .into_iter()
27677            .map(|record| record.to_string())
27678            .collect::<Vec<_>>()
27679            .join("\n")
27680        };
27681        std::fs::write(
27682            history_root.join("stable-session.jsonl"),
27683            transcript("stable-session", &workspace_root, "Workspace review"),
27684        )
27685        .unwrap();
27686        std::fs::write(
27687            history_root.join("outside-session.jsonl"),
27688            transcript("outside-session", &outside_root, "Outside review"),
27689        )
27690        .unwrap();
27691        let history = NativeHistoryConfig::new(vec![
27692            NativeHistoryRoot::new(
27693                gate4agent_types::AdapterId::new("claude-code").unwrap(),
27694                HistorySourceLayout::SingleNdjson,
27695                history_root.clone(),
27696            )
27697            .unwrap(),
27698        ])
27699        .unwrap();
27700        let catalog = active_registry().unwrap();
27701        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27702        assert!(handle.snapshot().sessions.is_empty());
27703        let workspace_id = WorkspaceId::new("primary").unwrap();
27704        let workspace = WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap();
27705        let mut shared = NodeShared::new(
27706            handle,
27707            "fixture-token".to_owned(),
27708            NodeId::new("node-native-catalog").unwrap(),
27709            vec![workspace],
27710            vec![agent("claude")],
27711        );
27712        shared.native_session_catalog = Some(Arc::new(Mutex::new(
27713            NativeSessionCatalogAuthority::new(history),
27714        )));
27715        let route = NativeSessionCatalogRoute::workspace(
27716            workspace_id.clone(),
27717            agent("claude"),
27718        );
27719        let request = NodeRequest::CatalogNativeSessions {
27720            route: route.clone(),
27721            limit: 2,
27722        };
27723        let observer = process_request_inner(&shared, 7, ClientRole::Observer, request.clone())
27724            .await
27725            .unwrap_err();
27726        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27727        let response = process_request_inner(&shared, 7, ClientRole::Operator, request)
27728            .await
27729            .unwrap();
27730        let NodeResponse::NativeSessionsCataloged { entries, summary, .. } = response else {
27731            panic!("native catalog returned a different response");
27732        };
27733        let summary = summary.expect("node must emit native session catalog summary metadata");
27734        assert_eq!(entries.len(), 1);
27735        assert!(gate4agent_types::validate_candidate_id(&entries[0].selection_id).is_ok());
27736        assert_eq!(entries[0].external_group, None);
27737        assert_eq!(entries[0].record_id, None);
27738        assert_eq!(entries[0].title, None);
27739        assert_eq!(entries[0].model, None);
27740        assert_eq!(entries[0].message_count, 0);
27741        let encoded_entries = serde_json::to_string(&entries).unwrap();
27742        assert!(!encoded_entries.contains("stable-session"));
27743        assert!(!encoded_entries.contains(history_root.to_string_lossy().as_ref()));
27744        let page_request = NodeRequest::PageNativeSessions {
27745            route: route.clone(),
27746            window: crate::protocol::NativeSessionCatalogWindow::Recent,
27747            catalog_revision: summary.catalog_revision,
27748            recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27749            after_selection_id: None,
27750            limit: 2,
27751        };
27752        let observer = process_request_inner(
27753            &shared,
27754            7,
27755            ClientRole::Observer,
27756            page_request.clone(),
27757        )
27758        .await
27759        .unwrap_err();
27760        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27761        let page_response = process_request_inner(
27762            &shared,
27763            7,
27764            ClientRole::Operator,
27765            page_request.clone(),
27766        )
27767        .await
27768        .unwrap();
27769        assert!(matches!(
27770            page_response,
27771            NodeResponse::NativeSessionsPaged { page, .. }
27772                if page.entries.len() == 1 && !page.has_more && page.remaining_count == 0
27773        ));
27774        let stale = process_request_inner(
27775            &shared,
27776            7,
27777            ClientRole::Operator,
27778            NodeRequest::PageNativeSessions {
27779                route: route.clone(),
27780                window: crate::protocol::NativeSessionCatalogWindow::Recent,
27781                catalog_revision: summary.catalog_revision.wrapping_add(1),
27782                recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27783                after_selection_id: None,
27784                limit: 2,
27785            },
27786        )
27787        .await
27788        .unwrap_err();
27789        assert_eq!(stale.code, NodeFailureCode::StaleNativeSessionCatalog);
27790        let selection = NativeSessionSelection {
27791            route: route.clone(),
27792            catalog_revision: summary.catalog_revision,
27793            recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27794            selection_id: entries[0].selection_id.clone(),
27795        };
27796        let preview_response = process_request_inner(
27797            &shared,
27798            7,
27799            ClientRole::Operator,
27800            NodeRequest::PreviewNativeSession {
27801                selection: selection.clone(),
27802                message_limit: 1,
27803            },
27804        )
27805        .await
27806        .unwrap();
27807        let NodeResponse::NativeSessionPreviewed { preview, .. } = preview_response else {
27808            panic!("native preview returned a different response");
27809        };
27810        assert_eq!(preview.messages.len(), 1);
27811        assert!(preview.message_count_exact);
27812        assert_eq!(preview.messages[0].text, "raw answer");
27813        assert!(preview.truncated);
27814
27815        let index_request = NodeRequest::IndexNativeSession {
27816            selection: selection.clone(),
27817            display_name: "Saved Claude".to_owned(),
27818        };
27819        let observer = process_request_inner(
27820            &shared,
27821            7,
27822            ClientRole::Observer,
27823            index_request.clone(),
27824        )
27825        .await
27826        .unwrap_err();
27827        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27828        let without_controller = process_request_inner(
27829            &shared,
27830            7,
27831            ClientRole::Operator,
27832            index_request.clone(),
27833        )
27834        .await
27835        .unwrap_err();
27836        assert_eq!(without_controller.code, NodeFailureCode::ControllerRequired);
27837        let acquired = process_request_inner(
27838            &shared,
27839            7,
27840            ClientRole::Operator,
27841            NodeRequest::AcquireController { lease_ms: 5_000 },
27842        )
27843        .await
27844        .unwrap();
27845        assert!(matches!(acquired, NodeResponse::Controller { controller: Some(_) }));
27846        let indexed = process_request_inner(
27847            &shared,
27848            7,
27849            ClientRole::Operator,
27850            index_request,
27851        )
27852        .await
27853        .unwrap();
27854        assert!(indexed.native_session_index_contract_is_valid());
27855        let NodeResponse::NativeSessionIndexed {
27856            selection: echoed_selection,
27857            record,
27858        } = indexed else {
27859            panic!("native session index returned a different response");
27860        };
27861        assert_eq!(echoed_selection, selection);
27862        let recataloged = process_request_inner(
27863            &shared,
27864            7,
27865            ClientRole::Operator,
27866            NodeRequest::CatalogNativeSessions {
27867                route,
27868                limit: 2,
27869            },
27870        )
27871        .await
27872        .unwrap();
27873        let NodeResponse::NativeSessionsCataloged { entries, .. } = recataloged else {
27874            panic!("native catalog returned a different response after indexing");
27875        };
27876        assert_eq!(entries[0].record_id.as_ref(), Some(&record.record_id));
27877
27878        let unregistered_route = NativeSessionCatalogRoute::unregistered(agent("claude"));
27879        let external_catalog = process_request_inner(
27880            &shared,
27881            7,
27882            ClientRole::Operator,
27883            NodeRequest::CatalogNativeSessions {
27884                route: unregistered_route.clone(),
27885                limit: 2,
27886            },
27887        )
27888        .await
27889        .unwrap();
27890        let external_json = serde_json::to_string(&external_catalog).unwrap();
27891        assert!(!external_json.contains("outside-session"));
27892        assert!(!external_json.contains(outside_root.to_string_lossy().as_ref()));
27893        assert!(!external_json.contains(history_root.to_string_lossy().as_ref()));
27894        assert!(!external_json.contains("\"session_id\""));
27895        assert!(!external_json.contains("\"cwd\""));
27896        assert!(!external_json.contains("\"path\""));
27897        let NodeResponse::NativeSessionsCataloged {
27898            route: echoed_route,
27899            entries: external_entries,
27900            summary: Some(external_summary),
27901        } = external_catalog
27902        else {
27903            panic!("unregistered native catalog returned a different response");
27904        };
27905        assert_eq!(echoed_route, unregistered_route);
27906        assert_eq!(external_entries.len(), 1);
27907        assert_eq!(external_entries[0].record_id, None);
27908        let external_group = external_entries[0]
27909            .external_group
27910            .as_ref()
27911            .expect("unregistered session must have an external group");
27912        assert_eq!(
27913            external_group.kind,
27914            gate4agent_types::NativeSessionExternalGroupKind::Project,
27915        );
27916        assert!(external_group.group_id.starts_with("external-"));
27917        assert_eq!(
27918            external_group.display_name,
27919            outside_root.file_name().unwrap().to_string_lossy(),
27920        );
27921        let external_selection = NativeSessionSelection {
27922            route: unregistered_route,
27923            catalog_revision: external_summary.catalog_revision,
27924            recent_cutoff_unix_ms: external_summary.recent_cutoff_unix_ms,
27925            selection_id: external_entries[0].selection_id.clone(),
27926        };
27927        let external_preview = process_request_inner(
27928            &shared,
27929            7,
27930            ClientRole::Operator,
27931            NodeRequest::PreviewNativeSession {
27932                selection: external_selection.clone(),
27933                message_limit: 1,
27934            },
27935        )
27936        .await
27937        .unwrap();
27938        let external_preview_json = serde_json::to_string(&external_preview).unwrap();
27939        assert!(!external_preview_json.contains("outside-session"));
27940        assert!(!external_preview_json.contains(outside_root.to_string_lossy().as_ref()));
27941        assert!(!external_preview_json.contains("\"session_id\""));
27942        let NodeResponse::NativeSessionPreviewed { preview, .. } = external_preview else {
27943            panic!("unregistered native preview returned a different response");
27944        };
27945        assert_eq!(preview.messages.len(), 1);
27946        let external_index = process_request_inner(
27947            &shared,
27948            7,
27949            ClientRole::Operator,
27950            NodeRequest::IndexNativeSession {
27951                selection: external_selection,
27952                display_name: "External Claude".to_owned(),
27953            },
27954        )
27955        .await
27956        .unwrap_err();
27957        assert_eq!(
27958            external_index.code,
27959            NodeFailureCode::WorkspaceRegistrationRequired,
27960        );
27961        let record_response = process_request_inner(
27962            &shared,
27963            7,
27964            ClientRole::Operator,
27965            NodeRequest::PreviewSessionRecord {
27966                record_id: record.record_id.clone(),
27967                message_limit: 2,
27968            },
27969        )
27970        .await
27971        .unwrap();
27972        let NodeResponse::SessionRecordPreviewed { record_id, preview } = record_response else {
27973            panic!("record preview returned a different response");
27974        };
27975        assert_eq!(record_id, record.record_id);
27976        assert_eq!(preview.messages.len(), 2);
27977        let encoded = serde_json::to_string(&preview).unwrap();
27978        assert!(!encoded.contains("stable-session"));
27979        assert!(!encoded.contains(history_root.to_string_lossy().as_ref()));
27980        let NodeResponse::Resync { events, .. } = shared.resync(0) else {
27981            panic!("record preview history summary returned a different response");
27982        };
27983        let summaries = events
27984            .iter()
27985            .filter_map(|envelope| match &envelope.event {
27986                NodeEvent::SessionRecordHistorySummarized { record_id, summary }
27987                    if record_id == &record.record_id =>
27988                {
27989                    Some(*summary)
27990                }
27991                _ => None,
27992            })
27993            .collect::<Vec<_>>();
27994        assert_eq!(summaries, vec![SessionHistorySummaryV1::from(&preview)]);
27995        let summary_json = serde_json::to_string(&summaries).unwrap();
27996        for private in [
27997            "stable-session",
27998            "raw user text must not cross the catalog wire",
27999            "raw answer",
28000            "Workspace review",
28001            "claude-sonnet",
28002        ] {
28003            assert!(!summary_json.contains(private), "{private}");
28004        }
28005        let identity = record.provider_session.clone().unwrap();
28006        assert_eq!(
28007            shared.revalidate_session_record_preview(&record, &identity),
28008            Ok(())
28009        );
28010        shared.remove_record_memory(&record.record_id);
28011        assert_eq!(
28012            shared
28013                .revalidate_session_record_preview(&record, &identity)
28014                .unwrap_err()
28015                .code,
28016            NodeFailureCode::UnknownSessionRecord
28017        );
28018        let released = process_request_inner(
28019            &shared,
28020            7,
28021            ClientRole::Operator,
28022            NodeRequest::ReleaseController,
28023        )
28024        .await
28025        .unwrap();
28026        assert!(matches!(released, NodeResponse::Controller { controller: None }));
28027        assert!(shared.controller_state().is_none());
28028        std::fs::remove_dir_all(workspace_root).unwrap();
28029        std::fs::remove_dir_all(history_root).unwrap();
28030        std::fs::remove_dir_all(outside_root).unwrap();
28031    }
28032
28033    #[tokio::test]
28034    async fn native_session_catalog_concurrent_operation_fails_fast_without_waiting_on_mutex() {
28035        let history_root = temporary_workspace_root("native-catalog-concurrent-operation");
28036        std::fs::create_dir_all(&history_root).unwrap();
28037        let history = NativeHistoryConfig::new(vec![
28038            NativeHistoryRoot::new(
28039                gate4agent_types::AdapterId::new("claude-code").unwrap(),
28040                HistorySourceLayout::SingleNdjson,
28041                history_root.clone(),
28042            )
28043            .unwrap(),
28044        ])
28045        .unwrap();
28046        let catalog = Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)));
28047        let held = catalog.lock().unwrap();
28048        let result = timeout(
28049            Duration::from_secs(1),
28050            run_native_session_catalog_operation(
28051                Arc::clone(&catalog),
28052                "native session catalog",
28053                |_| Ok::<(), std::convert::Infallible>(()),
28054            ),
28055        )
28056        .await
28057        .expect("concurrent native catalog request must not wait for the operation deadline")
28058        .unwrap_err();
28059        assert_eq!(result.code, NodeFailureCode::BackendBusy);
28060        drop(held);
28061        std::fs::remove_dir_all(history_root).unwrap();
28062    }
28063
28064    fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
28065        let unique = std::time::SystemTime::now()
28066            .duration_since(std::time::UNIX_EPOCH)
28067            .unwrap()
28068            .as_nanos();
28069        std::env::temp_dir().join(format!(
28070            "gate4agent-node-{label}-{}-{unique}",
28071            std::process::id(),
28072        ))
28073    }
28074
28075    fn git_snapshot_for_parser() -> GitSnapshot {
28076        GitSnapshot {
28077            is_repository: true,
28078            branch: Some("main".to_owned()),
28079            status: Vec::new(),
28080            recent_commits: Vec::new(),
28081            worktrees: Vec::new(),
28082            managed_worktree: None,
28083            truncated: false,
28084            diagnostic: None,
28085        }
28086    }
28087
28088    #[tokio::test]
28089    async fn workspace_inspection_scopes_only_exact_active_managed_branch() {
28090        let root = temporary_workspace_root("managed-git-scope");
28091        std::fs::create_dir_all(&root).unwrap();
28092        run_test_git(&root, &["init"]);
28093        run_test_git(&root, &["config", "user.email", "gate4agent@example.invalid"]);
28094        run_test_git(&root, &["config", "user.name", "Gate4Agent Test"]);
28095        std::fs::write(root.join("seed.txt"), "seed\n").unwrap();
28096        run_test_git(&root, &["add", "seed.txt"]);
28097        run_test_git(&root, &["commit", "-m", "seed"]);
28098        run_test_git(&root, &["checkout", "-b", "gate4agent/a"]);
28099
28100        let workspace_id = WorkspaceId::new("managed-a").unwrap();
28101        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
28102        let catalog = active_registry().unwrap();
28103        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28104        let shared = NodeShared::new(
28105            handle,
28106            "fixture-token".to_owned(),
28107            NodeId::new("node-1").unwrap(),
28108            vec![workspace],
28109            vec![agent("claude")],
28110        );
28111        let mut lease = ManagedWorktreeLeaseRecord {
28112            lease_id: ManagedWorktreeLeaseId::new("mw-a").unwrap(),
28113            source_workspace_id: WorkspaceId::new("primary").unwrap(),
28114            workspace_id: workspace_id.clone(),
28115            profile_id: WorktreeProfileId::new("default").unwrap(),
28116            profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
28117            target_root: root.to_string_lossy().into_owned(),
28118            branch: "gate4agent/a".to_owned(),
28119            base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
28120            expected_head: None,
28121            retention: ManagedWorktreeRetention::Retain,
28122            state: ManagedWorktreeLeaseState::InUse,
28123            session_holders: vec![ManagedWorktreeSessionHolder {
28124                incarnation_id: shared.incarnation_id,
28125                instance_id: AgentInstanceId(7),
28126                generation: SessionGeneration(1),
28127            }],
28128            record_holders: Vec::new(),
28129            cleanup_failure: None,
28130            created_at_unix_ms: 1,
28131            updated_at_unix_ms: 1,
28132        };
28133        *shared
28134            .managed_worktrees
28135            .lock()
28136            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
28137            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
28138
28139        let inspection = shared.inspect_workspace(workspace_id.clone()).await.unwrap();
28140        let scope = inspection.git.managed_worktree.unwrap();
28141        assert_eq!(scope.lease_id, lease.lease_id);
28142        assert_eq!(scope.branch, "gate4agent/a");
28143        assert_eq!(scope.active_session_count, 1);
28144
28145        lease.branch = "gate4agent/b".to_owned();
28146        *shared
28147            .managed_worktrees
28148            .lock()
28149            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
28150            ManagedWorktreeRegistry::from_records(vec![lease], Vec::new()).unwrap();
28151        let mismatch = shared.inspect_workspace(workspace_id).await.unwrap();
28152        assert!(mismatch.git.managed_worktree.is_none());
28153        std::fs::remove_dir_all(root).unwrap();
28154    }
28155
28156    #[tokio::test]
28157    async fn workspace_file_write_requires_controller_and_rejects_stale_revision() {
28158        let root = temporary_workspace_root("workspace-file-write-controller");
28159        std::fs::create_dir_all(root.join("src")).unwrap();
28160        std::fs::write(root.join("src/lib.rs"), b"old\n").unwrap();
28161        let workspace_id = WorkspaceId::new("primary").unwrap();
28162        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
28163        let catalog = active_registry().unwrap();
28164        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28165        let shared = NodeShared::new(
28166            handle,
28167            "fixture-token".to_owned(),
28168            NodeId::new("node-1").unwrap(),
28169            vec![workspace],
28170            vec![agent("claude")],
28171        );
28172        let path = RepositoryPath::utf8("src/lib.rs".to_owned()).unwrap();
28173        let expected_revision = workspace_file_revision(b"old\n");
28174        let request = NodeRequest::WriteWorkspaceFile {
28175            workspace_id: workspace_id.clone(),
28176            path: path.clone(),
28177            expected_revision: expected_revision.clone(),
28178            text: "new\n".to_owned(),
28179        };
28180        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
28181            .await
28182            .unwrap_err();
28183        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
28184        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
28185            .await
28186            .unwrap_err();
28187        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
28188        shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
28189        let response = process_request_inner(&shared, 77, ClientRole::Operator, request).await.unwrap();
28190        let NodeResponse::WorkspaceFileWritten { file } = response else {
28191            panic!("workspace file write returned another response");
28192        };
28193        assert_eq!(file.revision, Some(workspace_file_revision(b"new\n")));
28194        assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
28195        let stale = process_request_inner(
28196            &shared,
28197            77,
28198            ClientRole::Operator,
28199            NodeRequest::WriteWorkspaceFile {
28200                workspace_id,
28201                path,
28202                expected_revision,
28203                text: "lost update\n".to_owned(),
28204            },
28205        )
28206        .await
28207        .unwrap_err();
28208        assert_eq!(stale.code, NodeFailureCode::RepositoryFileRevisionConflict);
28209        assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
28210        std::fs::remove_dir_all(root).unwrap();
28211    }
28212
28213    #[tokio::test]
28214    async fn workspace_entry_create_requires_controller_and_never_overwrites() {
28215        let root = temporary_workspace_root("workspace-entry-create-controller");
28216        std::fs::create_dir_all(root.join("src")).unwrap();
28217        let workspace_id = WorkspaceId::new("primary").unwrap();
28218        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
28219        let catalog = active_registry().unwrap();
28220        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28221        let shared = NodeShared::new(
28222            handle,
28223            "fixture-token".to_owned(),
28224            NodeId::new("node-1").unwrap(),
28225            vec![workspace],
28226            vec![agent("claude")],
28227        );
28228        let path = RepositoryPath::utf8("src/new.rs".to_owned()).unwrap();
28229        let request = NodeRequest::CreateWorkspaceFile {
28230            workspace_id: workspace_id.clone(),
28231            path: path.clone(),
28232        };
28233        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
28234            capability.as_str() == NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY
28235        }));
28236        assert!(!request_uses_unnegotiated_capability(
28237            &request,
28238            &baseline_capabilities().unwrap(),
28239        ));
28240        assert!(request_uses_unnegotiated_capability(&request, &[]));
28241        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
28242            .await
28243            .unwrap_err();
28244        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
28245        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
28246            .await
28247            .unwrap_err();
28248        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
28249        shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
28250
28251        let response = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
28252            .await
28253            .unwrap();
28254        let NodeResponse::WorkspaceFileCreated { file } = response else {
28255            panic!("workspace file create returned another response");
28256        };
28257        assert_eq!(file.workspace_id, workspace_id);
28258        assert_eq!(file.path, path);
28259        assert_eq!(file.revision, Some(workspace_file_revision(&[])));
28260        assert_eq!(std::fs::read(root.join("src/new.rs")).unwrap(), Vec::<u8>::new());
28261
28262        let duplicate = process_request_inner(&shared, 77, ClientRole::Operator, request)
28263            .await
28264            .unwrap_err();
28265        assert_eq!(duplicate.code, NodeFailureCode::RepositoryEntryAlreadyExists);
28266
28267        let directory_path = RepositoryPath::utf8("src/nested".to_owned()).unwrap();
28268        let response = process_request_inner(
28269            &shared,
28270            77,
28271            ClientRole::Operator,
28272            NodeRequest::CreateWorkspaceDirectory {
28273                workspace_id: workspace_id.clone(),
28274                path: directory_path.clone(),
28275            },
28276        )
28277        .await
28278        .unwrap();
28279        assert_eq!(
28280            response,
28281            NodeResponse::WorkspaceDirectoryCreated {
28282                workspace_id: workspace_id.clone(),
28283                entry: WorkspaceEntry {
28284                    relative_path: directory_path,
28285                    kind: WorkspaceEntryKind::Directory,
28286                },
28287            },
28288        );
28289        assert!(root.join("src/nested").is_dir());
28290
28291        let missing_parent = process_request_inner(
28292            &shared,
28293            77,
28294            ClientRole::Operator,
28295            NodeRequest::CreateWorkspaceFile {
28296                workspace_id: workspace_id.clone(),
28297                path: RepositoryPath::utf8("missing/new.rs".to_owned()).unwrap(),
28298            },
28299        )
28300        .await
28301        .unwrap_err();
28302        assert_eq!(missing_parent.code, NodeFailureCode::RepositoryParentNotFound);
28303        let non_directory_parent = process_request_inner(
28304            &shared,
28305            77,
28306            ClientRole::Operator,
28307            NodeRequest::CreateWorkspaceFile {
28308                workspace_id,
28309                path: RepositoryPath::utf8("src/new.rs/child".to_owned()).unwrap(),
28310            },
28311        )
28312        .await
28313        .unwrap_err();
28314        assert_eq!(
28315            non_directory_parent.code,
28316            NodeFailureCode::RepositoryParentNotDirectory,
28317        );
28318        std::fs::remove_dir_all(root).unwrap();
28319    }
28320
28321    #[tokio::test]
28322    async fn workspace_entry_create_timeout_cancels_before_commit_without_late_mutation() {
28323        let root = temporary_workspace_root("workspace-entry-create-timeout-cancel");
28324        std::fs::create_dir_all(root.join("src")).unwrap();
28325        #[cfg(windows)]
28326        let root = std::fs::canonicalize(root).unwrap();
28327        let path = RepositoryPath::utf8("src/late.rs".to_owned()).unwrap();
28328        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
28329        let barrier = Arc::new(std::sync::Barrier::new(2));
28330        let done = Arc::new(AtomicBool::new(false));
28331        let task_commit_state = Arc::clone(&commit_state);
28332        let task_barrier = Arc::clone(&barrier);
28333        let task_done = Arc::clone(&done);
28334        let task_root = root.clone();
28335        let task = tokio::task::spawn_blocking(move || {
28336            task_barrier.wait();
28337            let result = create_workspace_file_on_disk(
28338                &task_root,
28339                &path,
28340                &task_commit_state,
28341            );
28342            task_done.store(true, Ordering::Release);
28343            result
28344        });
28345
28346        let error = settle_workspace_entry_create(
28347            task,
28348            Arc::clone(&commit_state),
28349            Duration::from_millis(10),
28350            "fixture deadline",
28351            "fixture task failed",
28352        )
28353        .await
28354        .unwrap_err();
28355        assert_eq!(error.code, NodeFailureCode::RepositoryEntryCreateTimedOut);
28356        assert_eq!(
28357            commit_state.load(Ordering::Acquire),
28358            WORKSPACE_ENTRY_CREATE_CANCELED,
28359        );
28360
28361        barrier.wait();
28362        timeout(Duration::from_secs(1), async {
28363            while !done.load(Ordering::Acquire) {
28364                tokio::task::yield_now().await;
28365            }
28366        })
28367        .await
28368        .expect("canceled blocking create did not settle");
28369        assert!(!root.join("src/late.rs").exists());
28370        std::fs::remove_dir_all(root).unwrap();
28371    }
28372}
28373
28374#[cfg(test)]
28375mod provisional_spawn_tests {
28376    use super::*;
28377    use std::sync::Arc;
28378
28379    fn shared() -> NodeShared {
28380        let catalog = active_registry().unwrap();
28381        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28382        let workspace = WorkspaceConfig::new(
28383            WorkspaceId::new("primary").unwrap(),
28384            std::env::current_dir().unwrap(),
28385        )
28386        .unwrap()
28387        .with_worktree_service_mode(WorktreeServiceMode::Manual);
28388        NodeShared::new(
28389            handle,
28390            "fixture-token".to_owned(),
28391            NodeId::new("node-provisional-test").unwrap(),
28392            vec![workspace],
28393            vec![AgentId::new("claude").unwrap()],
28394        )
28395    }
28396
28397    /// Provisional draft map: sessionize by instance id only; 50 sequential
28398    /// mark/claim cycles never collide, and a claimed draft is never reaped.
28399    #[test]
28400    fn provisional_spawn_keys_are_unique_and_sessionize_prevents_reap() {
28401        let shared = shared();
28402        let ws = WorkspaceId::new("primary").unwrap();
28403        let mut ids = Vec::with_capacity(50);
28404        for i in 1u64..=50 {
28405            let id = AgentInstanceId(i);
28406            ids.push(id);
28407            let address = SessionAddress {
28408                workspace_id: ws.clone(),
28409                session: SessionKey {
28410                    instance_id: id,
28411                    generation: SessionGeneration(1),
28412                },
28413            };
28414            shared.mark_spawn_provisional(address);
28415        }
28416        assert_eq!(
28417            shared
28418                .provisional_spawns
28419                .lock()
28420                .unwrap_or_else(|p| p.into_inner())
28421                .len(),
28422            50
28423        );
28424        for id in ids.iter().step_by(2) {
28425            shared.sessionize_spawn(*id);
28426        }
28427        assert_eq!(
28428            shared
28429                .provisional_spawns
28430                .lock()
28431                .unwrap_or_else(|p| p.into_inner())
28432                .len(),
28433            25
28434        );
28435        assert!(shared.take_due_provisional_spawns().is_empty());
28436        {
28437            let mut map = shared
28438                .provisional_spawns
28439                .lock()
28440                .unwrap_or_else(|p| p.into_inner());
28441            let past = Instant::now() - Duration::from_secs(91);
28442            for (_, (_, at)) in map.iter_mut() {
28443                *at = past;
28444            }
28445        }
28446        let due = shared.take_due_provisional_spawns();
28447        assert_eq!(due.len(), 25);
28448        let due_ids: std::collections::BTreeSet<_> =
28449            due.iter().map(|a| a.session.instance_id.0).collect();
28450        let expected: std::collections::BTreeSet<_> =
28451            ids.iter().skip(1).step_by(2).map(|id| id.0).collect();
28452        assert_eq!(due_ids, expected);
28453        assert!(shared
28454            .provisional_spawns
28455            .lock()
28456            .unwrap_or_else(|p| p.into_inner())
28457            .is_empty());
28458    }
28459
28460    /// Twenty parallel provisional marks must each get their own instance key;
28461    /// killing/sessionizing one id must not touch the others.
28462    #[test]
28463    fn provisional_spawn_parallel_marks_isolate_kills_by_instance_id() {
28464        let shared = Arc::new(shared());
28465        let ws = WorkspaceId::new("primary").unwrap();
28466        let mut handles = Vec::new();
28467        for i in 1u64..=20 {
28468            let shared = Arc::clone(&shared);
28469            let ws = ws.clone();
28470            handles.push(std::thread::spawn(move || {
28471                let id = AgentInstanceId(i);
28472                let address = SessionAddress {
28473                    workspace_id: ws,
28474                    session: SessionKey {
28475                        instance_id: id,
28476                        generation: SessionGeneration(1),
28477                    },
28478                };
28479                shared.mark_spawn_provisional(address);
28480                id
28481            }));
28482        }
28483        let mut ids = Vec::new();
28484        for h in handles {
28485            ids.push(h.join().unwrap());
28486        }
28487        ids.sort_by_key(|id| id.0);
28488        assert_eq!(ids.len(), 20);
28489        ids.dedup();
28490        assert_eq!(ids.len(), 20, "parallel marks must not share instance keys");
28491        assert_eq!(
28492            shared
28493                .provisional_spawns
28494                .lock()
28495                .unwrap_or_else(|p| p.into_inner())
28496                .len(),
28497            20
28498        );
28499        shared.sessionize_spawn(AgentInstanceId(7));
28500        let left: Vec<_> = shared
28501            .provisional_spawns
28502            .lock()
28503            .unwrap_or_else(|p| p.into_inner())
28504            .keys()
28505            .map(|id| id.0)
28506            .collect();
28507        assert!(!left.contains(&7));
28508        assert_eq!(left.len(), 19);
28509    }
28510}
28511
28512
28513#[cfg(unix)]
28514extern "C" {
28515    #[link_name = "geteuid"]
28516    fn libc_geteuid() -> u32;
28517}
28518
28519#[cfg(test)]
28520mod call_home_link_tests {
28521    use super::*;
28522
28523    #[test]
28524    fn call_home_backoff_is_capped_and_monotonic() {
28525        let mut last = Duration::ZERO;
28526        for attempt in 1..20 {
28527            let next = call_home_backoff(attempt);
28528            assert!(next >= last);
28529            assert!(next <= Duration::from_secs(8));
28530            last = next;
28531        }
28532        assert_eq!(call_home_backoff(0), Duration::from_millis(500));
28533    }
28534
28535    #[tokio::test]
28536    async fn call_home_socket_gets_keepalive() {
28537        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
28538        let addr = listener.local_addr().unwrap();
28539        let (client, _server) = tokio::join!(tokio::net::TcpStream::connect(addr), listener.accept());
28540        let client = client.unwrap();
28541        enable_call_home_keepalive(&client).unwrap();
28542        let sock = socket2::SockRef::from(&client);
28543        assert!(sock.keepalive().unwrap());
28544        #[cfg(any(target_os = "linux", target_os = "macos"))]
28545        assert_eq!(sock.tcp_keepalive_time().unwrap(), CALL_HOME_KEEPALIVE_IDLE);
28546    }
28547}
28548
28549/// A provider transcript for this session id exists in a known provider home.
28550fn provider_session_exists(provider: &str, id: &str) -> bool {
28551    if provider != "claude" { return false; }
28552    let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
28553    if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") { homes.extend(std::env::split_paths(&extra)); }
28554    let name = format!("{id}.jsonl");
28555    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())))
28556}
28557
28558/// Append launch args to a prepared overlay (or create an instance overlay for them).
28559fn overlay_with_extra_args(
28560    provider: &AgentId,
28561    mode: SessionMode,
28562    overlay: Option<PreparedNativeLaunchOverlay>,
28563    args: Vec<OsString>,
28564) -> Result<Option<PreparedNativeLaunchOverlay>, NodeFailure> {
28565    let bad = || failure(NodeFailureCode::BackendOperationFailed, "resume launch args overlay is invalid");
28566    Ok(match (overlay, args) {
28567        (prepared, args) if args.is_empty() => prepared,
28568        (Some(PreparedNativeLaunchOverlay::Instance(mut o)), args) => { o.append_extra_args(args).map_err(|_| bad())?; Some(PreparedNativeLaunchOverlay::Instance(o)) }
28569        (Some(PreparedNativeLaunchOverlay::Environment(o)), args) => Some(PreparedNativeLaunchOverlay::Instance(o.into_instance_with_extra_args(args).map_err(|_| bad())?)),
28570        (None, args) => Some(PreparedNativeLaunchOverlay::Instance(NativeInstanceLaunchOverlay::new(
28571            provider.clone(),
28572            match mode { SessionMode::Pty => TransportKind::Pty, SessionMode::Acp => TransportKind::Acp, SessionMode::Inline => TransportKind::Pipe },
28573            Vec::new(),
28574            args,
28575        ).map_err(|_| bad())?)),
28576    })
28577}