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        let signer_identity = match (mode, self.signer_proxy.get(), signer_provider) {
4171            (SessionMode::Pty, Some(proxy), Some(m4a_provider)) => {
4172                let session_key = format!("{}-{}", self.incarnation_id, instance_id.0);
4173                let uid = unsafe { libc_geteuid() };
4174                let handle = proxy.reserve(&session_key, m4a_provider, uid);
4175                let sock = std::env::var_os(crate::keystore_proxy::SOCKET_ENV).unwrap_or_default();
4176                tracing::info!(provider = %provider, instance_id = ?instance_id, "m4a signer handle reserved at spawn");
4177                Some(vec![
4178                    gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from(crate::keystore_proxy::HANDLE_ENV), value: Some(std::ffi::OsString::from(handle)) },
4179                    gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from(crate::keystore_proxy::SOCKET_ENV), value: Some(sock) },
4180                    gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from("M4A_SESSION_ID"), value: Some(std::ffi::OsString::from(session_key)) },
4181                ])
4182            }
4183            _ => None,
4184        };
4185        #[cfg(not(unix))]
4186        let signer_identity: Option<Vec<gate4agent_catalog::EnvMutation>> = None;
4187        let environment_overlay = match (mode, self.m4a_accounts.as_ref(), crate::m4a_accounts::m4a_provider(provider.as_str()), signer_identity) {
4188            (SessionMode::Pty, _, _, Some(identity)) => {
4189                let merged = match environment_overlay {
4190                    None => NativeInstanceLaunchOverlay::new_session_identity(provider.clone(), identity).map(PreparedNativeLaunchOverlay::Instance),
4191                    Some(PreparedNativeLaunchOverlay::Instance(mut overlay)) => {
4192                        overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
4193                    }
4194                    Some(PreparedNativeLaunchOverlay::Environment(mut overlay)) => {
4195                        overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Environment(overlay))
4196                    }
4197                }
4198                .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a signer handle overlay is invalid"))?;
4199                Some(merged)
4200            }
4201            (SessionMode::Pty | SessionMode::Acp, Some(accounts), Some(m4a_provider), None) => {
4202                let session_key = self.m4a_session_key(instance_id);
4203                self.m4a_session_overlay(accounts, m4a_provider, provider, instance_id, environment_overlay, &session_key, mode)?
4204            }
4205            (SessionMode::Pty, Some(_), None, _) => {
4206                tracing::warn!(provider = %provider, instance_id = ?instance_id, "m4a session account not assigned: provider has no m4a mapping");
4207                environment_overlay
4208            }
4209            _ => environment_overlay,
4210        };
4211        let mut instance_overlay = environment_overlay
4212            .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
4213            .transpose()?
4214            .flatten();
4215        Ok(PreparedSpawn {
4216            instance_overlay,
4217            environment_selection,
4218            materialization_guard,
4219            harness_mcp_overlay,
4220            #[cfg(feature = "dig2-station-probe")]
4221            browser_station_lease_guard,
4222            materialization_id,
4223        })
4224    }
4225}
4226
4227struct SessionRecords {
4228    records: BTreeMap<SessionRecordId, ManagedSessionRecord>,
4229}
4230
4231struct SpawnIdempotencyCache {
4232    entries: BTreeMap<SpawnIdempotencyKey, SpawnIdempotencyEntry>,
4233}
4234
4235struct SpawnIdempotencyEntry {
4236    value: SpawnIdempotencyValue,
4237    expires_at: Instant,
4238}
4239
4240enum SpawnIdempotencyValue {
4241    Standard {
4242        spec: SpawnSpec,
4243        result: Result<ResolvedSpawnReceipt, NodeFailure>,
4244    },
4245    Managed {
4246        request: ManagedWorktreeSpawnRequest,
4247        result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
4248    },
4249    HarnessMcp,
4250}
4251
4252enum DurableManagedSpawnReplayDecision {
4253    Reserved,
4254    PendingLinked,
4255    Committed(ManagedWorktreeSpawnReceipt),
4256}
4257
4258impl SpawnIdempotencyValue {
4259    fn references_context(&self, context_id: &SpawnContextId) -> bool {
4260        match self {
4261            Self::Standard { result, .. } => result
4262                .as_ref()
4263                .ok()
4264                .and_then(|receipt| receipt.context.as_ref()),
4265            Self::Managed { result, .. } => result
4266                .as_ref()
4267                .ok()
4268                .and_then(|receipt| receipt.spawn.context.as_ref()),
4269            Self::HarnessMcp => None,
4270        }
4271        .is_some_and(|receipt| &receipt.id == context_id)
4272    }
4273}
4274
4275#[derive(Clone, Copy)]
4276struct ControllerLease {
4277    connection_id: u64,
4278    expires_at: Instant,
4279}
4280
4281struct NodeEventHistory {
4282    last_sequence: u64,
4283    replay_floor_sequence: u64,
4284    events: VecDeque<NodeEventEnvelope>,
4285    record_providers: BTreeMap<SessionRecordId, AgentId>,
4286    removed_record_providers: BTreeMap<u64, AgentId>,
4287}
4288
4289impl NodeEventHistory {
4290    fn new(record_providers: BTreeMap<SessionRecordId, AgentId>) -> Self {
4291        Self {
4292            last_sequence: 0,
4293            replay_floor_sequence: 1,
4294            events: VecDeque::with_capacity(NODE_EVENT_HISTORY_MAX),
4295            record_providers,
4296            removed_record_providers: BTreeMap::new(),
4297        }
4298    }
4299}
4300
4301impl NodeShared {
4302    /// A node-spawned grok PTY session gets its own leader socket, so its agent backend runs
4303    /// as a descendant of this session's process (with the session's own environment) and
4304    /// never attaches to an operator's shared leader. Same grok home, same login.
4305    fn session_resume_journal(&self) -> Option<PathBuf> {
4306        self.state_path.as_ref().and_then(|p| p.parent()).map(crate::pty_resume::path)
4307    }
4308
4309    /// Node start: resume every journalled session through its provider's resume path,
4310    /// under its old instance number, so HQ shows the same chat.
4311    async fn restore_journalled_sessions(&self) {
4312        let Some(file) = self.session_resume_journal() else { return };
4313        if !crate::pty_resume::enabled() { return; }
4314        for e in crate::pty_resume::load(&file) {
4315            crate::pty_resume::remove(&file, e.instance_id);
4316            // Same m4a session key as before the restart: the session keeps its account and nick.
4317            if !e.session_key.is_empty() {
4318                self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).insert(AgentInstanceId(e.instance_id), e.session_key.clone());
4319            }
4320            let (Ok(ws), Ok(provider)) = (WorkspaceId::new(e.workspace_id.clone()), AgentId::new(e.provider.clone())) else { continue };
4321            if e.acp {
4322                let record_id = {
4323                    let records = self.session_records.lock().unwrap_or_else(|p| p.into_inner());
4324                    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())
4325                };
4326                let cur = self.next_instance_id.load(Ordering::Acquire);
4327                if e.instance_id >= cur { self.next_instance_id.store(e.instance_id, Ordering::Release); }
4328                let size = gate4agent_types::TerminalSize { rows: 40, columns: 120 };
4329                let resumed = match record_id {
4330                    Some(rid) => SPAWN_SESSION_OPTIONS.scope(e.options.clone(), self.resume_session_record(&rid, size, None)).await.map(|(_, a)| a),
4331                    None => Err(failure(NodeFailureCode::SessionRecordNotResumable, "no record")),
4332                };
4333                // An agent without ACP session/load (claude) cannot resume: same number, fresh chat.
4334                let r = match resumed {
4335                    Ok(a) => Ok(a),
4336                    Err(err) => {
4337                        tracing::info!(instance_id = e.instance_id, message = %err.message, "acp resume unavailable; starting fresh under the same number");
4338                        self.next_instance_id.store(e.instance_id.max(self.next_instance_id.load(Ordering::Acquire).min(e.instance_id)), Ordering::Release);
4339                        SPAWN_SESSION_OPTIONS.scope(e.options.clone(), self.spawn_session(ws, provider, SessionMode::Acp, size, None)).await
4340                    }
4341                };
4342                match r {
4343                    Ok(addr) => tracing::info!(instance_id = addr.session.instance_id.0, provider = %e.provider, "journalled acp session restored"),
4344                    Err(err) => tracing::warn!(instance_id = e.instance_id, provider = %e.provider, message = %err.message, "journalled acp session restore failed"),
4345                }
4346                continue;
4347            }
4348            if e.provider_session_id.is_empty() || !provider_session_exists(&e.provider, &e.provider_session_id) {
4349                // Nothing said yet in that chat: start it fresh under the same number.
4350                let cur = self.next_instance_id.load(Ordering::Acquire);
4351                if e.instance_id >= cur { self.next_instance_id.store(e.instance_id, Ordering::Release); }
4352                let size = gate4agent_types::TerminalSize { rows: 40, columns: 120 };
4353                match SPAWN_SESSION_OPTIONS.scope(e.options.clone(), self.spawn_session(ws, provider, SessionMode::Pty, size, None)).await {
4354                    Ok(addr) => tracing::info!(instance_id = addr.session.instance_id.0, provider = %e.provider, "journalled session had no transcript; started fresh"),
4355                    Err(err) => tracing::warn!(instance_id = e.instance_id, provider = %e.provider, message = %err.message, "journalled session restart failed"),
4356                }
4357                continue;
4358            }
4359            let folder = std::path::Path::new(&e.cwd);
4360            let in_session_dir = folder.parent().and_then(|p| p.file_name()).is_some_and(|n| n == ".hatchery-sessions");
4361            let name = match (in_session_dir, folder.file_name()) {
4362                (true, Some(n)) => n.to_string_lossy().into_owned(),
4363                _ => format!("{} #{}", e.provider, e.instance_id),
4364            };
4365            let identity = ProviderSessionIdentity { key: gate4agent_types::ProviderSessionKey::SessionId, id: e.provider_session_id.clone(), transcript_path: None };
4366            let record = match self.index_provider_session(ws, provider, identity, name) {
4367                Ok(r) => r,
4368                Err(err) => { tracing::warn!(instance_id = e.instance_id, message = %err.message, "journalled session not indexed"); continue; }
4369            };
4370            let cur = self.next_instance_id.load(Ordering::Acquire);
4371            if e.instance_id >= cur { self.next_instance_id.store(e.instance_id, Ordering::Release); }
4372            let size = gate4agent_types::TerminalSize { rows: if e.rows > 0 { e.rows } else { 40 }, columns: if e.cols > 0 { e.cols } else { 120 } };
4373            let r = SPAWN_SESSION_OPTIONS.scope(e.options.clone(), self.resume_session_record(&record.record_id, size, None)).await;
4374            match r {
4375                Ok((_, addr)) => tracing::info!(instance_id = addr.session.instance_id.0, provider = %e.provider, "journalled session resumed"),
4376                Err(err) => tracing::warn!(instance_id = e.instance_id, provider = %e.provider, message = %err.message, "journalled session resume failed"),
4377            }
4378        }
4379    }
4380
4381    fn refresh_session_journal(&self) {
4382        let Some(file) = self.session_resume_journal() else { return };
4383        let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
4384        if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") { homes.extend(std::env::split_paths(&extra)); }
4385        crate::pty_resume::refresh(&file, &homes);
4386        let ids: Vec<(u64, String)> = {
4387            let records = self.session_records.lock().unwrap_or_else(|p| p.into_inner());
4388            records.records.values().filter_map(|r| Some((r.active_session.as_ref()?.session.instance_id.0, r.provider_session.as_ref()?.id.clone()))).collect()
4389        };
4390        for (inst, sid) in ids {
4391            crate::pty_resume::set_session_id(&file, inst, &sid);
4392        }
4393    }
4394
4395    fn session_grok_leader_socket(
4396        &self,
4397        provider: &AgentId,
4398        mode: SessionMode,
4399        instance_id: AgentInstanceId,
4400    ) -> Option<std::path::PathBuf> {
4401        if provider.as_str() != "grok" || mode != SessionMode::Pty || crate::grok_leader::disabled() {
4402            return None;
4403        }
4404        let root = self
4405            .m4a_accounts
4406            .as_ref()
4407            .and_then(|a| a.base().parent().map(std::path::Path::to_path_buf))
4408            .unwrap_or_else(std::env::temp_dir);
4409        let socket = crate::grok_leader::socket_path(&root, &format!("{}-{}", self.incarnation_id, instance_id.0));
4410        if let Some(previous) = self
4411            .grok_leaders
4412            .lock()
4413            .unwrap_or_else(|p| p.into_inner())
4414            .insert(instance_id, socket.clone())
4415        {
4416            crate::grok_leader::retire(&previous);
4417        }
4418        Some(socket)
4419    }
4420
4421    /// Stops this session's own grok leader, if it had one. Never touches any other leader.
4422    fn retire_session_grok_leader(&self, instance_id: AgentInstanceId) {
4423        let socket = self.grok_leaders.lock().unwrap_or_else(|p| p.into_inner()).remove(&instance_id);
4424        if let Some(socket) = socket {
4425            crate::grok_leader::retire(&socket);
4426        }
4427    }
4428
4429    fn new_with_incarnation(
4430        handle: Gate4AgentHandle,
4431        access_token: String,
4432        node_id: NodeId,
4433        incarnation_id: NodeIncarnationId,
4434        workspaces: Vec<WorkspaceConfig>,
4435        enabled_providers: Vec<AgentId>,
4436        provider_runtime_statuses: ProviderRuntimeStatuses,
4437        provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
4438        provider_contracts: Vec<ProviderContractSupport>,
4439        provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
4440        spawn_profiles: SpawnProfileRegistry,
4441        native_launch_profile_control: Option<NativeLaunchProfileControl>,
4442        native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
4443        session_environment_materializer: Option<SessionEnvironmentMaterializer>,
4444        state_path: Option<PathBuf>,
4445        records: Vec<ManagedSessionRecord>,
4446        managed_worktrees: Vec<ManagedWorktreeLeaseRecord>,
4447        managed_worktree_tombstones: Vec<ManagedWorktreeLeaseRecord>,
4448        materializations: Vec<MaterializationOwnershipRecord>,
4449        persistence_warning: Option<String>,
4450        input_settle_timeout_ms: u64,
4451    ) -> Self {
4452        let (event_tx, _) = broadcast::channel(NODE_BROADCAST_CAPACITY);
4453        let (terminal_event_tx, _) = broadcast::channel(NODE_TERMINAL_BROADCAST_CAPACITY);
4454        let (agent_stream_event_tx, _) = broadcast::channel(NODE_AGENT_STREAM_BROADCAST_CAPACITY);
4455        let (harness_mcp_proxy_event_tx, _) =
4456            broadcast::channel(NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY);
4457        let record_providers = records
4458            .iter()
4459            .map(|record| (record.record_id.clone(), record.provider.clone()))
4460            .collect();
4461        let mut worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode> = workspaces
4462            .iter()
4463            .map(|workspace| {
4464                (workspace.workspace_id.clone(), workspace.worktree_service_mode)
4465            })
4466            .collect();
4467        for lease in &managed_worktrees {
4468            worktree_service_modes.insert(lease.workspace_id.clone(), WorktreeServiceMode::Off);
4469        }
4470        let managed_worktree_profiles = workspaces.iter()
4471            .map(|workspace| (
4472                workspace.workspace_id.clone(),
4473                workspace.managed_worktree_profiles.clone(),
4474            ))
4475            .collect();
4476        let mut managed_worktrees = ManagedWorktreeRegistry::from_records(
4477            managed_worktrees,
4478            managed_worktree_tombstones,
4479        ).expect("durable managed worktree registry was validated while loading");
4480        managed_worktrees.clear_stale_session_holders(incarnation_id, unix_time_ms());
4481        managed_worktrees.reattach_record_holders(
4482            &managed_worktree_record_holders(&records),
4483            unix_time_ms(),
4484        );
4485        Self {
4486            handle,
4487            access_token,
4488            node_id,
4489            incarnation_id,
4490            started_at_unix_ms: std::time::SystemTime::now()
4491                .duration_since(std::time::UNIX_EPOCH)
4492                .unwrap_or_default()
4493                .as_millis()
4494                .min(u64::MAX as u128) as u64,
4495            workspaces: RwLock::new(
4496                workspaces
4497                    .into_iter()
4498                    .map(|workspace| (workspace.workspace_id, workspace.canonical_root))
4499                    .collect(),
4500            ),
4501            worktree_service_modes,
4502            managed_worktree_profiles,
4503            managed_worktrees: Mutex::new(managed_worktrees),
4504            enabled_providers,
4505            provider_runtime_statuses,
4506            provider_runtime_status_updates: RwLock::new(BTreeMap::new()),
4507            provider_runtime_monitor,
4508            provider_contracts,
4509            provider_adapter_contracts,
4510            spawn_profiles,
4511            environment_profiles: RwLock::new(BTreeMap::new()),
4512            environment_materialization_profiles: RwLock::new(BTreeMap::new()),
4513            network_allowlist_catalog: RwLock::new(crate::network_allowlist_catalog::NetworkAllowlistCatalog::new()),
4514            #[cfg(feature = "dig2-station-probe")]
4515            browser_station_leases: crate::dig2_station_lease::new_lease_table(),
4516            bundle_catalog: RwLock::new(BundleCatalog::default()),
4517            delivery_store: Mutex::new(None),
4518            context_pack_store: Mutex::new(None),
4519            context_catalog: RwLock::new(ContextPackCatalog::default()),
4520            native_launch_profile_control,
4521            harness_mcp_registry: None,
4522            native_session_catalog,
4523            session_environment_materializer,
4524            m4a_accounts: crate::m4a_accounts::M4aSessionAccounts::from_env(),
4525            m4a_live: Mutex::new(BTreeMap::new()),
4526            m4a_keys: Mutex::new(BTreeMap::new()),
4527            m4a_reuse_keys: Mutex::new(BTreeMap::new()),
4528            grok_leaders: Mutex::new(BTreeMap::new()),
4529            #[cfg(unix)]
4530            signer_proxy: std::sync::OnceLock::new(),
4531            materializations: Mutex::new(
4532                materializations
4533                    .into_iter()
4534                    .map(|record| (record.id().clone(), record))
4535                    .collect(),
4536            ),
4537            spawn_idempotency: Mutex::new(SpawnIdempotencyCache {
4538                entries: BTreeMap::new(),
4539            }),
4540            managed_spawn_replays: Mutex::new(BTreeMap::new()),
4541            controller: Mutex::new(None),
4542            provisional_spawns: Mutex::new(BTreeMap::new()),
4543            pending_acp_options: Mutex::new(BTreeMap::new()),
4544            history: Mutex::new(NodeEventHistory::new(record_providers)),
4545            event_tx,
4546            terminal_event_tx,
4547            agent_stream_event_tx,
4548            harness_mcp_proxy_event_tx,
4549            terminal_frame_watermarks: Mutex::new(BTreeMap::new()),
4550            pty_input_wake: tokio::sync::Notify::new(),
4551            pty_input_marks: Mutex::new(BTreeMap::new()),
4552            next_connection_id: AtomicU64::new(1),
4553            next_instance_id: AtomicU64::new(1),
4554            next_command_id: AtomicU64::new(1),
4555            shutdown: AtomicBool::new(false),
4556            shutdown_notify: Notify::new(),
4557            preauth_slots: Arc::new(Semaphore::new(MAX_PREAUTH_CONNECTIONS)),
4558            authenticated_slots: Arc::new(Semaphore::new(MAX_AUTHENTICATED_CONNECTIONS)),
4559            inspection_slots: Arc::new(Semaphore::new(WORKSPACE_INSPECTION_MAX_CONCURRENCY)),
4560            mutation_gate: AsyncMutex::new(()),
4561            session_bindings: Mutex::new(BTreeMap::new()),
4562            session_records: Mutex::new(SessionRecords {
4563                records: records
4564                    .into_iter()
4565                    .map(|record| (record.record_id.clone(), record))
4566                    .collect(),
4567            }),
4568            state_path,
4569            persistence_error: RwLock::new(
4570                persistence_warning
4571                    .map(|message| session_registry::sanitized_persistence_summary(&message)),
4572            ),
4573            state_transaction: Mutex::new(()),
4574            input_settle_timeout_ms,
4575            drive_loop_profile: Mutex::new(DriveLoopProfiler::default()),
4576            runtime_tick_profile: Mutex::new(TickProfileSnapshot::default()),
4577            shell_efficiency_profile: OnceLock::new(),
4578            native_session_gauge: AtomicUsize::new(0),
4579            connection_loop_iterations: AtomicU64::new(0),
4580            connection_events_sent: AtomicU64::new(0),
4581            rejected_commands_total: AtomicU64::new(0),
4582            rejected_command_reasons: Mutex::new(BTreeMap::new()),
4583            drive_loop_iterations_total: AtomicU64::new(0),
4584            drive_loop_iterations_idle: AtomicU64::new(0),
4585            session_record_retention: SessionRecordRetentionConfig::default(),
4586            retired_records_total: AtomicU64::new(0),
4587            #[cfg(feature = "fixture")]
4588            fixture_semantic_hook_policy: false,
4589            #[cfg(feature = "fixture")]
4590            fixture_spawn_managed_worktree_v2_failure_probe:
4591                SpawnManagedWorktreeV2FailureProbe::default(),
4592        }
4593    }
4594
4595    #[cfg(test)]
4596    fn new(
4597        handle: Gate4AgentHandle,
4598        access_token: String,
4599        node_id: NodeId,
4600        workspaces: Vec<WorkspaceConfig>,
4601        enabled_providers: Vec<AgentId>,
4602    ) -> Self {
4603        Self::new_with_incarnation(
4604            handle,
4605            access_token,
4606            node_id,
4607            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
4608            workspaces,
4609            enabled_providers,
4610            ProviderRuntimeStatuses::default(),
4611            None,
4612            Vec::new(),
4613            Vec::new(),
4614            SpawnProfileRegistry::default(),
4615            None,
4616            None,
4617            None,
4618            None,
4619            Vec::new(),
4620            Vec::new(),
4621            Vec::new(),
4622            Vec::new(),
4623            None,
4624            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
4625        )
4626    }
4627
4628    fn persistence_error(&self) -> Option<String> {
4629        self.persistence_error
4630            .read()
4631            .unwrap_or_else(|poisoned| poisoned.into_inner())
4632            .clone()
4633    }
4634
4635    async fn admit_provider_runtime(
4636        &self,
4637        provider: &AgentId,
4638        requirement: ProviderRuntimeRequirement,
4639    ) -> Result<ProviderRuntimePolicy, NodeFailure> {
4640        let admission = if let Some(monitor) = self.provider_runtime_monitor.clone() {
4641            let pipe_transport = monitor.supports_pipe_transport(provider);
4642            let acp_transport = monitor.supports_acp_transport(provider);
4643            let provider = provider.clone();
4644            let refresh = tokio::task::spawn_blocking(move || monitor.evaluate(&provider));
4645            let (status, admission) = timeout(
4646                Duration::from_millis(PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS),
4647                refresh,
4648            )
4649            .await
4650            .map_err(|_| failure(
4651                NodeFailureCode::BackendBusy,
4652                "provider runtime probe exceeded its bounded deadline",
4653            ))?
4654            .map_err(|_| failure(
4655                NodeFailureCode::BackendOperationFailed,
4656                "provider runtime probe task failed",
4657            ))?;
4658            if let Some(status) = status {
4659                self
4660                    .provider_runtime_status_updates
4661                    .write()
4662                    .unwrap_or_else(|poisoned| poisoned.into_inner())
4663                    .insert(status.provider().clone(), status);
4664            }
4665            admission.and_then(|policy| {
4666                require_policy(policy, pipe_transport, acp_transport, requirement).map(|()| policy)
4667            })
4668        } else {
4669            // No live monitor means no catalog to consult -- there is
4670            // nothing here to confirm this provider declares a Pipe or ACP
4671            // transport, so Inline/Acp are rejected the same conservative
4672            // way an undeclared provider always was, rather than admitted
4673            // on trust.
4674            crate::provider_runtime::admit_status(
4675                &self.provider_runtime_statuses,
4676                provider,
4677                false,
4678                false,
4679                requirement,
4680            )
4681        };
4682        let admission = admission.map(|policy| self.admit_declared_hook_semantics(provider, policy));
4683        admission.map_err(|error| match error {
4684            ProviderRuntimeAdmissionError::LauncherUnavailable => failure(
4685                NodeFailureCode::BackendOperationFailed,
4686                "provider launcher is unavailable",
4687            ),
4688            ProviderRuntimeAdmissionError::SemanticCapabilityUnverified => failure(
4689                NodeFailureCode::UnsupportedCapability,
4690                "provider semantic capability is not verified",
4691            ),
4692            ProviderRuntimeAdmissionError::ProbeBusy => failure(
4693                NodeFailureCode::BackendBusy,
4694                "provider runtime probe is already in progress",
4695            ),
4696        })
4697    }
4698
4699    /// Whether the catalog declares a Pipe transport for `provider` --
4700    /// the fact `require_policy` needs to admit or reject
4701    /// `ProviderRuntimeRequirement::Inline`. With no live monitor there is no
4702    /// catalog to consult, so this stays conservative and rejects, matching
4703    /// `admit_provider_runtime`'s own no-monitor branch.
4704    fn provider_supports_pipe_transport(&self, provider: &AgentId) -> bool {
4705        match self.provider_runtime_monitor.as_deref() {
4706            Some(monitor) => monitor.supports_pipe_transport(provider),
4707            None => false,
4708        }
4709    }
4710
4711    /// Whether the catalog declares an ACP transport for `provider` -- the
4712    /// fact `require_policy` needs to admit or reject
4713    /// `ProviderRuntimeRequirement::Acp`. Mirrors
4714    /// `provider_supports_pipe_transport` exactly, including the
4715    /// conservative no-monitor default.
4716    fn provider_supports_acp_transport(&self, provider: &AgentId) -> bool {
4717        match self.provider_runtime_monitor.as_deref() {
4718            Some(monitor) => monitor.supports_acp_transport(provider),
4719            None => false,
4720        }
4721    }
4722
4723    /// Grants hook ingestion to a provider whose catalog entry declares a
4724    /// Hook adapter, whatever runtime mode its launcher probe landed in.
4725    ///
4726    /// `admit_status`'s `RawPassthrough` arm hands back
4727    /// `ProviderRuntimePolicy::raw_pty()`, which grants nothing semantic --
4728    /// correct for everything it was written to answer, and wrong for hooks
4729    /// specifically. A hook event is not inferred from terminal behaviour,
4730    /// so it has nothing to do with whether a launcher probe verified this
4731    /// CLI version's PTY contract; the authority is the catalog declaring
4732    /// the adapter plus the ingress authenticating the route. Without this
4733    /// the node installs a provider's hooks, the CLI faithfully calls them,
4734    /// and every event is refused -- which is exactly the state grok was in.
4735    ///
4736    /// Applied to both admission paths so the monitor's own derivation and
4737    /// this coarse fallback cannot disagree about the same provider.
4738    fn admit_declared_hook_semantics(
4739        &self,
4740        provider: &AgentId,
4741        policy: ProviderRuntimePolicy,
4742    ) -> ProviderRuntimePolicy {
4743        if policy.hook_semantics || !policy.raw_pty_lifecycle {
4744            return policy;
4745        }
4746        let declares_hook = self.provider_adapter_contracts.iter().any(|contract| {
4747            contract.provider == *provider && contract.family == AdapterFamily::Hook
4748        });
4749        if !declares_hook {
4750            return policy;
4751        }
4752        ProviderRuntimePolicy::new(
4753            policy.raw_pty_lifecycle,
4754            policy.semantic_readiness,
4755            policy.structured_prompt,
4756            policy.provider_session_identity,
4757            policy.semantic_resume,
4758            true,
4759        )
4760        .expect("granting hook semantics over a raw-PTY policy is internally valid")
4761    }
4762
4763    /// Station network allowlist → provider launch overlay argv.
4764    ///
4765    /// - **Codex**: first-slice `-c sandbox_workspace_write.network_access` from
4766    ///   `provider_native` under Moderate (PTY or ACP).
4767    /// - **Claude**: catalog `permits` → ProviderHome sidecar settings JSON +
4768    ///   documented `--settings <path>` on **PTY** only (Linux/macOS/WSL2).
4769    ///   ACP refuses (no measured ACP settings-path surface). Native Windows
4770    ///   refuses (no vendor Bash sandbox). Membership-only (empty permits) =
4771    ///   no overlay.
4772    /// - **Kimi / Grok**: no first-party settings/argv network surface here —
4773    ///   empty overlay when no `provider_native`; invented native keys already
4774    ///   refuse at load / resolve.
4775    ///
4776    /// Empty when no allowlist / nothing to map. Never cookies / secrets.
4777    fn resolve_network_allowlist_overlay_args(
4778        &self,
4779        provider: &AgentId,
4780        mode: SessionMode,
4781        approval_level: ApprovalLevel,
4782        network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4783        provider_home: Option<&Path>,
4784    ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4785        let Some(allowlist_id) = network_allowlist else {
4786            return Ok(Vec::new());
4787        };
4788        let catalog = self
4789            .network_allowlist_catalog
4790            .read()
4791            .unwrap_or_else(|poisoned| poisoned.into_inner());
4792        let Some(entry) = catalog.get(allowlist_id) else {
4793            return Err(failure(
4794                NodeFailureCode::UnknownNetworkAllowlist,
4795                "spawn network allowlist is unavailable on this node",
4796            ));
4797        };
4798
4799        // Claude: settings-shaped overlay from permits (not provider_native).
4800        if provider.as_str() == "claude" && !entry.permits.is_empty() {
4801            crate::network_allowlist_catalog::claude_bash_sandbox_network_os_supported().map_err(
4802                |message| {
4803                    failure(
4804                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4805                        &message,
4806                    )
4807                },
4808            )?;
4809            if !matches!(mode, SessionMode::Pty) {
4810                return Err(failure(
4811                    NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4812                    "Claude sandbox.network --settings overlay requires PTY transport; ACP has no measured settings-path surface (refuse rather than invent RPC)",
4813                ));
4814            }
4815            let Some(home) = provider_home else {
4816                return Err(failure(
4817                    NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4818                    "Claude sandbox.network --settings overlay requires ProviderHome materialization (CLAUDE_CONFIG_DIR)",
4819                ));
4820            };
4821            let domains =
4822                crate::network_allowlist_catalog::claude_allowed_domains_from_permits(&entry.permits);
4823            if domains.is_empty() {
4824                return Err(failure(
4825                    NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4826                    "Claude sandbox.network overlay could not map catalog permits to allowedDomains",
4827                ));
4828            }
4829            let settings_path = home.join(
4830                crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
4831            );
4832            let json =
4833                crate::network_allowlist_catalog::claude_station_network_settings_json(&domains);
4834            crate::session_environment::secure_create_file(&settings_path, json.as_bytes()).map_err(
4835                |_| {
4836                    failure(
4837                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4838                        "Claude station network settings sidecar could not be written under ProviderHome",
4839                    )
4840                },
4841            )?;
4842            let overlay =
4843                crate::network_allowlist_catalog::claude_settings_network_overlay_args(&settings_path);
4844            return Ok(overlay.into_iter().map(std::ffi::OsString::from).collect());
4845        }
4846
4847        let Some(native) = entry.provider_native.as_ref() else {
4848            return Ok(Vec::new());
4849        };
4850        let overlay = crate::network_allowlist_catalog::resolve_provider_native_launch_overlay(
4851            provider.as_str(),
4852            approval_level,
4853            native,
4854        )
4855        .map_err(|message| {
4856            failure(
4857                NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4858                &message,
4859            )
4860        })?;
4861        if overlay.is_empty() {
4862            return Ok(Vec::new());
4863        }
4864        if !matches!(mode, SessionMode::Pty | SessionMode::Acp) {
4865            return Err(failure(
4866                NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4867                "Codex network_access -c overlay requires PTY or ACP transport",
4868            ));
4869        }
4870        Ok(overlay.into_iter().map(std::ffi::OsString::from).collect())
4871    }
4872
4873    /// Backward-compatible alias used by Codex-focused unit tests.
4874    fn resolve_codex_network_access_overlay_args(
4875        &self,
4876        provider: &AgentId,
4877        mode: SessionMode,
4878        approval_level: ApprovalLevel,
4879        network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4880    ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4881        self.resolve_network_allowlist_overlay_args(
4882            provider,
4883            mode,
4884            approval_level,
4885            network_allowlist,
4886            None,
4887        )
4888    }
4889
4890    fn resolve_environment_profile(
4891        &self,
4892        resolved: &ResolvedSpawnSpec,
4893    ) -> Result<Option<ResolvedEnvironmentProfileReceipt>, NodeFailure> {
4894        // Station network allowlist catalog enforce (plan
4895        // station-network-and-browser-profile-knobs §2.1 / §4): empty-default
4896        // catalog — unknown ids refuse rather than silent ambient. Runs even
4897        // when no environment_profile_id is present so a bare allowlist still
4898        // cannot bypass the catalog. Never cookies / OAuth / proxy credentials.
4899        if let Some(allowlist_id) = resolved.network_allowlist.as_ref() {
4900            let catalog = self
4901                .network_allowlist_catalog
4902                .read()
4903                .unwrap_or_else(|poisoned| poisoned.into_inner());
4904            let Some(entry) = catalog.get(allowlist_id) else {
4905                return Err(failure(
4906                    NodeFailureCode::UnknownNetworkAllowlist,
4907                    "spawn network allowlist is unavailable on this node",
4908                ));
4909            };
4910            // Track B: refuse provider-native mappings this provider cannot
4911            // honor (e.g. codex_network_access on Claude). Never silent ambient.
4912            if let Some(native) = entry.provider_native.as_ref() {
4913                if let Err(message) = crate::network_allowlist_catalog::provider_native_mapping_supported(
4914                    resolved.provider.as_str(),
4915                    resolved.approval_level,
4916                    native,
4917                ) {
4918                    return Err(failure(
4919                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4920                        &message,
4921                    ));
4922                }
4923            }
4924        }
4925        // Station knobs Track A (dig2browser-station-probe-and-network-permit-set):
4926        // when feature `dig2-station-probe` is on and browser_profile_id is set,
4927        // cheap named-pipe path reachability must succeed (Windows
4928        // `\\.\pipe\{suffix}`); platform-unsupported or unreachable → clear
4929        // NodeFailureCode. Runs even without environment_profile_id (same
4930        // placement spirit as allowlist catalog). Feature off keeps stub echo.
4931        // Never cookies / OAuth / proxy credentials. Exclusive lease is
4932        // acquired later in spawn_session_with_deadline (after instance id).
4933        #[cfg(feature = "dig2-station-probe")]
4934        if resolved.browser_profile_id.is_some() {
4935            use crate::dig2_station_probe::{probe_station_reachable, StationProbeError};
4936            match probe_station_reachable() {
4937                Ok(()) => {}
4938                Err(StationProbeError::UnsupportedPlatform)
4939                | Err(StationProbeError::InvalidPipeSuffix) => {
4940                    return Err(failure(
4941                        NodeFailureCode::BrowserStationProbeUnavailable,
4942                        "dig2browser station probe unavailable on this platform",
4943                    ));
4944                }
4945                Err(StationProbeError::Unreachable) => {
4946                    return Err(failure(
4947                        NodeFailureCode::BrowserStationUnreachable,
4948                        "dig2browser station named-pipe is unreachable",
4949                    ));
4950                }
4951            }
4952        }
4953        let Some(profile_id) = resolved.environment_profile_id.as_ref() else {
4954            return Ok(None);
4955        };
4956        let profiles = self
4957            .environment_profiles
4958            .read()
4959            .unwrap_or_else(|poisoned| poisoned.into_inner());
4960        let binding = profiles.get(profile_id).ok_or_else(|| {
4961            failure(
4962                NodeFailureCode::UnknownEnvironmentProfile,
4963                "spawn environment profile is unavailable on this node",
4964            )
4965        })?;
4966        if binding.provider != resolved.provider
4967            || binding.native_profile_id(resolved.mode).is_none()
4968        {
4969            return Err(failure(
4970                NodeFailureCode::EnvironmentProfileBindingMismatch,
4971                "spawn environment profile does not match the provider and mode",
4972            ));
4973        }
4974        // Echo opaque network_allowlist / browser_profile_id onto the receipt
4975        // after catalog + optional dig2 probe above. Empty/whitespace ids
4976        // already refuse at Spawn*Id construction.
4977        Ok(Some(ResolvedEnvironmentProfileReceipt {
4978            profile_id: binding.id.clone(),
4979            profile_revision: binding.revision.clone(),
4980            network_allowlist: resolved.network_allowlist.clone(),
4981            browser_profile_id: resolved.browser_profile_id.clone(),
4982        }))
4983    }
4984
4985    fn resolve_bundle(
4986        &self,
4987        resolved: &ResolvedSpawnSpec,
4988        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4989    ) -> Result<Option<ResolvedBundleReceipt>, NodeFailure> {
4990        let Some(bundle_id) = resolved.bundle_id.as_ref() else {
4991            return Ok(None);
4992        };
4993        let catalog = self.bundle_catalog
4994            .read()
4995            .unwrap_or_else(|poisoned| poisoned.into_inner());
4996        let bundle = catalog.get(bundle_id).ok_or_else(|| {
4997                failure(
4998                    NodeFailureCode::UnknownBundle,
4999                    "spawn bundle is unavailable on this node",
5000                )
5001            })?;
5002        self.bundle_layout(
5003            &resolved.provider,
5004            resolved.mode,
5005            environment_profile,
5006            bundle,
5007        )?;
5008        Ok(Some(bundle.receipt()))
5009    }
5010
5011    fn begin_delivery_stage(
5012        &self,
5013        manifest: DeliveryBundleManifestV2,
5014    ) -> Result<NodeResponse, NodeFailure> {
5015        let mut store = self
5016            .delivery_store
5017            .lock()
5018            .unwrap_or_else(|poisoned| poisoned.into_inner());
5019        let store = store.as_mut().ok_or_else(|| {
5020            failure(
5021                NodeFailureCode::DeliveryStageStorageFailed,
5022                "node delivery store is unavailable",
5023            )
5024        })?;
5025        let begun = store.begin(manifest).map_err(delivery_failure)?;
5026        Ok(NodeResponse::DeliveryStageBegun {
5027            stage_id: begun.stage_id,
5028            manifest_digest: begun.manifest_digest,
5029            missing_blobs: begun.missing_blobs,
5030        })
5031    }
5032
5033    fn put_delivery_blob_chunk(
5034        &self,
5035        stage_id: DeliveryStageId,
5036        blob_digest: DeliveryBlobDigestV1,
5037        offset: u64,
5038        chunk_hex: DeliveryBlobChunkHexV1,
5039    ) -> Result<NodeResponse, NodeFailure> {
5040        let mut store = self
5041            .delivery_store
5042            .lock()
5043            .unwrap_or_else(|poisoned| poisoned.into_inner());
5044        let store = store.as_mut().ok_or_else(|| {
5045            failure(
5046                NodeFailureCode::DeliveryStageStorageFailed,
5047                "node delivery store is unavailable",
5048            )
5049        })?;
5050        let accepted = store
5051            .put_chunk(&stage_id, &blob_digest, offset, &chunk_hex.decode())
5052            .map_err(delivery_failure)?;
5053        Ok(NodeResponse::DeliveryBlobChunkAccepted {
5054            stage_id: accepted.stage_id,
5055            blob_digest: accepted.blob_digest,
5056            next_offset: accepted.next_offset,
5057        })
5058    }
5059
5060    fn commit_delivery_stage(
5061        &self,
5062        stage_id: DeliveryStageId,
5063    ) -> Result<DeliveryCommitReceiptV1, NodeFailure> {
5064        let mut store = self
5065            .delivery_store
5066            .lock()
5067            .unwrap_or_else(|poisoned| poisoned.into_inner());
5068        let store = store.as_mut().ok_or_else(|| {
5069            failure(
5070                NodeFailureCode::DeliveryStageStorageFailed,
5071                "node delivery store is unavailable",
5072            )
5073        })?;
5074        let prepared = store.prepare_commit(&stage_id).map_err(delivery_failure)?;
5075        let mut catalog = self
5076            .bundle_catalog
5077            .write()
5078            .unwrap_or_else(|poisoned| poisoned.into_inner());
5079        let mut next_catalog = catalog.clone();
5080        next_catalog
5081            .insert_idempotent(prepared.bundle().clone())
5082            .map_err(|_| {
5083                failure(
5084                    NodeFailureCode::DeliveryStageConflict,
5085                    "delivery bundle identity conflicts with installed content",
5086                )
5087            })?;
5088        let receipt = store.publish_commit(prepared).map_err(delivery_failure)?;
5089        *catalog = next_catalog;
5090        Ok(receipt)
5091    }
5092
5093    fn abort_delivery_stage(
5094        &self,
5095        stage_id: &DeliveryStageId,
5096    ) -> Result<(), NodeFailure> {
5097        let mut store = self
5098            .delivery_store
5099            .lock()
5100            .unwrap_or_else(|poisoned| poisoned.into_inner());
5101        let store = store.as_mut().ok_or_else(|| {
5102            failure(
5103                NodeFailureCode::DeliveryStageStorageFailed,
5104                "node delivery store is unavailable",
5105            )
5106        })?;
5107        store.abort(stage_id).map_err(delivery_failure)
5108    }
5109
5110    fn resolve_context(
5111        &self,
5112        resolved: &ResolvedSpawnSpec,
5113    ) -> Result<Option<ResolvedContextPackReceipt>, NodeFailure> {
5114        let Some(context_id) = resolved.context_id.as_ref() else {
5115            return Ok(None);
5116        };
5117        self.context_catalog
5118            .read()
5119            .unwrap_or_else(|poisoned| poisoned.into_inner())
5120            .get(context_id)
5121            .map(|context| context.receipt().clone())
5122            .ok_or_else(|| {
5123                failure(
5124                    NodeFailureCode::UnknownContextPack,
5125                    "spawn context pack is unavailable on this node",
5126                )
5127            })
5128            .map(Some)
5129    }
5130
5131    fn bundle_layout(
5132        &self,
5133        provider: &AgentId,
5134        mode: SessionMode,
5135        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5136        bundle: &NodeBundle,
5137    ) -> Result<BundleProviderLayout, NodeFailure> {
5138        let layout = validate_bundle_binding(provider, mode, bundle).map_err(|_| {
5139            failure(
5140                NodeFailureCode::BundleBindingMismatch,
5141                "spawn bundle does not match the provider and mode",
5142            )
5143        })?;
5144        if layout != BundleProviderLayout::Codex {
5145            return Ok(layout);
5146        }
5147        let profile = environment_profile.and_then(|receipt| {
5148            let profiles = self
5149                .environment_profiles
5150                .read()
5151                .unwrap_or_else(|poisoned| poisoned.into_inner());
5152            let binding = profiles.get(&receipt.profile_id)?;
5153            if binding.revision != receipt.profile_revision
5154                || &binding.provider != provider
5155                || binding.native_profile_id(mode).is_none()
5156            {
5157                return None;
5158            }
5159            self.environment_materialization_profiles
5160                .read()
5161                .unwrap_or_else(|poisoned| poisoned.into_inner())
5162                .get(&receipt.profile_id)
5163                .cloned()
5164        });
5165        if !profile.is_some_and(|profile| profile.supports_bundle_layout(layout)) {
5166            return Err(failure(
5167                NodeFailureCode::BundleBindingMismatch,
5168                "Codex bundle requires an exact isolated CODEX_HOME profile",
5169            ));
5170        }
5171        Ok(layout)
5172    }
5173
5174    fn select_environment_profile(
5175        &self,
5176        instance_id: AgentInstanceId,
5177        provider: &AgentId,
5178        mode: SessionMode,
5179        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5180    ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
5181        let Some(environment_profile) = environment_profile else {
5182            return Ok(None);
5183        };
5184        let native_profile_id = {
5185            let profiles = self
5186                .environment_profiles
5187                .read()
5188                .unwrap_or_else(|poisoned| poisoned.into_inner());
5189            let binding = profiles
5190                .get(&environment_profile.profile_id)
5191                .ok_or_else(|| {
5192                    failure(
5193                        NodeFailureCode::UnknownEnvironmentProfile,
5194                        "session environment profile is unavailable on this node",
5195                    )
5196                })?;
5197            if &binding.revision != &environment_profile.profile_revision
5198                || &binding.provider != provider
5199            {
5200                return Err(failure(
5201                    NodeFailureCode::EnvironmentProfileBindingMismatch,
5202                    "session environment profile revision or provider changed",
5203                ));
5204            }
5205            binding.native_profile_id(mode).cloned().ok_or_else(|| {
5206                failure(
5207                    NodeFailureCode::EnvironmentProfileBindingMismatch,
5208                    "session environment profile does not support this mode",
5209                )
5210            })?
5211        };
5212        let control = self.native_launch_profile_control.clone().ok_or_else(|| {
5213            failure(
5214                NodeFailureCode::BackendOperationFailed,
5215                "native environment profile controller is unavailable",
5216            )
5217        })?;
5218        control
5219            .select_native_launch_profile(instance_id, native_profile_id)
5220            .map_err(|_| {
5221                failure(
5222                    NodeFailureCode::BackendOperationFailed,
5223                    "native environment profile selection failed",
5224                )
5225            })?;
5226        Ok(Some(NativeEnvironmentSelectionGuard {
5227            control: Some(control),
5228            instance_id,
5229        }))
5230    }
5231
5232    fn install_prepared_launch_overlay(
5233        &self,
5234        instance_id: AgentInstanceId,
5235        overlay: PreparedNativeLaunchOverlay,
5236    ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
5237        let control = self.native_launch_profile_control.clone().ok_or_else(|| {
5238            failure(
5239                NodeFailureCode::BackendOperationFailed,
5240                "native launch profile controller is unavailable",
5241            )
5242        })?;
5243        match overlay {
5244            PreparedNativeLaunchOverlay::Environment(overlay) => {
5245                control
5246                    .install_native_launch_environment_overlay(instance_id, overlay)
5247                    .map_err(|_| failure(
5248                        NodeFailureCode::BackendOperationFailed,
5249                        "session environment overlay installation failed",
5250                    ))?;
5251                Ok(None)
5252            }
5253            PreparedNativeLaunchOverlay::Instance(overlay) => {
5254                control
5255                    .install_native_instance_launch_overlay(instance_id, overlay)
5256                    .map_err(|_| failure(
5257                        NodeFailureCode::BundleBindingMismatch,
5258                        "session bundle launch overlay installation failed",
5259                    ))?;
5260                Ok(Some(NativeInstanceOverlayGuard {
5261                    control: Some(control),
5262                    instance_id,
5263                }))
5264            }
5265        }
5266    }
5267
5268    fn environment_materialization_profile(
5269        &self,
5270        environment_profile: &ResolvedEnvironmentProfileReceipt,
5271    ) -> Option<NodeSessionMaterializationProfile> {
5272        self.environment_materialization_profiles
5273            .read()
5274            .unwrap_or_else(|poisoned| poisoned.into_inner())
5275            .get(&environment_profile.profile_id)
5276            .cloned()
5277    }
5278
5279    fn materialization_profile(
5280        &self,
5281        provider: &AgentId,
5282        mode: SessionMode,
5283        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5284        bundle: Option<&ResolvedBundleReceipt>,
5285        context: Option<&ResolvedContextPackReceipt>,
5286    ) -> Result<Option<(
5287        NodeSessionMaterializationProfile,
5288        bool,
5289        Option<BundleProviderLayout>,
5290    )>, NodeFailure> {
5291        let environment = environment_profile
5292            .and_then(|receipt| self.environment_materialization_profile(receipt));
5293        let (bundle, bundle_layout) = match bundle {
5294            Some(receipt) => {
5295                let catalog = self.bundle_catalog.read()
5296                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5297                let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
5298                    NodeFailureCode::UnknownBundle,
5299                    "session bundle is unavailable on this node",
5300                ))?;
5301                if installed.receipt() != *receipt {
5302                    return Err(failure(
5303                        NodeFailureCode::BundleBindingMismatch,
5304                        "session bundle revision or digest changed",
5305                    ));
5306                }
5307                let layout = self.bundle_layout(
5308                    provider,
5309                    mode,
5310                    environment_profile,
5311                    installed,
5312                )?;
5313                (Some(installed.clone()), Some(layout))
5314            }
5315            None => (None, None),
5316        };
5317        let has_environment_overlay = environment.is_some() || context.is_some();
5318        let profile = match (environment, bundle.as_ref(), bundle_layout) {
5319            (None, None, None) => None,
5320            (Some(profile), None, None) => Some(Ok(profile)),
5321            (None, Some(bundle), Some(layout)) => {
5322                Some(NodeSessionMaterializationProfile::from_bundle(bundle, layout))
5323            }
5324            (Some(profile), Some(bundle), Some(layout)) => {
5325                Some(profile.with_bundle(bundle, layout))
5326            }
5327            _ => {
5328                return Err(failure(
5329                    NodeFailureCode::BundleBindingMismatch,
5330                    "session bundle provider layout is unavailable",
5331                ));
5332            }
5333        };
5334        let profile = profile
5335            .transpose()
5336            .map_err(|_| failure(
5337                NodeFailureCode::BundleMaterializationFailed,
5338                "session bundle materialization profile is invalid",
5339            ))?;
5340        let profile = match (profile, context) {
5341            (Some(profile), Some(receipt)) => {
5342                let catalog = self.context_catalog
5343                    .read()
5344                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5345                let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
5346                    NodeFailureCode::UnknownContextPack,
5347                    "session context pack is unavailable on this node",
5348                ))?;
5349                if pack.receipt() != receipt {
5350                    return Err(failure(
5351                        NodeFailureCode::ContextPackMaterializationFailed,
5352                        "session context pack identity changed",
5353                    ));
5354                }
5355                profile.with_context(pack)
5356            }
5357            (None, Some(receipt)) => {
5358                let catalog = self.context_catalog
5359                    .read()
5360                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5361                let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
5362                    NodeFailureCode::UnknownContextPack,
5363                    "session context pack is unavailable on this node",
5364                ))?;
5365                if pack.receipt() != receipt {
5366                    return Err(failure(
5367                        NodeFailureCode::ContextPackMaterializationFailed,
5368                        "session context pack identity changed",
5369                    ));
5370                }
5371                NodeSessionMaterializationProfile::from_context(pack)
5372            }
5373            (Some(profile), None) => Ok(profile),
5374            (None, None) => return Ok(None),
5375        }
5376        .map_err(|_| failure(
5377            NodeFailureCode::ContextPackMaterializationFailed,
5378            "session context pack materialization profile is invalid",
5379        ))?;
5380        Ok(Some((profile, has_environment_overlay, bundle_layout)))
5381    }
5382
5383    fn prepare_session_materialization<'a>(
5384        &'a self,
5385        address: &SessionAddress,
5386        provider: &AgentId,
5387        mode: SessionMode,
5388        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5389        bundle: Option<&ResolvedBundleReceipt>,
5390        context: Option<&ResolvedContextPackReceipt>,
5391        managed_lease_id: Option<ManagedWorktreeLeaseId>,
5392    ) -> Result<Option<(Option<PreparedNativeLaunchOverlay>, SessionMaterializationGuard<'a>)>, NodeFailure> {
5393        let Some((profile, has_environment_overlay, bundle_layout)) =
5394            self.materialization_profile(provider, mode, environment_profile, bundle, context)?
5395        else {
5396            return Ok(None);
5397        };
5398        let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5399            failure(
5400                NodeFailureCode::BackendOperationFailed,
5401                "session environment materializer is unavailable",
5402            )
5403        })?;
5404        let materialization_failure_code = if context.is_some() {
5405            NodeFailureCode::ContextPackMaterializationFailed
5406        } else if bundle.is_some() {
5407            NodeFailureCode::BundleMaterializationFailed
5408        } else {
5409            NodeFailureCode::BackendOperationFailed
5410        };
5411        let id = MaterializationId::new(format!(
5412            "mat-{}-{}-{}",
5413            self.incarnation_id,
5414            address.session.instance_id.0,
5415            address.session.generation.0,
5416        ))
5417        .map_err(|_| {
5418            failure(
5419                NodeFailureCode::BackendOperationFailed,
5420                "session environment materialization identity failed",
5421            )
5422        })?;
5423        let owner = MaterializationOwner::Session {
5424            incarnation_id: self.incarnation_id,
5425            instance_id: address.session.instance_id,
5426            generation: address.session.generation,
5427        };
5428        let mut ownership = materializer
5429            .begin(
5430                id.clone(),
5431                environment_profile.cloned(),
5432                bundle.cloned(),
5433                context.cloned(),
5434                owner,
5435                managed_lease_id,
5436                &profile,
5437                unix_time_ms(),
5438            )
5439            .map_err(|_| {
5440                failure(
5441                    materialization_failure_code,
5442                    "session materialization preparation failed",
5443                )
5444            })?;
5445        {
5446            let _transaction = self
5447                .state_transaction
5448                .lock()
5449                .unwrap_or_else(|poisoned| poisoned.into_inner());
5450            let duplicate = self
5451                .materializations
5452                .lock()
5453                .unwrap_or_else(|poisoned| poisoned.into_inner())
5454                .insert(id.clone(), ownership.clone());
5455            if duplicate.is_some() {
5456                return Err(failure(
5457                    NodeFailureCode::BackendOperationFailed,
5458                    "session environment materialization identity conflict",
5459                ));
5460            }
5461            if let Err(error) = self.persist_state_locked() {
5462                self.materializations
5463                    .lock()
5464                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5465                    .remove(&id);
5466                return Err(persistence_failure(error));
5467            }
5468        }
5469        let environment = match materializer.materialize(&mut ownership, &profile, unix_time_ms()) {
5470            Ok(environment) => environment,
5471            Err(_) => {
5472                let transaction = self
5473                    .state_transaction
5474                    .lock()
5475                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5476                self.materializations
5477                    .lock()
5478                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5479                    .insert(id.clone(), ownership.clone());
5480                if self.persist_state_locked().is_err() {
5481                    self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5482                }
5483                drop(transaction);
5484                if ownership.state() == MaterializationState::CleanupRequired {
5485                    let _ = self.cleanup_materialization(&id);
5486                }
5487                return Err(failure(
5488                    materialization_failure_code,
5489                    "session materialization failed",
5490                ));
5491            }
5492        };
5493        {
5494            let _transaction = self
5495                .state_transaction
5496                .lock()
5497                .unwrap_or_else(|poisoned| poisoned.into_inner());
5498            self.materializations
5499                .lock()
5500                .unwrap_or_else(|poisoned| poisoned.into_inner())
5501                .insert(id.clone(), ownership.clone());
5502            if let Err(error) = self.persist_state_locked() {
5503                drop(_transaction);
5504                let _ = self.cleanup_materialization(&id);
5505                return Err(persistence_failure(error));
5506            }
5507        }
5508        let guard = SessionMaterializationGuard {
5509            shared: self,
5510            id: Some(id),
5511            provider_home: Some(ownership.provider_home().to_path_buf()),
5512        };
5513        let bundle_arguments = match bundle {
5514            Some(receipt) => {
5515                let catalog = self.bundle_catalog.read()
5516                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5517                let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
5518                    NodeFailureCode::UnknownBundle,
5519                    "session bundle is unavailable on this node",
5520                ))?;
5521                if installed.receipt() != *receipt {
5522                    return Err(failure(
5523                        NodeFailureCode::BundleBindingMismatch,
5524                        "session bundle revision or digest changed",
5525                    ));
5526                }
5527                let layout = bundle_layout.ok_or_else(|| failure(
5528                    NodeFailureCode::BundleBindingMismatch,
5529                    "materialized bundle provider layout is unavailable",
5530                ))?;
5531                materializer.revalidate_bundle(&ownership, installed, layout).map_err(|_| {
5532                    failure(
5533                        NodeFailureCode::BundleMaterializationFailed,
5534                        "session bundle byte revalidation failed",
5535                    )
5536                })?;
5537                Some(bundle_launch_arguments(
5538                    layout,
5539                    ownership.bundle_root(),
5540                    ownership.provider_home(),
5541                ).map_err(|_| failure(
5542                    NodeFailureCode::BundleBindingMismatch,
5543                    "session bundle launch binding is invalid",
5544                ))?)
5545            }
5546            None => None,
5547        };
5548        if let Some(receipt) = context {
5549            materializer
5550                .revalidate_context(&ownership, receipt)
5551                .map_err(|_| failure(
5552                    NodeFailureCode::ContextPackMaterializationFailed,
5553                    "session context pack byte revalidation failed",
5554                ))?;
5555        }
5556        let transport = match mode {
5557            SessionMode::Pty => TransportKind::Pty,
5558            SessionMode::Inline => TransportKind::Pipe,
5559            SessionMode::Acp => TransportKind::Acp,
5560        };
5561        let overlay = if let Some(extra_args) = bundle_arguments {
5562            Some(PreparedNativeLaunchOverlay::Instance(
5563                NativeInstanceLaunchOverlay::new(
5564                    provider.clone(),
5565                    transport,
5566                    if has_environment_overlay {
5567                        environment
5568                    } else {
5569                        Vec::new()
5570                    },
5571                    extra_args,
5572                )
5573                .map_err(|_| {
5574                    failure(
5575                        NodeFailureCode::BundleBindingMismatch,
5576                        "session bundle launch overlay is invalid",
5577                    )
5578                })?,
5579            ))
5580        } else if has_environment_overlay {
5581            let context_only = environment_profile.is_none()
5582                && bundle.is_none()
5583                && context.is_some();
5584            let overlay = if context_only {
5585                NativeLaunchEnvironmentOverlay::new_context_root(
5586                    provider.clone(),
5587                    transport,
5588                    environment,
5589                )
5590                .map_err(|_| {
5591                    failure(
5592                        NodeFailureCode::ContextPackMaterializationFailed,
5593                        "context-only launch overlay is invalid",
5594                    )
5595                })?
5596            } else {
5597                NativeLaunchEnvironmentOverlay::new(
5598                    provider.clone(),
5599                    transport,
5600                    environment,
5601                )
5602                .map_err(|_| {
5603                    failure(
5604                        NodeFailureCode::BackendOperationFailed,
5605                        "session environment overlay is invalid",
5606                    )
5607                })?
5608            };
5609            Some(PreparedNativeLaunchOverlay::Environment(overlay))
5610        } else {
5611            None
5612        };
5613        Ok(Some((overlay, guard)))
5614    }
5615
5616    fn cleanup_materialization(&self, id: &MaterializationId) -> Result<(), NodeFailure> {
5617        let Some(mut ownership) = self
5618            .materializations
5619            .lock()
5620            .unwrap_or_else(|poisoned| poisoned.into_inner())
5621            .get(id)
5622            .cloned()
5623        else {
5624            return Ok(());
5625        };
5626        if ownership.state() != MaterializationState::CleanupRequired {
5627            ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5628                failure(
5629                    NodeFailureCode::BackendOperationFailed,
5630                    "session environment cleanup state is invalid",
5631                )
5632            })?;
5633        }
5634        {
5635            let _transaction = self
5636                .state_transaction
5637                .lock()
5638                .unwrap_or_else(|poisoned| poisoned.into_inner());
5639            let previous = self
5640                .materializations
5641                .lock()
5642                .unwrap_or_else(|poisoned| poisoned.into_inner())
5643                .insert(id.clone(), ownership.clone());
5644            if let Err(error) = self.persist_state_locked() {
5645                let mut materializations = self
5646                    .materializations
5647                    .lock()
5648                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5649                if let Some(previous) = previous {
5650                    materializations.insert(id.clone(), previous);
5651                }
5652                return Err(persistence_failure(error));
5653            }
5654        }
5655        let cleanup = self
5656            .session_environment_materializer
5657            .as_ref()
5658            .ok_or(())
5659            .and_then(|materializer| materializer.cleanup(&ownership).map_err(|_| ()));
5660        if cleanup.is_err() {
5661            let _ = ownership.mark_recovery_required(unix_time_ms());
5662            self.materializations
5663                .lock()
5664                .unwrap_or_else(|poisoned| poisoned.into_inner())
5665                .insert(id.clone(), ownership);
5666            if self.persist_state().is_err() {
5667                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5668            }
5669            return Err(failure(
5670                NodeFailureCode::BackendOperationFailed,
5671                "session environment cleanup requires recovery",
5672            ));
5673        }
5674        let _transaction = self
5675            .state_transaction
5676            .lock()
5677            .unwrap_or_else(|poisoned| poisoned.into_inner());
5678        self.materializations
5679            .lock()
5680            .unwrap_or_else(|poisoned| poisoned.into_inner())
5681            .remove(id);
5682        if let Err(error) = self.persist_state_locked() {
5683            self.materializations
5684                .lock()
5685                .unwrap_or_else(|poisoned| poisoned.into_inner())
5686                .insert(id.clone(), ownership);
5687            return Err(persistence_failure(error));
5688        }
5689        Ok(())
5690    }
5691
5692    fn resolve_record_materialization(
5693        &self,
5694        record_id: &SessionRecordId,
5695        provider: &AgentId,
5696        mode: SessionMode,
5697        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5698        bundle: Option<&ResolvedBundleReceipt>,
5699        context: Option<&ResolvedContextPackReceipt>,
5700    ) -> Result<Option<(MaterializationId, Option<PreparedNativeLaunchOverlay>)>, NodeFailure> {
5701        let ownership = {
5702            let materializations = self
5703                .materializations
5704                .lock()
5705                .unwrap_or_else(|poisoned| poisoned.into_inner());
5706            let mut matches = materializations.values().filter(|record| {
5707                record.owner()
5708                    == &MaterializationOwner::Record {
5709                        record_id: record_id.clone(),
5710                    }
5711            });
5712            let ownership = matches.next().cloned();
5713            if matches.next().is_some() {
5714                return Err(failure(
5715                    NodeFailureCode::BackendOperationFailed,
5716                    "managed session has conflicting environment ownership",
5717                ));
5718            }
5719            ownership
5720        };
5721        if let (Some(ownership), Some(receipt)) = (ownership.as_ref(), context) {
5722            let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5723                failure(
5724                    NodeFailureCode::ContextPackMaterializationFailed,
5725                    "session context materializer is unavailable",
5726                )
5727            })?;
5728            let pack = materializer
5729                .revalidate_context(ownership, receipt)
5730                .map_err(|_| {
5731                    self.mark_materialization_recovery_required(ownership.id());
5732                    failure(
5733                        NodeFailureCode::ContextPackMaterializationFailed,
5734                        "managed session context pack revalidation failed",
5735                    )
5736                })?;
5737            self.context_catalog
5738                .write()
5739                .unwrap_or_else(|poisoned| poisoned.into_inner())
5740                .insert(pack)
5741                .map_err(|_| failure(
5742                    NodeFailureCode::ContextPackMaterializationFailed,
5743                    "managed session context pack catalog restore failed",
5744                ))?;
5745        }
5746        let materialization = self.materialization_profile(
5747            provider,
5748            mode,
5749            environment_profile,
5750            bundle,
5751            context,
5752        )?;
5753        match (ownership, materialization) {
5754            (None, None) => Ok(None),
5755            (None, Some(_)) | (Some(_), None) => Err(failure(
5756                NodeFailureCode::BackendOperationFailed,
5757                "managed session materialization is unavailable",
5758            )),
5759            (Some(ownership), Some((profile, has_environment_materialization, bundle_layout))) => {
5760                if ownership.environment_profile() != environment_profile
5761                    || ownership.bundle() != bundle
5762                    || ownership.context() != context
5763                    || ownership.state() != MaterializationState::Ready
5764                {
5765                    self.mark_materialization_recovery_required(ownership.id());
5766                    return Err(failure(
5767                        NodeFailureCode::BackendOperationFailed,
5768                        "managed session environment ownership requires recovery",
5769                    ));
5770                }
5771                let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5772                    failure(
5773                        NodeFailureCode::BackendOperationFailed,
5774                        "session environment materializer is unavailable",
5775                    )
5776                })?;
5777                if materializer.revalidate(&ownership).is_err() {
5778                    self.mark_materialization_recovery_required(ownership.id());
5779                    return Err(failure(
5780                        NodeFailureCode::BackendOperationFailed,
5781                        "managed session environment revalidation failed",
5782                    ));
5783                }
5784                let bundle_arguments = if let Some(bundle_receipt) = bundle {
5785                    let catalog = self.bundle_catalog.read()
5786                        .unwrap_or_else(|poisoned| poisoned.into_inner());
5787                    let Some(installed) = catalog.get(&bundle_receipt.id) else {
5788                        return Err(failure(
5789                            NodeFailureCode::UnknownBundle,
5790                            "managed session bundle is unavailable",
5791                        ));
5792                    };
5793                    let layout = bundle_layout.ok_or_else(|| failure(
5794                        NodeFailureCode::BundleBindingMismatch,
5795                        "record bundle provider layout is unavailable",
5796                    ))?;
5797                    if materializer.revalidate_bundle(&ownership, installed, layout).is_err() {
5798                        drop(catalog);
5799                        self.mark_materialization_recovery_required(ownership.id());
5800                        return Err(failure(
5801                            NodeFailureCode::BundleMaterializationFailed,
5802                            "managed session bundle revalidation failed",
5803                        ));
5804                    }
5805                    Some(bundle_launch_arguments(
5806                        layout,
5807                        ownership.bundle_root(),
5808                        ownership.provider_home(),
5809                    ).map_err(|_| failure(
5810                        NodeFailureCode::BundleBindingMismatch,
5811                        "managed session bundle launch binding is invalid",
5812                    ))?)
5813                } else {
5814                    None
5815                };
5816                if !has_environment_materialization {
5817                    let overlay = bundle_arguments.map(|extra_args| {
5818                        NativeInstanceLaunchOverlay::new(
5819                            provider.clone(),
5820                            TransportKind::Pty,
5821                            Vec::new(),
5822                            extra_args,
5823                        )
5824                        .map(PreparedNativeLaunchOverlay::Instance)
5825                        .map_err(|_| failure(
5826                            NodeFailureCode::BundleBindingMismatch,
5827                            "managed session bundle launch overlay is invalid",
5828                        ))
5829                    }).transpose()?;
5830                    return Ok(Some((ownership.id().clone(), overlay)));
5831                }
5832                let environment = match materializer.resolve_environment(&ownership, &profile) {
5833                    Ok(environment) => environment,
5834                    Err(
5835                        SessionEnvironmentMaterializeError::SecretUnavailable
5836                        | SessionEnvironmentMaterializeError::SecretDenied
5837                        | SessionEnvironmentMaterializeError::InvalidSecretValue,
5838                    ) => {
5839                        return Err(failure(
5840                            NodeFailureCode::BackendOperationFailed,
5841                            "managed session environment resolution failed",
5842                        ));
5843                    }
5844                    Err(
5845                        SessionEnvironmentMaterializeError::InvalidRoot
5846                        | SessionEnvironmentMaterializeError::OwnershipMismatch
5847                        | SessionEnvironmentMaterializeError::Filesystem(_)
5848                        | SessionEnvironmentMaterializeError::Record(_),
5849                    ) => {
5850                        self.mark_materialization_recovery_required(ownership.id());
5851                        return Err(failure(
5852                            NodeFailureCode::BackendOperationFailed,
5853                            "managed session environment resolution failed",
5854                        ));
5855                    }
5856                };
5857                let transport = match mode {
5858                    SessionMode::Pty => TransportKind::Pty,
5859                    SessionMode::Inline => TransportKind::Pipe,
5860                    SessionMode::Acp => TransportKind::Acp,
5861                };
5862                let overlay = match bundle_arguments {
5863                    Some(extra_args) => PreparedNativeLaunchOverlay::Instance(
5864                        NativeInstanceLaunchOverlay::new(
5865                            provider.clone(),
5866                            transport,
5867                            environment,
5868                            extra_args,
5869                        )
5870                        .map_err(|_| failure(
5871                            NodeFailureCode::BundleBindingMismatch,
5872                            "managed session bundle launch overlay is invalid",
5873                        ))?,
5874                    ),
5875                    None => PreparedNativeLaunchOverlay::Environment(
5876                        NativeLaunchEnvironmentOverlay::new(
5877                            provider.clone(),
5878                            transport,
5879                            environment,
5880                        )
5881                        .map_err(|_| {
5882                            failure(
5883                                NodeFailureCode::BackendOperationFailed,
5884                                "managed session environment overlay is invalid",
5885                            )
5886                        })?,
5887                    ),
5888                };
5889                Ok(Some((ownership.id().clone(), Some(overlay))))
5890            }
5891        }
5892    }
5893
5894    fn destroy_record_materialization(
5895        &self,
5896        record_id: &SessionRecordId,
5897    ) -> Result<bool, NodeFailure> {
5898        let ids = self
5899            .materializations
5900            .lock()
5901            .unwrap_or_else(|poisoned| poisoned.into_inner())
5902            .values()
5903            .filter_map(|record| {
5904                (record.owner()
5905                    == &MaterializationOwner::Record {
5906                        record_id: record_id.clone(),
5907                    })
5908                .then(|| record.id().clone())
5909            })
5910            .collect::<Vec<_>>();
5911        if ids.len() > 1 {
5912            return Err(failure(
5913                NodeFailureCode::BackendOperationFailed,
5914                "managed session has conflicting environment ownership",
5915            ));
5916        }
5917        let Some(id) = ids.first().cloned() else {
5918            return Ok(false);
5919        };
5920        let (terminal_record, mut cleanup_ownership) = {
5921            let _transaction = self
5922                .state_transaction
5923                .lock()
5924                .unwrap_or_else(|poisoned| poisoned.into_inner());
5925            let original_record = self.record(record_id)?;
5926            let mut terminal_record = original_record.clone();
5927            terminal_record.provider_session = None;
5928            terminal_record.active_session = None;
5929            terminal_record.state = ManagedSessionState::Unavailable;
5930            terminal_record.last_error = Some("environment-profile-unavailable".to_owned());
5931            terminal_record.updated_at_unix_ms = unix_time_ms();
5932            let original_ownership = self
5933                .materializations
5934                .lock()
5935                .unwrap_or_else(|poisoned| poisoned.into_inner())
5936                .get(&id)
5937                .cloned()
5938                .ok_or_else(|| {
5939                    failure(
5940                        NodeFailureCode::BackendOperationFailed,
5941                        "managed session environment ownership disappeared during cleanup",
5942                    )
5943                })?;
5944            let mut cleanup_ownership = original_ownership.clone();
5945            if cleanup_ownership.state() != MaterializationState::CleanupRequired {
5946                cleanup_ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5947                    failure(
5948                        NodeFailureCode::BackendOperationFailed,
5949                        "managed session environment cleanup state is invalid",
5950                    )
5951                })?;
5952            }
5953            self.session_records
5954                .lock()
5955                .unwrap_or_else(|poisoned| poisoned.into_inner())
5956                .records
5957                .insert(record_id.clone(), terminal_record.clone());
5958            self.materializations
5959                .lock()
5960                .unwrap_or_else(|poisoned| poisoned.into_inner())
5961                .insert(id.clone(), cleanup_ownership.clone());
5962            if let Err(error) = self.persist_state_locked() {
5963                self.session_records
5964                    .lock()
5965                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5966                    .records
5967                    .insert(record_id.clone(), original_record);
5968                self.materializations
5969                    .lock()
5970                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5971                    .insert(id.clone(), original_ownership);
5972                return Err(persistence_failure(error));
5973            }
5974            (terminal_record, cleanup_ownership)
5975        };
5976        let cleanup = self
5977            .session_environment_materializer
5978            .as_ref()
5979            .ok_or(())
5980            .and_then(|materializer| materializer.cleanup(&cleanup_ownership).map_err(|_| ()));
5981        if cleanup.is_err() {
5982            let _ = cleanup_ownership.mark_recovery_required(unix_time_ms());
5983            self.materializations
5984                .lock()
5985                .unwrap_or_else(|poisoned| poisoned.into_inner())
5986                .insert(id.clone(), cleanup_ownership);
5987            if self.persist_state().is_err() {
5988                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5989            }
5990            return Err(failure(
5991                NodeFailureCode::BackendOperationFailed,
5992                "managed session environment cleanup requires recovery",
5993            ));
5994        }
5995        let _transaction = self
5996            .state_transaction
5997            .lock()
5998            .unwrap_or_else(|poisoned| poisoned.into_inner());
5999        self.remove_record_memory(record_id);
6000        self.materializations
6001            .lock()
6002            .unwrap_or_else(|poisoned| poisoned.into_inner())
6003            .remove(&id);
6004        if let Err(error) = self.persist_state_locked() {
6005            self.session_records
6006                .lock()
6007                .unwrap_or_else(|poisoned| poisoned.into_inner())
6008                .records
6009                .insert(record_id.clone(), terminal_record);
6010            self.materializations
6011                .lock()
6012                .unwrap_or_else(|poisoned| poisoned.into_inner())
6013                .insert(id, cleanup_ownership);
6014            return Err(persistence_failure(error));
6015        }
6016        Ok(true)
6017    }
6018
6019    fn mark_materialization_recovery_required(&self, id: &MaterializationId) {
6020        let changed = {
6021            let mut materializations = self
6022                .materializations
6023                .lock()
6024                .unwrap_or_else(|poisoned| poisoned.into_inner());
6025            let Some(ownership) = materializations.get_mut(id) else {
6026                return;
6027            };
6028            ownership.mark_recovery_required(unix_time_ms()).is_ok()
6029        };
6030        if changed && self.persist_state().is_err() {
6031            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6032        }
6033    }
6034
6035    fn reconcile_materializations(&self) {
6036        let records = self
6037            .materializations
6038            .lock()
6039            .unwrap_or_else(|poisoned| poisoned.into_inner())
6040            .values()
6041            .cloned()
6042            .collect::<Vec<_>>();
6043        for mut ownership in records {
6044            let result = match (ownership.state(), ownership.owner()) {
6045                (MaterializationState::Preparing, _)
6046                | (MaterializationState::CleanupRequired, _) => {
6047                    self.cleanup_materialization(ownership.id())
6048                }
6049                (MaterializationState::Ready, MaterializationOwner::Session { .. }) => {
6050                    if ownership.mark_recovery_required(unix_time_ms()).is_ok() {
6051                        self.materializations
6052                            .lock()
6053                            .unwrap_or_else(|poisoned| poisoned.into_inner())
6054                            .insert(ownership.id().clone(), ownership.clone());
6055                        self.persist_state().map_err(persistence_failure)
6056                    } else {
6057                        Err(failure(
6058                            NodeFailureCode::BackendOperationFailed,
6059                            "session environment reconciliation failed",
6060                        ))
6061                    }
6062                }
6063                (MaterializationState::Ready, MaterializationOwner::Record { record_id }) => self
6064                    .session_environment_materializer
6065                    .as_ref()
6066                    .ok_or_else(|| {
6067                        failure(
6068                            NodeFailureCode::BackendOperationFailed,
6069                            "session environment materializer is unavailable",
6070                        )
6071                    })
6072                    .and_then(|materializer| {
6073                        materializer.revalidate(&ownership).map_err(|_| failure(
6074                            NodeFailureCode::BackendOperationFailed,
6075                            "session materialization revalidation failed",
6076                        ))?;
6077                        let record = self.record(record_id)?;
6078                        if record.environment_profile.as_ref() != ownership.environment_profile()
6079                            || record.bundle.as_ref() != ownership.bundle()
6080                            || record.context.as_ref() != ownership.context()
6081                        {
6082                            return Err(failure(
6083                                NodeFailureCode::BackendOperationFailed,
6084                                "session materialization ownership changed after restart",
6085                            ));
6086                        }
6087                        if let Some(receipt) = ownership.bundle() {
6088                            let catalog = self.bundle_catalog.read()
6089                                .unwrap_or_else(|poisoned| poisoned.into_inner());
6090                            let bundle = catalog.get(&receipt.id).ok_or_else(|| failure(
6091                                NodeFailureCode::UnknownBundle,
6092                                "session bundle is unavailable after restart",
6093                            ))?;
6094                            if bundle.receipt() != *receipt {
6095                                return Err(failure(
6096                                    NodeFailureCode::BundleBindingMismatch,
6097                                    "session bundle changed after restart",
6098                                ));
6099                            }
6100                            let layout = self.bundle_layout(
6101                                &record.provider,
6102                                record.mode,
6103                                record.environment_profile.as_ref(),
6104                                bundle,
6105                            )?;
6106                            materializer
6107                                .revalidate_bundle(&ownership, bundle, layout)
6108                                .map_err(|_| failure(
6109                                    NodeFailureCode::BundleMaterializationFailed,
6110                                    "session bundle revalidation failed",
6111                                ))?;
6112                        }
6113                        if let Some(receipt) = ownership.context() {
6114                            let pack = materializer
6115                                .revalidate_context(&ownership, receipt)
6116                                .map_err(|_| {
6117                                    failure(
6118                                        NodeFailureCode::ContextPackMaterializationFailed,
6119                                        "session context revalidation failed",
6120                                    )
6121                                })?;
6122                            self.context_catalog
6123                                .write()
6124                                .unwrap_or_else(|poisoned| poisoned.into_inner())
6125                                .insert(pack)
6126                                .map_err(|_| {
6127                                    failure(
6128                                        NodeFailureCode::ContextPackMaterializationFailed,
6129                                        "session context catalog restore failed",
6130                                    )
6131                                })?;
6132                        }
6133                        Ok(())
6134                    }),
6135                (MaterializationState::RecoveryRequired, _) => Ok(()),
6136            };
6137            if result.is_err() && ownership.state() != MaterializationState::RecoveryRequired {
6138                let _ = ownership.mark_recovery_required(unix_time_ms());
6139                self.materializations
6140                    .lock()
6141                    .unwrap_or_else(|poisoned| poisoned.into_inner())
6142                    .insert(ownership.id().clone(), ownership);
6143                if self.persist_state().is_err() {
6144                    self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6145                }
6146            }
6147        }
6148        let materialized_profiles = self
6149            .environment_materialization_profiles
6150            .read()
6151            .unwrap_or_else(|poisoned| poisoned.into_inner())
6152            .keys()
6153            .cloned()
6154            .collect::<Vec<_>>();
6155        let ownership = self
6156            .materializations
6157            .lock()
6158            .unwrap_or_else(|poisoned| poisoned.into_inner())
6159            .values()
6160            .cloned()
6161            .collect::<Vec<_>>();
6162        let changed = {
6163            let mut session_records = self
6164                .session_records
6165                .lock()
6166                .unwrap_or_else(|poisoned| poisoned.into_inner());
6167            let mut changed = false;
6168            for record in session_records.records.values_mut() {
6169                let environment_profile = record.environment_profile.as_ref();
6170                if environment_profile.is_none()
6171                    && record.bundle.is_none()
6172                    && record.context.is_none()
6173                {
6174                    continue;
6175                }
6176                if record.bundle.is_none()
6177                    && environment_profile.is_some_and(|profile| {
6178                        !materialized_profiles.contains(&profile.profile_id)
6179                    })
6180                {
6181                    continue;
6182                }
6183                let has_exact_ownership = ownership.iter().any(|candidate| {
6184                    candidate.state() == MaterializationState::Ready
6185                        && candidate.environment_profile() == environment_profile
6186                        && candidate.bundle() == record.bundle.as_ref()
6187                        && candidate.context() == record.context.as_ref()
6188                        && candidate.owner()
6189                            == &MaterializationOwner::Record {
6190                                record_id: record.record_id.clone(),
6191                            }
6192                });
6193                if !has_exact_ownership {
6194                    record.provider_session = None;
6195                    record.active_session = None;
6196                    record.state = ManagedSessionState::Unavailable;
6197                    record.last_error = Some(if record.bundle.is_some() {
6198                        "bundle-unavailable".to_owned()
6199                    } else if record.context.is_some() {
6200                        "context-unavailable".to_owned()
6201                    } else {
6202                        "environment-profile-unavailable".to_owned()
6203                    });
6204                    record.updated_at_unix_ms = unix_time_ms();
6205                    changed = true;
6206                }
6207            }
6208            changed
6209        };
6210        if changed && self.persist_state().is_err() {
6211            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6212        }
6213    }
6214
6215    fn replay_spawn_spec(
6216        &self,
6217        spec: &SpawnSpec,
6218        now: Instant,
6219    ) -> Result<Option<Result<ResolvedSpawnReceipt, NodeFailure>>, NodeFailure> {
6220        let mut cache = self
6221            .spawn_idempotency
6222            .lock()
6223            .unwrap_or_else(|poisoned| poisoned.into_inner());
6224        cache.entries.retain(|_, entry| entry.expires_at > now);
6225        if let Some(entry) = cache.entries.get(&spec.idempotency_key) {
6226            let SpawnIdempotencyValue::Standard { spec: existing, result } = &entry.value else {
6227                return Err(failure(
6228                    NodeFailureCode::SpawnIdempotencyConflict,
6229                    "spawn idempotency key was reused across spawn kinds",
6230                ));
6231            };
6232            if existing != spec {
6233                return Err(failure(
6234                    NodeFailureCode::SpawnIdempotencyConflict,
6235                    "spawn idempotency key was reused with a different specification",
6236                ));
6237            }
6238            return Ok(Some(result.clone()));
6239        }
6240        if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
6241            return Err(failure(
6242                NodeFailureCode::SpawnIdempotencyCapacity,
6243                "spawn idempotency cache reached its bounded capacity",
6244            ));
6245        }
6246        Ok(None)
6247    }
6248
6249    fn remember_spawn_spec(
6250        &self,
6251        spec: SpawnSpec,
6252        result: Result<ResolvedSpawnReceipt, NodeFailure>,
6253        accepted_at: Instant,
6254    ) {
6255        self
6256            .spawn_idempotency
6257            .lock()
6258            .unwrap_or_else(|poisoned| poisoned.into_inner())
6259            .entries
6260            .insert(
6261                spec.idempotency_key.clone(),
6262                SpawnIdempotencyEntry {
6263                    value: SpawnIdempotencyValue::Standard { spec, result },
6264                    expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
6265                },
6266            );
6267    }
6268
6269    fn claim_harness_mcp_spawn(
6270        &self,
6271        _reservation_id: &HarnessMcpReservationId,
6272        _activation_digest: &HarnessMcpActivationDigest,
6273        spec: &SpawnSpec,
6274        now: Instant,
6275    ) -> Result<(), NodeFailure> {
6276        let mut cache = self.spawn_idempotency.lock()
6277            .unwrap_or_else(|poisoned| poisoned.into_inner());
6278        cache.entries.retain(|_, entry| entry.expires_at > now);
6279        if cache.entries.contains_key(&spec.idempotency_key) {
6280            return Err(failure(
6281                NodeFailureCode::SpawnIdempotencyConflict,
6282                "spawn idempotency key was reused across spawn kinds",
6283            ));
6284        }
6285        if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
6286            return Err(failure(
6287                NodeFailureCode::SpawnIdempotencyCapacity,
6288                "spawn idempotency cache reached its bounded capacity",
6289            ));
6290        }
6291        cache.entries.insert(spec.idempotency_key.clone(), SpawnIdempotencyEntry {
6292            value: SpawnIdempotencyValue::HarnessMcp,
6293            expires_at: now + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
6294        });
6295        Ok(())
6296    }
6297
6298    fn replay_managed_spawn(
6299        &self,
6300        request: &ManagedWorktreeSpawnRequest,
6301        now: Instant,
6302    ) -> Result<Option<Result<ManagedWorktreeSpawnReceipt, NodeFailure>>, NodeFailure> {
6303        let mut cache = self.spawn_idempotency.lock()
6304            .unwrap_or_else(|poisoned| poisoned.into_inner());
6305        cache.entries.retain(|_, entry| entry.expires_at > now);
6306        if let Some(entry) = cache.entries.get(&request.spawn_spec.idempotency_key) {
6307            let SpawnIdempotencyValue::Managed { request: existing, result } = &entry.value else {
6308                return Err(failure(
6309                    NodeFailureCode::SpawnIdempotencyConflict,
6310                    "spawn idempotency key was reused across spawn kinds",
6311                ));
6312            };
6313            if existing != request {
6314                return Err(failure(
6315                    NodeFailureCode::SpawnIdempotencyConflict,
6316                    "spawn idempotency key was reused with a different managed specification",
6317                ));
6318            }
6319            return Ok(Some(result.clone()));
6320        }
6321        if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
6322            return Err(failure(
6323                NodeFailureCode::SpawnIdempotencyCapacity,
6324                "spawn idempotency cache reached its bounded capacity",
6325            ));
6326        }
6327        Ok(None)
6328    }
6329
6330    fn remember_managed_spawn(
6331        &self,
6332        request: ManagedWorktreeSpawnRequest,
6333        result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
6334        accepted_at: Instant,
6335    ) {
6336        self.spawn_idempotency.lock()
6337            .unwrap_or_else(|poisoned| poisoned.into_inner())
6338            .entries
6339            .insert(
6340                request.spawn_spec.idempotency_key.clone(),
6341                SpawnIdempotencyEntry {
6342                    value: SpawnIdempotencyValue::Managed { request, result },
6343                    expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
6344                },
6345            );
6346    }
6347
6348    fn remember_managed_spawn_attempt(
6349        &self,
6350        durable_v2_key: Option<&SpawnIdempotencyKey>,
6351        request: ManagedWorktreeSpawnRequest,
6352        result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
6353        accepted_at: Instant,
6354    ) {
6355        if durable_v2_key.is_none() {
6356            self.remember_managed_spawn(request, result, accepted_at);
6357        }
6358    }
6359
6360    fn acquire_durable_managed_spawn_v2(
6361        &self,
6362        request: &ManagedWorktreeSpawnRequestV2,
6363        request_digest: String,
6364    ) -> Result<DurableManagedSpawnReplayDecision, NodeFailure> {
6365        if self.state_path.is_none() {
6366            return Err(failure(
6367                NodeFailureCode::BackendOperationFailed,
6368                "managed worktree V2 requires durable node state",
6369            ));
6370        }
6371        let _transaction = self.state_transaction
6372            .lock()
6373            .unwrap_or_else(|poisoned| poisoned.into_inner());
6374        let key = request.spawn_spec.idempotency_key.clone();
6375        let mut previous = None;
6376        {
6377            let mut replays = self.managed_spawn_replays
6378                .lock()
6379                .unwrap_or_else(|poisoned| poisoned.into_inner());
6380            if let Some(record) = replays.get_mut(&key) {
6381                if record.request_digest != request_digest {
6382                    return Err(failure(
6383                        NodeFailureCode::SpawnIdempotencyConflict,
6384                        "managed worktree V2 idempotency key was reused with a different request",
6385                    ));
6386                }
6387                match &record.state {
6388                    ManagedWorktreeSpawnReplayStateV10::Pending {
6389                        owner_incarnation_id,
6390                        lease_id: None,
6391                    } if owner_incarnation_id != &self.incarnation_id => {
6392                        previous = Some(record.clone());
6393                        record.state = ManagedWorktreeSpawnReplayStateV10::Pending {
6394                            owner_incarnation_id: self.incarnation_id,
6395                            lease_id: None,
6396                        };
6397                        record.updated_at_unix_ms = unix_time_ms();
6398                    }
6399                    ManagedWorktreeSpawnReplayStateV10::Pending {
6400                        lease_id: None,
6401                        ..
6402                    } => {
6403                        return Err(failure(
6404                            NodeFailureCode::BackendBusy,
6405                            "managed worktree V2 reservation is already active",
6406                        ));
6407                    }
6408                    ManagedWorktreeSpawnReplayStateV10::Pending {
6409                        lease_id: Some(_),
6410                        ..
6411                    } => {
6412                        return Ok(DurableManagedSpawnReplayDecision::PendingLinked);
6413                    }
6414                    ManagedWorktreeSpawnReplayStateV10::Committed { receipt } => {
6415                        return Ok(DurableManagedSpawnReplayDecision::Committed(
6416                            receipt.clone(),
6417                        ));
6418                    }
6419                }
6420            } else if replays.len() >= MAX_MANAGED_WORKTREE_SPAWN_REPLAYS {
6421                return Err(failure(
6422                    NodeFailureCode::SpawnIdempotencyCapacity,
6423                    "durable managed worktree replay ledger reached its bounded capacity",
6424                ));
6425            } else {
6426                let now = unix_time_ms();
6427                replays.insert(key.clone(), ManagedWorktreeSpawnReplayRecordV10 {
6428                    idempotency_key: key.clone(),
6429                    request_digest,
6430                    source_workspace_id: request.spawn_spec.target.workspace_id.clone(),
6431                    profile_id: request.worktree_profile_id.clone(),
6432                    expected_profile_revision: request.expected_profile_revision.clone(),
6433                    state: ManagedWorktreeSpawnReplayStateV10::Pending {
6434                        owner_incarnation_id: self.incarnation_id,
6435                        lease_id: None,
6436                    },
6437                    created_at_unix_ms: now,
6438                    updated_at_unix_ms: now,
6439                });
6440            }
6441        }
6442        if let Err(error) = self.persist_state_locked() {
6443            let mut replays = self.managed_spawn_replays
6444                .lock()
6445                .unwrap_or_else(|poisoned| poisoned.into_inner());
6446            match previous {
6447                Some(previous) => {
6448                    replays.insert(key, previous);
6449                }
6450                None => {
6451                    replays.remove(&key);
6452                }
6453            }
6454            return Err(persistence_failure(error));
6455        }
6456        Ok(DurableManagedSpawnReplayDecision::Reserved)
6457    }
6458
6459    fn link_durable_managed_spawn_v2_lease_locked(
6460        &self,
6461        key: &SpawnIdempotencyKey,
6462        lease_id: &ManagedWorktreeLeaseId,
6463    ) -> Result<ManagedWorktreeSpawnReplayRecordV10, NodeFailure> {
6464        let mut replays = self.managed_spawn_replays
6465            .lock()
6466            .unwrap_or_else(|poisoned| poisoned.into_inner());
6467        let record = replays.get_mut(key).ok_or_else(|| {
6468            failure(
6469                NodeFailureCode::ManagedWorktreeRecoveryRequired,
6470                "managed worktree V2 reservation disappeared before allocation",
6471            )
6472        })?;
6473        let previous = record.clone();
6474        match &mut record.state {
6475            ManagedWorktreeSpawnReplayStateV10::Pending {
6476                lease_id: current,
6477                ..
6478            }
6479                if current.is_none() =>
6480            {
6481                *current = Some(lease_id.clone());
6482                record.updated_at_unix_ms = unix_time_ms();
6483                Ok(previous)
6484            }
6485            _ => Err(failure(
6486                NodeFailureCode::ManagedWorktreeRecoveryRequired,
6487                "managed worktree V2 reservation is not allocation-ready",
6488            )),
6489        }
6490    }
6491
6492    fn restore_durable_managed_spawn_v2_locked(
6493        &self,
6494        key: &SpawnIdempotencyKey,
6495        previous: ManagedWorktreeSpawnReplayRecordV10,
6496    ) {
6497        self.managed_spawn_replays
6498            .lock()
6499            .unwrap_or_else(|poisoned| poisoned.into_inner())
6500            .insert(key.clone(), previous);
6501    }
6502
6503    fn commit_durable_managed_spawn_v2(
6504        &self,
6505        request: &ManagedWorktreeSpawnRequestV2,
6506        receipt: ManagedWorktreeSpawnReceipt,
6507    ) -> Result<(), NodeFailure> {
6508        let _transaction = self.state_transaction
6509            .lock()
6510            .unwrap_or_else(|poisoned| poisoned.into_inner());
6511        let key = &request.spawn_spec.idempotency_key;
6512        let previous = {
6513            let mut replays = self.managed_spawn_replays
6514                .lock()
6515                .unwrap_or_else(|poisoned| poisoned.into_inner());
6516            let record = replays.get_mut(key).ok_or_else(|| {
6517                failure(
6518                    NodeFailureCode::ManagedWorktreeRecoveryRequired,
6519                    "managed worktree V2 reservation disappeared before commit",
6520                )
6521            })?;
6522            let previous = record.clone();
6523            match &record.state {
6524                ManagedWorktreeSpawnReplayStateV10::Pending {
6525                    lease_id: Some(lease_id),
6526                    ..
6527                } if lease_id == &receipt.lease.lease_id => {}
6528                _ => {
6529                    return Err(failure(
6530                        NodeFailureCode::ManagedWorktreeRecoveryRequired,
6531                        "managed worktree V2 reservation is not linked to the spawned lease",
6532                    ));
6533                }
6534            }
6535            record.state = ManagedWorktreeSpawnReplayStateV10::Committed { receipt };
6536            record.updated_at_unix_ms = unix_time_ms();
6537            previous
6538        };
6539        if let Err(error) = self.persist_state_locked() {
6540            self.restore_durable_managed_spawn_v2_locked(key, previous);
6541            return Err(persistence_failure(error));
6542        }
6543        Ok(())
6544    }
6545
6546    fn remove_unallocated_durable_managed_spawn_v2(
6547        &self,
6548        request: &ManagedWorktreeSpawnRequestV2,
6549    ) -> Result<(), NodeFailure> {
6550        let _transaction = self.state_transaction
6551            .lock()
6552            .unwrap_or_else(|poisoned| poisoned.into_inner());
6553        let key = &request.spawn_spec.idempotency_key;
6554        let removed = {
6555            let mut replays = self.managed_spawn_replays
6556                .lock()
6557                .unwrap_or_else(|poisoned| poisoned.into_inner());
6558            match replays.get(key).map(|record| &record.state) {
6559                Some(ManagedWorktreeSpawnReplayStateV10::Pending {
6560                    lease_id: None,
6561                    ..
6562                }) => {
6563                    replays.remove(key)
6564                }
6565                _ => None,
6566            }
6567        };
6568        let Some(removed) = removed else {
6569            return Ok(());
6570        };
6571        if let Err(error) = self.persist_state_locked() {
6572            self.managed_spawn_replays
6573                .lock()
6574                .unwrap_or_else(|poisoned| poisoned.into_inner())
6575                .insert(key.clone(), removed);
6576            return Err(persistence_failure(error));
6577        }
6578        Ok(())
6579    }
6580
6581    fn resolve_spawn_spec(&self, spec: &SpawnSpec) -> Result<ResolvedSpawnSpec, NodeFailure> {
6582        if spec.target.node_id != self.node_id {
6583            return Err(failure(
6584                NodeFailureCode::SpawnTargetMismatch,
6585                "spawn target does not match this node",
6586            ));
6587        }
6588        let defaults = self.spawn_profiles.get(&spec.profile_id).ok_or_else(|| {
6589            failure(
6590                NodeFailureCode::UnknownSpawnProfile,
6591                "spawn profile is unavailable on this node",
6592            )
6593        })?;
6594        let resolved = spec.resolve(defaults).map_err(|error| match error {
6595            SpawnSpecResolveError::ProfileRevisionMismatch { .. } => failure(
6596                NodeFailureCode::SpawnProfileRevisionMismatch,
6597                "spawn profile revision does not match the loaded profile",
6598            ),
6599            _ => failure(
6600                NodeFailureCode::InvalidRequest,
6601                "spawn specification could not be resolved",
6602            ),
6603        })?;
6604        if resolved.target.worktree_id.is_some() {
6605            self.require_worktree_service(&resolved.target.workspace_id)?;
6606        }
6607        let environment_profile = self.resolve_environment_profile(&resolved)?;
6608        self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6609        self.resolve_context(&resolved)?;
6610        Ok(resolved)
6611    }
6612
6613    async fn resolve_spawn_workspace(
6614        &self,
6615        resolved: &ResolvedSpawnSpec,
6616        deadline: Instant,
6617    ) -> Result<WorkspaceId, NodeFailure> {
6618        let Some(worktree_id) = resolved.target.worktree_id.as_ref() else {
6619            return Ok(resolved.target.workspace_id.clone());
6620        };
6621        if self.managed_worktrees.lock()
6622            .unwrap_or_else(|poisoned| poisoned.into_inner())
6623            .lease_for_workspace(worktree_id)
6624            .is_some()
6625        {
6626            return Err(failure(
6627                NodeFailureCode::ManagedWorktreeOwnershipConflict,
6628                "managed worktree targets may only be spawned through their lease request",
6629            ));
6630        }
6631        self.require_worktree_service(&resolved.target.workspace_id)?;
6632        let source_root = self.workspace_root(&resolved.target.workspace_id)?;
6633        let selected_root = self.workspace_root(worktree_id)?;
6634        let remaining = spawn_deadline_remaining(deadline)?;
6635        let worktrees = timeout(remaining, list_git_worktrees(&source_root))
6636            .await
6637            .map_err(|_| {
6638                failure(
6639                    NodeFailureCode::SpawnDeadlineExceeded,
6640                    "Git worktree selection exceeded the spawn deadline",
6641                )
6642            })?
6643            .map_err(git_worktree_failure)?;
6644        validate_selected_worktree(&source_root, &selected_root, &worktrees)?;
6645        Ok(worktree_id.clone())
6646    }
6647
6648    async fn spawn_from_spec(
6649        &self,
6650        spec: SpawnSpec,
6651    ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6652        self.spawn_from_spec_with_proxy(spec, None).await
6653    }
6654
6655    async fn spawn_from_spec_with_proxy(
6656        &self,
6657        spec: SpawnSpec,
6658        harness_mcp: Option<&PreparedHarnessMcpSpawn>,
6659    ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6660        let accepted_at = Instant::now();
6661        if harness_mcp.is_none() {
6662            if let Some(replayed) = self.replay_spawn_spec(&spec, accepted_at)? {
6663                return replayed;
6664            }
6665        }
6666        let resolved = self.resolve_spawn_spec(&spec)?;
6667        let environment_profile = self.resolve_environment_profile(&resolved)?;
6668        let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6669        let context = self.resolve_context(&resolved)?;
6670        let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6671        let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6672        let spawn_workspace_id = self.resolve_spawn_workspace(&resolved, deadline).await;
6673        let spawn_workspace_id = match spawn_workspace_id {
6674            Ok(workspace_id) => workspace_id,
6675            Err(error) => {
6676                let result = Err(error);
6677                if harness_mcp.is_none() {
6678                    self.remember_spawn_spec(spec, result.clone(), accepted_at);
6679                }
6680                return result;
6681            }
6682        };
6683        if self.managed_worktrees.lock()
6684            .unwrap_or_else(|poisoned| poisoned.into_inner())
6685            .lease_for_workspace(&spawn_workspace_id)
6686            .is_some()
6687        {
6688            let result = Err(failure(
6689                NodeFailureCode::ManagedWorktreeOwnershipConflict,
6690                "managed worktree targets may only be spawned through their lease request",
6691            ));
6692            if harness_mcp.is_none() {
6693                self.remember_spawn_spec(spec, result.clone(), accepted_at);
6694            }
6695            return result;
6696        }
6697        let result = self
6698            .spawn_session_with_deadline(
6699                spawn_workspace_id,
6700                resolved.provider.clone(),
6701                resolved.mode,
6702                resolved.terminal_size,
6703                resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
6704                environment_profile.clone(),
6705                bundle.clone(),
6706                context.clone(),
6707                None,
6708                None,
6709                SpawnRecordPolicy::Always,
6710                Some(deadline),
6711                &required_capabilities,
6712                harness_mcp,
6713                resolved.approval_level,
6714                resolved.network_allowlist.clone(),
6715                resolved.browser_profile_id.clone(),
6716            )
6717            .await
6718            .map(|(session, _runtime_policy)| {
6719                resolved.receipt_with_materialization(
6720                    self.incarnation_id,
6721                    session,
6722                    environment_profile,
6723                    bundle,
6724                    context,
6725                )
6726            });
6727        if harness_mcp.is_none() {
6728            self.remember_spawn_spec(spec, result.clone(), accepted_at);
6729        }
6730        result
6731    }
6732
6733    async fn spawn_from_spec_with_harness_mcp(
6734        &self,
6735        reservation_id: HarnessMcpReservationId,
6736        activation_digest: HarnessMcpActivationDigest,
6737        spec: SpawnSpec,
6738        deadline_unix_ms: u64,
6739    ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6740        let registry = self.harness_mcp_registry.as_ref().ok_or_else(|| {
6741            failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
6742        })?;
6743        if let Some(receipt) = registry
6744            .replay_spawn(&reservation_id, &activation_digest, &spec)
6745            .map_err(harness_mcp_failure)?
6746        {
6747            return Ok(receipt);
6748        }
6749        let resolved = self.resolve_spawn_spec(&spec)?;
6750        let prepared = registry
6751            .prepare_spawn(
6752                &reservation_id,
6753                &activation_digest,
6754                &spec,
6755                deadline_unix_ms,
6756                resolved.provider.clone(),
6757            )
6758            .map_err(harness_mcp_failure)?;
6759        self.claim_harness_mcp_spawn(
6760            &reservation_id,
6761            &activation_digest,
6762            &spec,
6763            Instant::now(),
6764        )?;
6765        let spawn = self.spawn_from_spec_with_proxy(spec, Some(&prepared)).await;
6766        let mut receipt = match spawn {
6767            Ok(receipt) => receipt,
6768            Err(error) => {
6769                let _ = registry.abort(&reservation_id, &activation_digest);
6770                return Err(error);
6771            }
6772        };
6773        let record_id = self
6774            .session_bindings
6775            .lock()
6776            .unwrap_or_else(|poisoned| poisoned.into_inner())
6777            .get(&receipt.session.session.instance_id)
6778            .and_then(|binding| binding.record_id.clone());
6779        let record_id = match record_id {
6780            Some(record_id) => record_id,
6781            None => {
6782                let rollback = self.remove_session(&receipt.session).await;
6783                let _ = registry.abort(&reservation_id, &activation_digest);
6784                if let Err(rollback_error) = rollback {
6785                    return Err(rollback_error);
6786                }
6787                return Err(failure(
6788                    NodeFailureCode::BindingMismatch,
6789                    "harness MCP spawn did not retain its exact managed record",
6790                ));
6791            }
6792        };
6793        receipt.harness_mcp_proxy = Some(ResolvedHarnessMcpProxyReceiptV1 {
6794            reservation_id: reservation_id.clone(),
6795            activation_digest: activation_digest.clone(),
6796        });
6797        if let Err(error) = registry.mark_spawned(
6798            &prepared,
6799            receipt.session.clone(),
6800            record_id,
6801            receipt.clone(),
6802        ) {
6803            let rollback = self.remove_session(&receipt.session).await;
6804            let _ = registry.abort(&reservation_id, &activation_digest);
6805            if let Err(rollback_error) = rollback {
6806                return Err(rollback_error);
6807            }
6808            return Err(harness_mcp_failure(error));
6809        }
6810        Ok(receipt)
6811    }
6812
6813    async fn spawn_managed_worktree(
6814        &self,
6815        request: ManagedWorktreeSpawnRequest,
6816    ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6817        self.spawn_managed_worktree_inner(request, None).await
6818    }
6819
6820    async fn spawn_managed_worktree_inner(
6821        &self,
6822        request: ManagedWorktreeSpawnRequest,
6823        durable_v2_key: Option<&SpawnIdempotencyKey>,
6824    ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6825        let accepted_at = Instant::now();
6826        if durable_v2_key.is_none() {
6827            if let Some(replayed) = self.replay_managed_spawn(&request, accepted_at)? {
6828                return replayed;
6829            }
6830        }
6831        let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
6832        if request.spawn_spec.target.worktree_id.is_some() {
6833            let result = Err(failure(
6834                NodeFailureCode::InvalidRequest,
6835                "managed worktree spawn must not select a caller-provided worktree",
6836            ));
6837            self.remember_managed_spawn_attempt(
6838                durable_v2_key,
6839                request,
6840                result.clone(),
6841                accepted_at,
6842            );
6843            return result;
6844        }
6845        let environment_profile = self.resolve_environment_profile(&resolved)?;
6846        let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6847        let context = self.resolve_context(&resolved)?;
6848        let source_workspace_id = resolved.target.workspace_id.clone();
6849        let profile = self.managed_profile(&source_workspace_id, &request.worktree_profile_id)?;
6850        let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6851        let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6852        let runtime_requirement = match (resolved.mode, resolved.prompt.is_some()) {
6853            (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
6854            (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
6855            (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
6856            (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
6857        };
6858        let runtime_policy = timeout(
6859            spawn_deadline_remaining(deadline)?,
6860            self.admit_provider_runtime(&resolved.provider, runtime_requirement),
6861        ).await.map_err(|_| failure(
6862            NodeFailureCode::SpawnDeadlineExceeded,
6863            "provider admission exceeded the managed spawn deadline",
6864        ))??;
6865        if required_capabilities.iter().any(|capability| !runtime_policy.admits(*capability)) {
6866            return Err(failure(
6867                NodeFailureCode::UnsupportedSpawnCapability,
6868                "provider runtime does not admit a required spawn capability",
6869            ));
6870        }
6871        self.ensure_binding_capacity()?;
6872        let source_root = self.workspace_root(&source_workspace_id)?;
6873        profile.validate_for_workspace(&source_root)
6874            .map_err(|message| failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message))?;
6875        let base_timeout_ms = u64::try_from(
6876            spawn_deadline_remaining(deadline)?.as_millis(),
6877        ).unwrap_or(u64::MAX).max(1);
6878        let base_commit = resolve_base_commit_with_timeout(
6879            &source_root,
6880            profile.base(),
6881            base_timeout_ms,
6882        ).await.map_err(|error| {
6883            if Instant::now() >= deadline || error.message.contains("timed out") {
6884                failure(
6885                    NodeFailureCode::SpawnDeadlineExceeded,
6886                    "managed worktree base resolution exceeded the spawn deadline",
6887                )
6888            } else {
6889                managed_git_worktree_failure(error)
6890            }
6891        })?;
6892
6893        let lease = {
6894            let _transaction = self.state_transaction.lock()
6895                .unwrap_or_else(|poisoned| poisoned.into_inner());
6896            let previous = self.managed_worktrees.lock()
6897                .unwrap_or_else(|poisoned| poisoned.into_inner()).clone();
6898            let lease = self.managed_worktrees.lock()
6899                .unwrap_or_else(|poisoned| poisoned.into_inner())
6900                .allocate(source_workspace_id.clone(), &profile, base_commit.clone(), unix_time_ms())
6901                .map_err(|message| failure(NodeFailureCode::BackendBusy, &message))?;
6902            let previous_replay = match durable_v2_key
6903                .map(|key| self.link_durable_managed_spawn_v2_lease_locked(key, &lease.lease_id))
6904                .transpose()
6905            {
6906                Ok(previous) => previous,
6907                Err(error) => {
6908                    *self.managed_worktrees.lock()
6909                        .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6910                    return Err(error);
6911                }
6912            };
6913            if let Err(error) = self.persist_state_locked() {
6914                *self.managed_worktrees.lock()
6915                    .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6916                if let (Some(key), Some(previous_replay)) =
6917                    (durable_v2_key, previous_replay)
6918                {
6919                    self.restore_durable_managed_spawn_v2_locked(key, previous_replay);
6920                }
6921                return Err(persistence_failure(error));
6922            }
6923            lease
6924        };
6925        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() });
6926        if let Err(message) = profile.validate_target_authority(&lease.lease_id, &lease.target_root) {
6927            self.managed_worktrees.lock()
6928                .unwrap_or_else(|poisoned| poisoned.into_inner())
6929                .remove_unmutated(&lease.lease_id);
6930            if self.persist_state().is_err() {
6931                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6932            }
6933            let result = Err(failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message));
6934            self.remember_managed_spawn_attempt(
6935                durable_v2_key,
6936                request,
6937                result.clone(),
6938                accepted_at,
6939            );
6940            return result;
6941        }
6942        let mutation_timeout_ms = match spawn_deadline_remaining(deadline) {
6943            Ok(remaining) => u64::try_from(remaining.as_millis()).unwrap_or(u64::MAX).max(1),
6944            Err(error) => {
6945                self.reconcile_failed_allocation(&lease, &source_root).await;
6946                let result = Err(error);
6947                self.remember_managed_spawn_attempt(
6948                    durable_v2_key,
6949                    request,
6950                    result.clone(),
6951                    accepted_at,
6952                );
6953                return result;
6954            }
6955        };
6956        let created = match create_git_worktree_with_timeout(
6957                &source_root,
6958                &lease.target_root,
6959                &lease.branch,
6960                Some(&base_commit),
6961                mutation_timeout_ms,
6962            ).await {
6963            Ok(created) => created,
6964            Err(error) => {
6965                self.reconcile_failed_allocation(&lease, &source_root).await;
6966                let result = Err(if Instant::now() >= deadline
6967                    || error.message.contains("timed out")
6968                    || error.message.contains("deadline")
6969                {
6970                    failure(
6971                        NodeFailureCode::SpawnDeadlineExceeded,
6972                        "managed worktree creation exceeded the spawn deadline",
6973                    )
6974                } else {
6975                    managed_git_worktree_failure(error)
6976                });
6977                self.remember_managed_spawn_attempt(
6978                    durable_v2_key,
6979                    request,
6980                    result.clone(),
6981                    accepted_at,
6982                );
6983                return result;
6984            }
6985        };
6986        if !exact_created_worktree(&lease, &created.path, created.branch.as_deref(), &created.head) {
6987            self.mark_managed_recovery_required(&lease.lease_id, ManagedWorktreeCleanupFailure::OwnershipConflict);
6988            let result = Err(failure(
6989                NodeFailureCode::ManagedWorktreeRecoveryRequired,
6990                "created worktree identity did not match its durable lease",
6991            ));
6992            self.remember_managed_spawn_attempt(
6993                durable_v2_key,
6994                request,
6995                result.clone(),
6996                accepted_at,
6997            );
6998            return result;
6999        }
7000        {
7001            let mut registry = self.managed_worktrees.lock()
7002                .unwrap_or_else(|poisoned| poisoned.into_inner());
7003            let current = registry.get_mut(&lease.lease_id)
7004                .expect("allocated managed worktree lease remains present");
7005            current.expected_head = Some(created.head.clone());
7006            current.state = ManagedWorktreeLeaseState::Ready;
7007            current.updated_at_unix_ms = unix_time_ms();
7008        }
7009        if let Err(error) = self.persist_state() {
7010            self.mark_managed_recovery_required(
7011                &lease.lease_id,
7012                ManagedWorktreeCleanupFailure::Backend,
7013            );
7014            let result = Err(persistence_failure(error));
7015            self.remember_managed_spawn_attempt(
7016                durable_v2_key,
7017                request,
7018                result.clone(),
7019                accepted_at,
7020            );
7021            return result;
7022        }
7023        let ready = self.managed_worktrees.lock()
7024            .unwrap_or_else(|poisoned| poisoned.into_inner())
7025            .get(&lease.lease_id)
7026            .expect("ready managed worktree lease remains present")
7027            .snapshot();
7028        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: ready });
7029        if let Err(error) = self.register_workspace(lease.workspace_id.clone(), created.path.clone()).await {
7030            let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
7031            let result = Err(error);
7032            self.remember_managed_spawn_attempt(
7033                durable_v2_key,
7034                request,
7035                result.clone(),
7036                accepted_at,
7037            );
7038            return result;
7039        }
7040        let spawn = self.spawn_session_with_deadline(
7041            lease.workspace_id.clone(),
7042            resolved.provider.clone(),
7043            resolved.mode,
7044            resolved.terminal_size,
7045            resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
7046            environment_profile.clone(),
7047            bundle.clone(),
7048            context.clone(),
7049            Some(lease.lease_id.clone()),
7050            Some(runtime_policy),
7051            SpawnRecordPolicy::Always,
7052            Some(deadline),
7053            &required_capabilities,
7054            None,
7055            resolved.approval_level,
7056            resolved.network_allowlist.clone(),
7057            resolved.browser_profile_id.clone(),
7058        ).await;
7059        let (session, effective_runtime_policy) = match spawn {
7060            Ok(spawned) => spawned,
7061            Err(error) => {
7062                if self
7063                    .cleanup_managed_worktree(&lease.lease_id, true)
7064                    .await
7065                    .is_err()
7066                {
7067                    self.mark_managed_recovery_required(
7068                        &lease.lease_id,
7069                        ManagedWorktreeCleanupFailure::Backend,
7070                    );
7071                }
7072                let result = Err(error);
7073                self.remember_managed_spawn_attempt(
7074                    durable_v2_key,
7075                    request,
7076                    result.clone(),
7077                    accepted_at,
7078                );
7079                return result;
7080            }
7081        };
7082        let (record_id, raw_inventory_record_id) = {
7083            let mut bindings = self.session_bindings.lock()
7084                .unwrap_or_else(|poisoned| poisoned.into_inner());
7085            let binding = bindings.get_mut(&session.session.instance_id)
7086                .expect("successful spawn retains its session binding");
7087            binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
7088            managed_spawn_record_ownership(binding, effective_runtime_policy)
7089        };
7090        let bound = self.managed_worktrees.lock()
7091            .unwrap_or_else(|poisoned| poisoned.into_inner())
7092            .bind_session(
7093                &lease.lease_id,
7094                ManagedWorktreeSessionHolder {
7095                    incarnation_id: self.incarnation_id,
7096                    instance_id: session.session.instance_id,
7097                    generation: session.session.generation,
7098                },
7099                record_id,
7100                unix_time_ms(),
7101            )
7102            .map_err(|message| failure(NodeFailureCode::ManagedWorktreeRecoveryRequired, &message));
7103        let snapshot = match bound {
7104            Ok(snapshot) => snapshot,
7105            Err(error) => {
7106                if raw_inventory_record_id
7107                    .as_ref()
7108                    .is_some_and(|record_id| self.discard_record(record_id).is_err())
7109                {
7110                    self.mark_managed_recovery_required(
7111                        &lease.lease_id,
7112                        ManagedWorktreeCleanupFailure::Backend,
7113                    );
7114                }
7115                let _ = self.remove_session(&session).await;
7116                self.mark_managed_recovery_required(
7117                    &lease.lease_id,
7118                    ManagedWorktreeCleanupFailure::Backend,
7119                );
7120                let result = Err(error);
7121                self.remember_managed_spawn_attempt(
7122                    durable_v2_key,
7123                    request,
7124                    result.clone(),
7125                    accepted_at,
7126                );
7127                return result;
7128            }
7129        };
7130        if let Err(error) = self.persist_state() {
7131            if raw_inventory_record_id
7132                .as_ref()
7133                .is_some_and(|record_id| self.discard_record(record_id).is_err())
7134            {
7135                self.mark_managed_recovery_required(
7136                    &lease.lease_id,
7137                    ManagedWorktreeCleanupFailure::Backend,
7138                );
7139            }
7140            let _ = self.remove_session(&session).await;
7141            if self
7142                .cleanup_managed_worktree(&lease.lease_id, true)
7143                .await
7144                .is_err()
7145            {
7146                self.mark_managed_recovery_required(
7147                    &lease.lease_id,
7148                    ManagedWorktreeCleanupFailure::Backend,
7149                );
7150            }
7151            let result = Err(persistence_failure(error));
7152            self.remember_managed_spawn_attempt(
7153                durable_v2_key,
7154                request,
7155                result.clone(),
7156                accepted_at,
7157            );
7158            return result;
7159        }
7160        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
7161        let receipt = managed_spawn_receipt(
7162            &resolved,
7163            self.incarnation_id,
7164            session,
7165            snapshot,
7166            environment_profile,
7167            bundle,
7168            context,
7169        );
7170        let result = Ok(receipt);
7171        self.remember_managed_spawn_attempt(
7172            durable_v2_key,
7173            request,
7174            result.clone(),
7175            accepted_at,
7176        );
7177        result
7178    }
7179
7180    async fn spawn_managed_worktree_v2(
7181        &self,
7182        request: ManagedWorktreeSpawnRequestV2,
7183    ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
7184        if request.spawn_spec.target.node_id != self.node_id {
7185            return Err(failure(
7186                NodeFailureCode::SpawnTargetMismatch,
7187                "spawn target does not match this node",
7188            ));
7189        }
7190        if request.spawn_spec.target.worktree_id.is_some() {
7191            return Err(failure(
7192                NodeFailureCode::InvalidRequest,
7193                "managed worktree spawn must not select a caller-provided worktree",
7194            ));
7195        }
7196        let request_digest = managed_spawn_request_digest_v2(&request)?;
7197        let replay = self.acquire_durable_managed_spawn_v2(&request, request_digest)?;
7198        match replay {
7199            DurableManagedSpawnReplayDecision::Committed(receipt) => return Ok(receipt),
7200            DurableManagedSpawnReplayDecision::PendingLinked => {
7201                return Err(failure(
7202                    NodeFailureCode::ManagedWorktreeRecoveryRequired,
7203                    "managed worktree V2 reservation has an incomplete durable allocation",
7204                ));
7205            }
7206            DurableManagedSpawnReplayDecision::Reserved => {}
7207        }
7208        let validated = (|| {
7209            let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
7210            let profile = self.managed_profile(
7211                &resolved.target.workspace_id,
7212                &request.worktree_profile_id,
7213            )?;
7214            if profile.revision() != &request.expected_profile_revision {
7215                return Err(failure(
7216                    NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
7217                    "managed worktree profile revision does not match the loaded profile",
7218                ));
7219            }
7220            Ok(())
7221        })();
7222        match validated {
7223            Ok(()) => {}
7224            Err(error) => {
7225                self.remove_unallocated_durable_managed_spawn_v2(&request)?;
7226                return Err(error);
7227            }
7228        }
7229        let legacy = ManagedWorktreeSpawnRequest {
7230            spawn_spec: request.spawn_spec.clone(),
7231            worktree_profile_id: request.worktree_profile_id.clone(),
7232        };
7233        match self
7234            .spawn_managed_worktree_inner(
7235                legacy,
7236                Some(&request.spawn_spec.idempotency_key),
7237            )
7238            .await
7239        {
7240            Ok(receipt) => {
7241                self.commit_durable_managed_spawn_v2(&request, receipt.clone())?;
7242                Ok(receipt)
7243            }
7244            Err(error) => {
7245                self.remove_unallocated_durable_managed_spawn_v2(&request)?;
7246                Err(error)
7247            }
7248        }
7249    }
7250
7251    fn managed_profile(
7252        &self,
7253        workspace_id: &WorkspaceId,
7254        profile_id: &WorktreeProfileId,
7255    ) -> Result<ManagedWorktreeProfile, NodeFailure> {
7256        if self.worktree_service_modes.get(workspace_id).copied()
7257            != Some(WorktreeServiceMode::Managed)
7258        {
7259            return Err(failure(
7260                NodeFailureCode::UnsupportedSpawnCapability,
7261                "workspace does not enable managed worktrees",
7262            ));
7263        }
7264        self.managed_worktree_profiles.get(workspace_id)
7265            .and_then(|profiles| profiles.get(profile_id))
7266            .cloned()
7267            .ok_or_else(|| failure(
7268                NodeFailureCode::InvalidRequest,
7269                "managed worktree profile is unavailable for this workspace",
7270            ))
7271    }
7272
7273    async fn reconcile_failed_allocation(
7274        &self,
7275        lease: &ManagedWorktreeLeaseRecord,
7276        source_root: &str,
7277    ) {
7278        match list_git_worktrees(source_root).await {
7279            Ok(worktrees) => {
7280                match worktrees.iter().find(|item| worktree_paths_equal(&item.path, &lease.target_root)) {
7281                    None => {
7282                        match std::fs::symlink_metadata(&lease.target_root) {
7283                            Err(error) if error.kind() == io::ErrorKind::NotFound => {
7284                                self.managed_worktrees.lock()
7285                                    .unwrap_or_else(|poisoned| poisoned.into_inner())
7286                                    .remove_unmutated(&lease.lease_id);
7287                                let _ = self.persist_state();
7288                            }
7289                            _ => self.mark_managed_recovery_required(
7290                                &lease.lease_id,
7291                                ManagedWorktreeCleanupFailure::OwnershipConflict,
7292                            ),
7293                        }
7294                    }
7295                    Some(item) if exact_owned_worktree(
7296                        lease,
7297                        &item.path,
7298                        item.branch.as_deref(),
7299                        &item.head,
7300                    ) => {
7301                        let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
7302                    }
7303                    Some(_) => self.mark_managed_recovery_required(
7304                        &lease.lease_id,
7305                        ManagedWorktreeCleanupFailure::OwnershipConflict,
7306                    ),
7307                }
7308            }
7309            Err(_) => self.mark_managed_recovery_required(
7310                &lease.lease_id,
7311                ManagedWorktreeCleanupFailure::Backend,
7312            ),
7313        }
7314    }
7315
7316    async fn reconcile_managed_worktrees(&self) {
7317        let leases = self.managed_worktrees.lock()
7318            .unwrap_or_else(|poisoned| poisoned.into_inner())
7319            .active_records()
7320            .cloned()
7321            .collect::<Vec<_>>();
7322        for lease in leases {
7323            let profile = match self.managed_profile(&lease.source_workspace_id, &lease.profile_id) {
7324                Ok(profile) if profile.revision() == &lease.profile_revision => profile,
7325                _ => {
7326                    self.mark_managed_recovery_required(
7327                        &lease.lease_id,
7328                        ManagedWorktreeCleanupFailure::OwnershipConflict,
7329                    );
7330                    continue;
7331                }
7332            };
7333            if profile.validate_target_authority(&lease.lease_id, &lease.target_root).is_err() {
7334                self.mark_managed_recovery_required(
7335                    &lease.lease_id,
7336                    ManagedWorktreeCleanupFailure::OwnershipConflict,
7337                );
7338                continue;
7339            }
7340            let source_root = match self.workspace_root(&lease.source_workspace_id) {
7341                Ok(root) => root,
7342                Err(_) => {
7343                    self.mark_managed_recovery_required(
7344                        &lease.lease_id,
7345                        ManagedWorktreeCleanupFailure::OwnershipConflict,
7346                    );
7347                    continue;
7348                }
7349            };
7350            let worktrees = match list_git_worktrees(&source_root).await {
7351                Ok(worktrees) => worktrees,
7352                Err(_) => {
7353                    self.mark_managed_recovery_required(
7354                        &lease.lease_id,
7355                        ManagedWorktreeCleanupFailure::Backend,
7356                    );
7357                    continue;
7358                }
7359            };
7360            let listed = worktrees.iter()
7361                .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
7362            let Some(listed) = listed else {
7363                match std::fs::symlink_metadata(&lease.target_root) {
7364                    Err(error) if error.kind() == io::ErrorKind::NotFound
7365                        && lease.state == ManagedWorktreeLeaseState::Allocating
7366                        && !lease.has_holders() => {
7367                            let removed = self.managed_worktrees.lock()
7368                                .unwrap_or_else(|poisoned| poisoned.into_inner())
7369                                .tombstone(&lease.lease_id, unix_time_ms());
7370                            if self.persist_state().is_ok() && removed.is_some() {
7371                                self.publish(NodeEvent::ManagedWorktreeRemoved {
7372                                    lease_id: lease.lease_id.clone(),
7373                                });
7374                            }
7375                        }
7376                    _ => self.mark_managed_recovery_required(
7377                        &lease.lease_id,
7378                        ManagedWorktreeCleanupFailure::OwnershipConflict,
7379                    ),
7380                }
7381                continue;
7382            };
7383            if listed.is_main || listed.is_bare || !exact_owned_worktree(
7384                &lease,
7385                &listed.path,
7386                listed.branch.as_deref(),
7387                &listed.head,
7388            ) {
7389                self.mark_managed_recovery_required(
7390                    &lease.lease_id,
7391                    ManagedWorktreeCleanupFailure::OwnershipConflict,
7392                );
7393                continue;
7394            }
7395            if !self.workspaces.read()
7396                .unwrap_or_else(|poisoned| poisoned.into_inner())
7397                .contains_key(&lease.workspace_id)
7398            {
7399                if self.register_workspace(lease.workspace_id.clone(), listed.path.clone()).await.is_err() {
7400                    self.mark_managed_recovery_required(
7401                        &lease.lease_id,
7402                        ManagedWorktreeCleanupFailure::Backend,
7403                    );
7404                    continue;
7405                }
7406            }
7407            let snapshot = {
7408                let mut registry = self.managed_worktrees.lock()
7409                    .unwrap_or_else(|poisoned| poisoned.into_inner());
7410                let current = registry.get_mut(&lease.lease_id)
7411                    .expect("reconciled managed lease remains present");
7412                current.expected_head = Some(listed.head.clone());
7413                current.cleanup_failure = None;
7414                current.state = if current.has_holders() {
7415                    ManagedWorktreeLeaseState::InUse
7416                } else if current.retention == ManagedWorktreeRetention::Retain {
7417                    ManagedWorktreeLeaseState::Retained
7418                } else {
7419                    ManagedWorktreeLeaseState::Ready
7420                };
7421                current.updated_at_unix_ms = unix_time_ms();
7422                current.snapshot()
7423            };
7424            if self.persist_state().is_err() {
7425                self.mark_managed_recovery_required(
7426                    &lease.lease_id,
7427                    ManagedWorktreeCleanupFailure::Backend,
7428                );
7429                continue;
7430            }
7431            self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
7432            if snapshot.state == ManagedWorktreeLeaseState::Ready {
7433                let _ = self.cleanup_managed_worktree(&lease.lease_id, false).await;
7434            }
7435        }
7436    }
7437
7438    /// Crash-recovery startup sweep: durably capture the pack for every
7439    /// `Live` managed record whose most recent session has not yet produced
7440    /// one (recovers a session whose `Exited{0}` event was observed but the
7441    /// Node crashed before the export finished). The reactive fast path for
7442    /// a single just-exited record does not go through this — see
7443    /// `drive_runtime_until_shutdown`, which resolves and spawns directly so
7444    /// it can capture the exact `SessionAddress` before `publish_control`
7445    /// clears it, rather than re-deriving it from (by then likely already
7446    /// `Dormant`) record state the way a rescan naturally would.
7447    ///
7448    /// Returns as soon as every eligible record's export has been *spawned*,
7449    /// not completed — each one runs on its own detached task (see
7450    /// `reconcile_context_pack_export_for_record`'s own doc comment for why:
7451    /// `NativeRuntime::tick()` has not even started its own loop yet at the
7452    /// point this is called from `NodeServer::run()`, so nothing could ever
7453    /// drain the history round trip if this awaited it inline here either).
7454    /// Best-effort throughout: never panics, no unwrap/expect; every failure
7455    /// is a silent skip for THIS record — an unusable source or a racing
7456    /// record is not surfaced to any caller.
7457    async fn reconcile_context_pack_exports(self: &Arc<Self>) {
7458        let candidates: Vec<(SessionRecordId, SessionAddress)> = self
7459            .session_records
7460            .lock()
7461            .unwrap_or_else(|poisoned| poisoned.into_inner())
7462            .records
7463            .values()
7464            .filter(|record| {
7465                record.state == ManagedSessionState::Live
7466                    && record.exported_context.is_none()
7467                    && record.provider_session.is_some()
7468            })
7469            .filter_map(|record| {
7470                record
7471                    .active_session
7472                    .clone()
7473                    .map(|session| (record.record_id.clone(), session))
7474            })
7475            .collect();
7476        for (record_id, session) in candidates {
7477            let shared = Arc::clone(self);
7478            tokio::spawn(async move {
7479                shared
7480                    .reconcile_context_pack_export_for_record(&record_id, session)
7481                    .await;
7482            });
7483        }
7484    }
7485
7486    /// Runs on its own detached task (`tokio::spawn`, see both call sites),
7487    /// never inline on `drive_runtime_until_shutdown`'s own task: the history
7488    /// round trip this performs (`discover_history`/`load_history`, via
7489    /// `export_context_pack_for_session_record`) can only ever settle once
7490    /// `NativeRuntime::tick()` runs again to drain the authority worker's
7491    /// completion — and `tick()` is exclusively owned by, and only ever
7492    /// called from, that same drive-loop task. Awaiting this inline there
7493    /// would starve `tick()` and this call could never observe its own
7494    /// command complete.
7495    ///
7496    /// Because this now genuinely races the drive loop's own
7497    /// `publish_control` (which downgrades this exact record from `Live` to
7498    /// `Dormant`, clearing `active_session`, for the very `Exited` event that
7499    /// triggered this reconcile), the record legitimately may already be
7500    /// `Dormant` by the time this reads it, or may transition mid-flight —
7501    /// `allow_clean_detachment: true` on the downstream export call is what
7502    /// tolerates exactly that transition (and only that transition; see
7503    /// `session_record_export_target_matches`).
7504    async fn reconcile_context_pack_export_for_record(
7505        &self,
7506        record_id: &SessionRecordId,
7507        session: SessionAddress,
7508    ) {
7509        let Ok(record) = self.record(record_id) else {
7510            return;
7511        };
7512        if !matches!(
7513            record.state,
7514            ManagedSessionState::Live | ManagedSessionState::Dormant
7515        ) || record.exported_context.is_some()
7516            || record.provider_session.is_none()
7517        {
7518            return;
7519        }
7520        // Pack bytes are already durably committed inside this call, via
7521        // commit_context_pack_for_session_record's own durable
7522        // write-through (context_pack_store) — no separate step here.
7523        // Unclean exits (non-zero/forced/no loadable history) are already
7524        // rejected by context_pack_source_status_is_usable inside
7525        // materialize_context_pack: this simply returns Err and is skipped.
7526        let Ok(receipt) = self
7527            .export_context_pack_for_session_record(&record.record_id, &session, true)
7528            .await
7529        else {
7530            return;
7531        };
7532        self.set_exported_context(&record.record_id, receipt);
7533    }
7534
7535    /// Records the record's most recent clean-exit pack. No-op (not an
7536    /// error) if the record disappeared, or if it already carries an
7537    /// `exported_context` — first-write-wins, matching the harness-side
7538    /// idempotent reconciliation that consumes this field.
7539    fn set_exported_context(&self, record_id: &SessionRecordId, receipt: ResolvedContextPackReceipt) {
7540        let transaction = self
7541            .state_transaction
7542            .lock()
7543            .unwrap_or_else(|poisoned| poisoned.into_inner());
7544        let (previous, updated) = {
7545            let mut records = self
7546                .session_records
7547                .lock()
7548                .unwrap_or_else(|poisoned| poisoned.into_inner());
7549            let Some(record) = records.records.get_mut(record_id) else {
7550                return;
7551            };
7552            if record.exported_context.is_some() {
7553                return;
7554            }
7555            let previous = record.clone();
7556            record.exported_context = Some(receipt);
7557            record.updated_at_unix_ms = unix_time_ms().max(record.updated_at_unix_ms);
7558            (previous, record.clone())
7559        };
7560        if self.persist_state_locked().is_err() {
7561            self.session_records
7562                .lock()
7563                .unwrap_or_else(|poisoned| poisoned.into_inner())
7564                .records
7565                .insert(record_id.clone(), previous);
7566            return;
7567        }
7568        drop(transaction);
7569        self.publish_record(updated);
7570    }
7571
7572    /// Resolves the managed record and its exact `SessionAddress` currently
7573    /// bound to `instance_id` at exactly `generation`, used by the reactive
7574    /// `Exited{0}` hook in `drive_runtime_until_shutdown` to target a single
7575    /// record without a full rescan. Mirrors the lookup already inlined in
7576    /// `publish_control`. Captured synchronously, before `publish_control`
7577    /// for the same event clears the record's own `active_session` — the
7578    /// caller passes the resolved address through to a detached
7579    /// reconciliation task rather than re-deriving it from the record later,
7580    /// after that race has necessarily already happened.
7581    fn managed_record_export_target_for_instance(
7582        &self,
7583        instance_id: AgentInstanceId,
7584        generation: SessionGeneration,
7585    ) -> Option<(SessionRecordId, SessionAddress)> {
7586        let bindings = self
7587            .session_bindings
7588            .lock()
7589            .unwrap_or_else(|poisoned| poisoned.into_inner());
7590        let binding = bindings.get(&instance_id).filter(|binding| binding.generation == generation)?;
7591        let record_id = binding.record_id.clone()?;
7592        Some((
7593            record_id,
7594            SessionAddress {
7595                workspace_id: binding.workspace_id.clone(),
7596                session: SessionKey { instance_id, generation },
7597            },
7598        ))
7599    }
7600
7601    fn mark_managed_recovery_required(
7602        &self,
7603        lease_id: &ManagedWorktreeLeaseId,
7604        failure_kind: ManagedWorktreeCleanupFailure,
7605    ) {
7606        let snapshot = {
7607            let mut registry = self.managed_worktrees.lock()
7608                .unwrap_or_else(|poisoned| poisoned.into_inner());
7609            let Some(lease) = registry.get_mut(lease_id) else { return };
7610            lease.state = ManagedWorktreeLeaseState::RecoveryRequired;
7611            lease.cleanup_failure = Some(failure_kind);
7612            lease.updated_at_unix_ms = unix_time_ms();
7613            lease.snapshot()
7614        };
7615        let _ = self.persist_state();
7616        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
7617    }
7618
7619    fn set_persistence_error(&self, error: Option<String>) {
7620        *self
7621            .persistence_error
7622            .write()
7623            .unwrap_or_else(|poisoned| poisoned.into_inner()) = error
7624            .map(|message| session_registry::sanitized_persistence_summary(&message));
7625    }
7626
7627    fn persist_state(&self) -> io::Result<()> {
7628        let _transaction = self
7629            .state_transaction
7630            .lock()
7631            .unwrap_or_else(|poisoned| poisoned.into_inner());
7632        self.persist_state_locked()
7633    }
7634
7635    fn persist_state_locked(&self) -> io::Result<()> {
7636        let workspaces = self
7637            .workspaces
7638            .read()
7639            .unwrap_or_else(|poisoned| poisoned.into_inner())
7640            .clone();
7641        let records = self
7642            .session_records
7643            .lock()
7644            .unwrap_or_else(|poisoned| poisoned.into_inner())
7645            .records
7646            .values()
7647            .cloned()
7648            .collect::<Vec<_>>();
7649        let mut managed = self.managed_worktrees
7650            .lock()
7651            .unwrap_or_else(|poisoned| poisoned.into_inner());
7652        managed.reattach_record_holders(
7653            &managed_worktree_record_holders(&records),
7654            unix_time_ms(),
7655        );
7656        let materializations = self
7657            .materializations
7658            .lock()
7659            .unwrap_or_else(|poisoned| poisoned.into_inner())
7660            .values()
7661            .cloned()
7662            .collect::<Vec<_>>();
7663        let managed_spawn_replays = self.managed_spawn_replays
7664            .lock()
7665            .unwrap_or_else(|poisoned| poisoned.into_inner())
7666            .values()
7667            .cloned()
7668            .collect::<Vec<_>>();
7669        let result = session_registry::save_v10(
7670            self.state_path.as_deref(),
7671            &self.node_id,
7672            &workspaces,
7673            &records,
7674            &managed.records(),
7675            &managed.tombstones(),
7676            &materializations,
7677            &managed_spawn_replays,
7678        );
7679        match &result {
7680            Ok(warning) => self.set_persistence_error(warning.clone()),
7681            Err(_) => self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned())),
7682        }
7683        result.map(|_| ())
7684    }
7685
7686    async fn begin_shutdown(&self) -> Result<(), NodeFailure> {
7687        let _mutation_guard = self.mutation_gate.lock().await;
7688        self.begin_shutdown_locked().await
7689    }
7690
7691    async fn begin_shutdown_locked(&self) -> Result<(), NodeFailure> {
7692        if self.shutdown.swap(true, Ordering::AcqRel) {
7693            return Ok(());
7694        }
7695        SessionLifecycleHook::on_shutdown(self);
7696        let deadline = Instant::now() + Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
7697        let mut first_error = None;
7698        for session in &self.handle.snapshot().sessions {
7699            let remaining = deadline.saturating_duration_since(Instant::now());
7700            if remaining.is_zero() {
7701                first_error.get_or_insert_with(|| {
7702                    failure(NodeFailureCode::BackendBusy, "stop-all dispatch exceeded its bounded deadline")
7703                });
7704                break;
7705            }
7706            if let Err(error) = self
7707                .dispatch_bounded(
7708                    ControlCommand::Stop {
7709                        instance_id: session.instance_id,
7710                        force: true,
7711                    },
7712                    remaining,
7713                )
7714                .await
7715            {
7716                first_error.get_or_insert(error);
7717            }
7718        }
7719        self.shutdown_notify.notify_waiters();
7720        match first_error {
7721            Some(error) => Err(error),
7722            None => Ok(()),
7723        }
7724    }
7725
7726    fn ensure_binding_capacity(&self) -> Result<(), NodeFailure> {
7727        let bindings = self
7728            .session_bindings
7729            .lock()
7730            .unwrap_or_else(|poisoned| poisoned.into_inner());
7731        if bindings.len() >= CONTROL_SESSIONS_MAX {
7732            Err(failure(
7733                NodeFailureCode::BackendBusy,
7734                "node session-binding capacity is full; remove an existing session first",
7735            ))
7736        } else {
7737            Ok(())
7738        }
7739    }
7740
7741    /// One-owner workspace/env collision guard: a live session on
7742    /// `workspace_id` with a different `environment_profile` (option
7743    /// equality on the resolved receipt, including Some vs None) blocks
7744    /// another spawn on that same cwd. Same profile may still share the
7745    /// workspace; distinct profiles need distinct workspace ids (managed
7746    /// worktree).
7747    fn ensure_workspace_environment_profile_compatible(
7748        &self,
7749        workspace_id: &WorkspaceId,
7750        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
7751    ) -> Result<(), NodeFailure> {
7752        let bindings = self
7753            .session_bindings
7754            .lock()
7755            .unwrap_or_else(|poisoned| poisoned.into_inner());
7756        for binding in bindings.values() {
7757            if &binding.workspace_id != workspace_id {
7758                continue;
7759            }
7760            if binding.environment_profile.as_ref() != environment_profile {
7761                return Err(failure(
7762                    NodeFailureCode::WorkspaceBusy,
7763                    "workspace already has a live session with a different environment profile; use a managed worktree for concurrent distinct ProviderHome/env bindings on one repo",
7764                ));
7765            }
7766        }
7767        Ok(())
7768    }
7769
7770    #[cfg(test)]
7771    fn bind_session(
7772        &self,
7773        instance_id: AgentInstanceId,
7774        workspace_id: WorkspaceId,
7775        generation: SessionGeneration,
7776    ) {
7777        self.bind_session_with_policy(
7778            &SessionAddress {
7779                workspace_id,
7780                session: SessionKey { instance_id, generation },
7781            },
7782            ProviderRuntimePolicy::raw_pty(),
7783            None,
7784        );
7785    }
7786
7787    #[cfg(test)]
7788    fn bind_session_with_policy(
7789        &self,
7790        address: &SessionAddress,
7791        runtime_policy: ProviderRuntimePolicy,
7792        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7793    ) {
7794        self.bind_session_with_materialization(
7795            address,
7796            runtime_policy,
7797            environment_profile,
7798            None,
7799            None,
7800            None,
7801        );
7802    }
7803
7804    fn bind_session_with_materialization(
7805        &self,
7806        address: &SessionAddress,
7807        runtime_policy: ProviderRuntimePolicy,
7808        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7809        bundle: Option<ResolvedBundleReceipt>,
7810        context: Option<ResolvedContextPackReceipt>,
7811        materialization_id: Option<MaterializationId>,
7812    ) {
7813        let mut bindings = self
7814            .session_bindings
7815            .lock()
7816            .unwrap_or_else(|poisoned| poisoned.into_inner());
7817        debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7818        debug_assert!(!bindings.contains_key(&address.session.instance_id));
7819        bindings.insert(address.session.instance_id, SessionBinding {
7820            workspace_id: address.workspace_id.clone(),
7821            generation: address.session.generation,
7822            runtime_policy,
7823            pending_resume: None,
7824            record_id: None,
7825            managed_worktree_lease_id: None,
7826            environment_profile,
7827            bundle,
7828            context,
7829            materialization_id,
7830        });
7831    }
7832
7833    #[cfg(test)]
7834    fn bind_managed_session(
7835        &self,
7836        address: &SessionAddress,
7837        record_id: SessionRecordId,
7838        runtime_policy: ProviderRuntimePolicy,
7839        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7840    ) {
7841        self.bind_managed_session_with_materialization(
7842            address,
7843            record_id,
7844            runtime_policy,
7845            environment_profile,
7846            None,
7847            None,
7848            None,
7849        );
7850    }
7851
7852    fn bind_managed_session_with_materialization(
7853        &self,
7854        address: &SessionAddress,
7855        record_id: SessionRecordId,
7856        runtime_policy: ProviderRuntimePolicy,
7857        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7858        bundle: Option<ResolvedBundleReceipt>,
7859        context: Option<ResolvedContextPackReceipt>,
7860        materialization_id: Option<MaterializationId>,
7861    ) {
7862        let mut bindings = self
7863            .session_bindings
7864            .lock()
7865            .unwrap_or_else(|poisoned| poisoned.into_inner());
7866        debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7867        debug_assert!(!bindings.contains_key(&address.session.instance_id));
7868        bindings.insert(
7869            address.session.instance_id,
7870            SessionBinding {
7871                workspace_id: address.workspace_id.clone(),
7872                generation: address.session.generation,
7873                runtime_policy,
7874                pending_resume: None,
7875                record_id: Some(record_id),
7876                managed_worktree_lease_id: None,
7877                environment_profile,
7878                bundle,
7879                context,
7880                materialization_id,
7881            },
7882        );
7883    }
7884
7885    #[cfg(test)]
7886    fn bind_spawn_session(
7887        &self,
7888        address: &SessionAddress,
7889        provider: AgentId,
7890        mode: SessionMode,
7891        runtime_policy: ProviderRuntimePolicy,
7892        record_policy: SpawnRecordPolicy,
7893        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7894    ) -> Result<Option<SessionRecordId>, NodeFailure> {
7895        self.bind_spawn_session_with_materialization(
7896            address,
7897            provider,
7898            mode,
7899            runtime_policy,
7900            record_policy,
7901            environment_profile,
7902            None,
7903            None,
7904            None,
7905        )
7906    }
7907
7908    fn bind_spawn_session_with_materialization(
7909        &self,
7910        address: &SessionAddress,
7911        provider: AgentId,
7912        mode: SessionMode,
7913        runtime_policy: ProviderRuntimePolicy,
7914        record_policy: SpawnRecordPolicy,
7915        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7916        bundle: Option<ResolvedBundleReceipt>,
7917        context: Option<ResolvedContextPackReceipt>,
7918        materialization_id: Option<MaterializationId>,
7919    ) -> Result<Option<SessionRecordId>, NodeFailure> {
7920        if record_policy == SpawnRecordPolicy::ProviderIdentityOnly
7921            && !runtime_policy.provider_session_identity
7922        {
7923            self.bind_session_with_materialization(
7924                address,
7925                runtime_policy,
7926                environment_profile,
7927                bundle,
7928                context,
7929                materialization_id,
7930            );
7931            return Ok(None);
7932        }
7933        let record_owns_lifecycle = runtime_policy.provider_session_identity;
7934        let record = self.new_record(
7935            address,
7936            provider,
7937            mode,
7938            active_record_state(runtime_policy.provider_session_identity, false),
7939            None,
7940            record_owns_lifecycle.then(|| environment_profile.clone()).flatten(),
7941            record_owns_lifecycle.then(|| bundle.clone()).flatten(),
7942            record_owns_lifecycle.then(|| context.clone()).flatten(),
7943        )?;
7944        let record_id = record.record_id.clone();
7945        let transaction = self
7946            .state_transaction
7947            .lock()
7948            .unwrap_or_else(|poisoned| poisoned.into_inner());
7949        self.insert_record(record.clone())?;
7950        let previous_materialization = if record_owns_lifecycle {
7951            if let Some(materialization_id) = materialization_id.as_ref() {
7952                let mut materializations = self
7953                    .materializations
7954                    .lock()
7955                    .unwrap_or_else(|poisoned| poisoned.into_inner());
7956                let Some(ownership) = materializations.get_mut(materialization_id) else {
7957                    drop(materializations);
7958                    self.remove_record_memory(&record_id);
7959                    return Err(failure(
7960                        NodeFailureCode::BackendOperationFailed,
7961                        "session environment ownership disappeared before binding",
7962                    ));
7963                };
7964                let previous = ownership.clone();
7965                if ownership
7966                    .transfer_to_record(record_id.clone(), unix_time_ms())
7967                    .is_err()
7968                {
7969                    drop(materializations);
7970                    self.remove_record_memory(&record_id);
7971                    return Err(failure(
7972                        NodeFailureCode::BackendOperationFailed,
7973                        "session environment ownership transfer failed",
7974                    ));
7975                }
7976                Some(previous)
7977            } else {
7978                None
7979            }
7980        } else {
7981            None
7982        };
7983        self.bind_managed_session_with_materialization(
7984            address,
7985            record_id.clone(),
7986            runtime_policy,
7987            environment_profile,
7988            bundle,
7989            context,
7990            materialization_id.clone(),
7991        );
7992        if let Err(error) = self.persist_state_locked() {
7993            self.remove_binding(address);
7994            self.remove_record_memory(&record_id);
7995            if let (Some(materialization_id), Some(previous)) =
7996                (materialization_id.as_ref(), previous_materialization)
7997            {
7998                self.materializations
7999                    .lock()
8000                    .unwrap_or_else(|poisoned| poisoned.into_inner())
8001                    .insert(materialization_id.clone(), previous);
8002            }
8003            return Err(persistence_failure(error));
8004        }
8005        drop(transaction);
8006        self.publish_record(record);
8007        Ok(Some(record_id))
8008    }
8009
8010    fn allocate_record_id(&self) -> Result<SessionRecordId, NodeFailure> {
8011        for _ in 0..8 {
8012            let bytes = random_nonce().map_err(|error| {
8013                failure(
8014                    NodeFailureCode::BackendOperationFailed,
8015                    &format!("session record identity generation failed: {error}"),
8016                )
8017            })?;
8018            let mut value = String::with_capacity(27);
8019            value.push_str("sr-");
8020            for byte in &bytes[..12] {
8021                use std::fmt::Write as _;
8022                let _ = write!(&mut value, "{byte:02x}");
8023            }
8024            let record_id = SessionRecordId::new(value).map_err(|error| {
8025                failure(NodeFailureCode::BackendOperationFailed, &error.to_string())
8026            })?;
8027            let records = self
8028                .session_records
8029                .lock()
8030                .unwrap_or_else(|poisoned| poisoned.into_inner());
8031            if !records.records.contains_key(&record_id) {
8032                return Ok(record_id);
8033            }
8034        }
8035        Err(failure(
8036            NodeFailureCode::BackendBusy,
8037            "could not allocate a unique session record ID",
8038        ))
8039    }
8040
8041    fn allocate_task_id(&self) -> Result<TaskId, NodeFailure> {
8042        for _ in 0..8 {
8043            let bytes = random_nonce().map_err(|error| {
8044                failure(
8045                    NodeFailureCode::BackendOperationFailed,
8046                    &format!("task identity generation failed: {error}"),
8047                )
8048            })?;
8049            let mut nonce = [0_u8; 12];
8050            nonce.copy_from_slice(&bytes[..12]);
8051            let task_id = TaskId::from_nonce(nonce);
8052            let records = self
8053                .session_records
8054                .lock()
8055                .unwrap_or_else(|poisoned| poisoned.into_inner());
8056            if !records.records.values().any(|record| {
8057                record.task_binding.as_ref().and_then(|binding| binding.task_id.as_ref())
8058                    == Some(&task_id)
8059            }) {
8060                return Ok(task_id);
8061            }
8062        }
8063        Err(failure(
8064            NodeFailureCode::BackendBusy,
8065            "could not allocate a unique task ID",
8066        ))
8067    }
8068
8069    fn default_record_name(&self, provider: &AgentId) -> String {
8070        let records = self
8071            .session_records
8072            .lock()
8073            .unwrap_or_else(|poisoned| poisoned.into_inner());
8074        let ordinal = records
8075            .records
8076            .values()
8077            .filter(|record| &record.provider == provider)
8078            .count()
8079            .saturating_add(1);
8080        format!("{} #{ordinal}", provider.as_str())
8081    }
8082
8083    fn new_record(
8084        &self,
8085        address: &SessionAddress,
8086        provider: AgentId,
8087        mode: SessionMode,
8088        state: ManagedSessionState,
8089        provider_session: Option<gate4agent_types::ProviderSessionIdentity>,
8090        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
8091        bundle: Option<ResolvedBundleReceipt>,
8092        context: Option<ResolvedContextPackReceipt>,
8093    ) -> Result<ManagedSessionRecord, NodeFailure> {
8094        let canonical_root = self.workspace_root(&address.workspace_id)?;
8095        let now = unix_time_ms();
8096        Ok(ManagedSessionRecord {
8097            record_id: self.allocate_record_id()?,
8098            display_name: self.m4a_nick_of(address.session.instance_id).unwrap_or_else(|| self.default_record_name(&provider)),
8099            provider,
8100            mode,
8101            state,
8102            workspace_id: address.workspace_id.clone(),
8103            canonical_root: opaque_windows_path(canonical_root),
8104            provider_session,
8105            active_session: Some(address.clone()),
8106            environment_profile,
8107            bundle,
8108            context_id: context.as_ref().map(|receipt| receipt.id.clone()),
8109            context,
8110            exported_context: None,
8111            task_binding: None,
8112            created_at_unix_ms: now,
8113            updated_at_unix_ms: now,
8114            last_error: None,
8115        })
8116    }
8117
8118    fn insert_record(&self, record: ManagedSessionRecord) -> Result<(), NodeFailure> {
8119        let mut records = self
8120            .session_records
8121            .lock()
8122            .unwrap_or_else(|poisoned| poisoned.into_inner());
8123        if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
8124            return Err(failure(
8125                NodeFailureCode::BackendBusy,
8126                "managed session record capacity is full; forget an inactive record first",
8127            ));
8128        }
8129        if let Some(identity) = record.provider_session.as_ref() {
8130            if records.records.values().any(|candidate| {
8131                candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8132                    session_registry::same_provider_session(
8133                        &candidate.provider,
8134                        candidate_identity,
8135                        &record.provider,
8136                        identity,
8137                    )
8138                })
8139            }) {
8140                return Err(failure(
8141                    NodeFailureCode::SessionRecordConflict,
8142                    "provider session is already represented by another managed record",
8143                ));
8144            }
8145        }
8146        match records.records.entry(record.record_id.clone()) {
8147            std::collections::btree_map::Entry::Vacant(entry) => {
8148                entry.insert(record);
8149            }
8150            std::collections::btree_map::Entry::Occupied(_) => {
8151                return Err(failure(
8152                    NodeFailureCode::SessionRecordConflict,
8153                    "session record ID collision",
8154                ));
8155            }
8156        }
8157        Ok(())
8158    }
8159
8160    fn record(&self, record_id: &SessionRecordId) -> Result<ManagedSessionRecord, NodeFailure> {
8161        self.session_records
8162            .lock()
8163            .unwrap_or_else(|poisoned| poisoned.into_inner())
8164            .records
8165            .get(record_id)
8166            .cloned()
8167            .ok_or_else(|| {
8168                failure(
8169                    NodeFailureCode::UnknownSessionRecord,
8170                    "managed session record does not exist",
8171                )
8172            })
8173    }
8174
8175    fn revalidate_session_record_preview(
8176        &self,
8177        expected: &ManagedSessionRecord,
8178        identity: &ProviderSessionIdentity,
8179    ) -> Result<(), NodeFailure> {
8180        let current = self.record(&expected.record_id)?;
8181        let current_root = self.workspace_root(&current.workspace_id)?;
8182        if current.provider != expected.provider
8183            || current.workspace_id != expected.workspace_id
8184            || current.canonical_root != expected.canonical_root
8185            || current.provider_session.as_ref() != Some(identity)
8186            || !platform::roots_equal(
8187                &current_root,
8188                windows_path_text(&current.canonical_root),
8189            )
8190        {
8191            return Err(failure(
8192                NodeFailureCode::SessionRecordConflict,
8193                "managed session changed while its preview was loading",
8194            ));
8195        }
8196        Ok(())
8197    }
8198
8199    fn revalidate_session_record_context_export(
8200        &self,
8201        expected: &ManagedSessionRecord,
8202        identity: Option<&ProviderSessionIdentity>,
8203        session: &SessionAddress,
8204        allow_clean_detachment: bool,
8205    ) -> Result<(), NodeFailure> {
8206        let current = self.record(&expected.record_id)?;
8207        let current_root = self.workspace_root(&current.workspace_id)?;
8208        let snapshot = self.internal_session_snapshot(session)?;
8209        let binding = self
8210            .session_bindings
8211            .lock()
8212            .unwrap_or_else(|poisoned| poisoned.into_inner())
8213            .get(&session.session.instance_id)
8214            .cloned()
8215            .ok_or_else(|| {
8216                failure(
8217                    NodeFailureCode::SessionRecordConflict,
8218                    "managed session runtime binding disappeared during context export",
8219                )
8220            })?;
8221        if !session_record_context_export_binding_is_exact(
8222            expected,
8223            &current,
8224            identity,
8225            session,
8226            &binding,
8227            &snapshot.agent_id,
8228            &current_root,
8229            allow_clean_detachment,
8230        ) || !session_record_context_export_source_is_usable(
8231            &current,
8232            &snapshot.status,
8233            allow_clean_detachment,
8234        ) {
8235            return Err(failure(
8236                NodeFailureCode::SessionRecordConflict,
8237                "managed session binding changed during context export",
8238            ));
8239        }
8240        Ok(())
8241    }
8242
8243    fn revalidate_loaded_history(
8244        &self,
8245        session: &SessionAddress,
8246        candidate_id: &str,
8247        loaded: &HistorySessionRecord,
8248    ) -> Result<(), NodeFailure> {
8249        let snapshot = self.internal_session_snapshot(session)?;
8250        if snapshot.history.pending.is_some()
8251            || snapshot.history.loaded_candidate_id.as_deref() != Some(candidate_id)
8252            || snapshot.history.loaded.as_ref() != Some(loaded)
8253        {
8254            return Err(failure(
8255                NodeFailureCode::SessionRecordConflict,
8256                "managed session history changed during context export",
8257            ));
8258        }
8259        Ok(())
8260    }
8261
8262    fn remove_record_memory(&self, record_id: &SessionRecordId) -> Option<ManagedSessionRecord> {
8263        self.session_records
8264            .lock()
8265            .unwrap_or_else(|poisoned| poisoned.into_inner())
8266            .records
8267            .remove(record_id)
8268    }
8269
8270    fn discard_record(&self, record_id: &SessionRecordId) -> Result<(), NodeFailure> {
8271        if self.destroy_record_materialization(record_id)? {
8272            self.publish(NodeEvent::SessionRecordRemoved {
8273                record_id: record_id.clone(),
8274            });
8275            return Ok(());
8276        }
8277        let transaction = self
8278            .state_transaction
8279            .lock()
8280            .unwrap_or_else(|poisoned| poisoned.into_inner());
8281        let Some(removed) = self.remove_record_memory(record_id) else {
8282            return Ok(());
8283        };
8284        if let Err(error) = self.persist_state_locked() {
8285            let _ = self.insert_record(removed);
8286            return Err(persistence_failure(error));
8287        }
8288        drop(transaction);
8289        self.publish(NodeEvent::SessionRecordRemoved {
8290            record_id: record_id.clone(),
8291        });
8292        Ok(())
8293    }
8294
8295    fn publish_record(&self, record: ManagedSessionRecord) {
8296        self.publish(NodeEvent::SessionRecordUpserted { record });
8297    }
8298
8299    fn mark_record_error(
8300        &self,
8301        record_id: &SessionRecordId,
8302        message: &str,
8303    ) -> Result<(), NodeFailure> {
8304        let transaction = self
8305            .state_transaction
8306            .lock()
8307            .unwrap_or_else(|poisoned| poisoned.into_inner());
8308        let previous = {
8309            let mut records = self
8310                .session_records
8311                .lock()
8312                .unwrap_or_else(|poisoned| poisoned.into_inner());
8313            let Some(record) = records.records.get_mut(record_id) else {
8314                return Ok(());
8315            };
8316            let previous = record.clone();
8317            record.state = ManagedSessionState::Unavailable;
8318            record.last_error = Some(session_registry::sanitized_record_error_summary(message));
8319            record.updated_at_unix_ms = unix_time_ms();
8320            previous
8321        };
8322        if let Err(error) = self.persist_state_locked() {
8323            self.session_records
8324                .lock()
8325                .unwrap_or_else(|poisoned| poisoned.into_inner())
8326                .records
8327                .insert(record_id.clone(), previous);
8328            return Err(persistence_failure(error));
8329        }
8330        let record = self.record(record_id)?;
8331        drop(transaction);
8332        self.publish_record(record);
8333        Ok(())
8334    }
8335
8336    fn rename_session_record(
8337        &self,
8338        record_id: &SessionRecordId,
8339        display_name: String,
8340    ) -> Result<ManagedSessionRecord, NodeFailure> {
8341        validate_display_name(&display_name)
8342            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8343        let _transaction = self
8344            .state_transaction
8345            .lock()
8346            .unwrap_or_else(|poisoned| poisoned.into_inner());
8347        let previous = {
8348            let mut records = self
8349                .session_records
8350                .lock()
8351                .unwrap_or_else(|poisoned| poisoned.into_inner());
8352            let record = records.records.get_mut(record_id).ok_or_else(|| {
8353                failure(
8354                    NodeFailureCode::UnknownSessionRecord,
8355                    "managed session record does not exist",
8356                )
8357            })?;
8358            let previous = record.clone();
8359            record.display_name = display_name;
8360            record.updated_at_unix_ms = unix_time_ms();
8361            previous
8362        };
8363        if let Err(error) = self.persist_state_locked() {
8364            self.session_records
8365                .lock()
8366                .unwrap_or_else(|poisoned| poisoned.into_inner())
8367                .records
8368                .insert(record_id.clone(), previous);
8369            return Err(persistence_failure(error));
8370        }
8371        let updated = self.record(record_id)?;
8372        self.publish_record(updated.clone());
8373        Ok(updated)
8374    }
8375
8376    fn set_session_task(
8377        &self,
8378        record_id: &SessionRecordId,
8379        expected_revision: u64,
8380        target: SessionTaskTargetV1,
8381    ) -> Result<ManagedSessionRecord, NodeFailure> {
8382        let transaction = self
8383            .state_transaction
8384            .lock()
8385            .unwrap_or_else(|poisoned| poisoned.into_inner());
8386        let current = self.record(record_id)?;
8387        let current_task_id = current
8388            .task_binding
8389            .as_ref()
8390            .and_then(|binding| binding.task_id.as_ref());
8391        let current_revision = current
8392            .task_binding
8393            .as_ref()
8394            .map_or(0, |binding| binding.revision);
8395        if expected_revision != current_revision {
8396            return Err(failure(
8397                NodeFailureCode::SessionRecordConflict,
8398                "managed session task binding revision changed",
8399            ));
8400        }
8401        let exact = match &target {
8402            SessionTaskTargetV1::Existing { task_id } => current_task_id == Some(task_id),
8403            SessionTaskTargetV1::Clear => current_task_id.is_none(),
8404            SessionTaskTargetV1::New => false,
8405        };
8406        if exact {
8407            return Ok(current);
8408        }
8409        let revision = current_revision.checked_add(1).ok_or_else(|| {
8410            failure(
8411                NodeFailureCode::SessionRecordConflict,
8412                "managed session task binding revision is exhausted",
8413            )
8414        })?;
8415        let task_id = match target {
8416            SessionTaskTargetV1::New => Some(self.allocate_task_id()?),
8417            SessionTaskTargetV1::Existing { task_id } => Some(task_id),
8418            SessionTaskTargetV1::Clear => None,
8419        };
8420        let changed_at_unix_ms = unix_time_ms()
8421            .max(current.updated_at_unix_ms.checked_add(1).ok_or_else(|| {
8422                failure(
8423                    NodeFailureCode::SessionRecordConflict,
8424                    "managed session record timestamp is exhausted",
8425                )
8426            })?);
8427        let updated = {
8428            let mut records = self
8429                .session_records
8430                .lock()
8431                .unwrap_or_else(|poisoned| poisoned.into_inner());
8432            let record = records.records.get_mut(record_id).ok_or_else(|| {
8433                failure(
8434                    NodeFailureCode::UnknownSessionRecord,
8435                    "managed session record does not exist",
8436                )
8437            })?;
8438            record.task_binding = Some(SessionTaskBindingV1 {
8439                revision,
8440                task_id,
8441                changed_at_unix_ms,
8442            });
8443            record.updated_at_unix_ms = changed_at_unix_ms;
8444            record.clone()
8445        };
8446        if let Err(error) = self.persist_state_locked() {
8447            self.session_records
8448                .lock()
8449                .unwrap_or_else(|poisoned| poisoned.into_inner())
8450                .records
8451                .insert(record_id.clone(), current);
8452            return Err(persistence_failure(error));
8453        }
8454        drop(transaction);
8455        self.publish_record(updated.clone());
8456        Ok(updated)
8457    }
8458
8459    fn index_provider_session(
8460        &self,
8461        workspace_id: WorkspaceId,
8462        provider: AgentId,
8463        identity: ProviderSessionIdentity,
8464        display_name: String,
8465    ) -> Result<ManagedSessionRecord, NodeFailure> {
8466        self.index_provider_session_with_policy(
8467            workspace_id,
8468            provider,
8469            identity,
8470            display_name,
8471            false,
8472        )
8473    }
8474
8475    fn index_provider_session_with_policy(
8476        &self,
8477        workspace_id: WorkspaceId,
8478        provider: AgentId,
8479        identity: ProviderSessionIdentity,
8480        display_name: String,
8481        allow_validated_transcript_path: bool,
8482    ) -> Result<ManagedSessionRecord, NodeFailure> {
8483        validate_display_name(&display_name)
8484            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8485        identity
8486            .validate()
8487            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8488        if identity.transcript_path.is_some() && !allow_validated_transcript_path {
8489            return Err(failure(
8490                NodeFailureCode::InvalidRequest,
8491                "provider session reference index v1 does not accept transcript paths",
8492            ));
8493        }
8494        let canonical_root = self.workspace_root(&workspace_id)?;
8495        if !self.enabled_providers.contains(&provider) {
8496            return Err(failure(
8497                NodeFailureCode::InvalidRequest,
8498                "provider is not enabled on this node",
8499            ));
8500        }
8501
8502        let transaction = self
8503            .state_transaction
8504            .lock()
8505            .unwrap_or_else(|poisoned| poisoned.into_inner());
8506        {
8507            let records = self
8508                .session_records
8509                .lock()
8510                .unwrap_or_else(|poisoned| poisoned.into_inner());
8511            if let Some(existing) = records.records.values().find(|candidate| {
8512                candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8513                    candidate_identity.key == identity.key && candidate_identity.id == identity.id
8514                })
8515            }) {
8516                if existing.provider == provider && existing.workspace_id == workspace_id {
8517                    return Ok(existing.clone());
8518                }
8519                return Err(failure(
8520                    NodeFailureCode::SessionRecordConflict,
8521                    "provider session reference conflicts with another provider or workspace",
8522                ));
8523            }
8524            if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
8525                return Err(failure(
8526                    NodeFailureCode::BackendBusy,
8527                    "managed session record capacity is full; forget an inactive record first",
8528                ));
8529            }
8530        }
8531
8532        let now = unix_time_ms();
8533        let record = ManagedSessionRecord {
8534            record_id: self.allocate_record_id()?,
8535            display_name,
8536            provider,
8537            mode: SessionMode::Pty,
8538            state: ManagedSessionState::Dormant,
8539            workspace_id,
8540            canonical_root: opaque_windows_path(canonical_root),
8541            provider_session: Some(identity),
8542            active_session: None,
8543            environment_profile: None,
8544            bundle: None,
8545            context_id: None,
8546            context: None,
8547            exported_context: None,
8548            task_binding: None,
8549            created_at_unix_ms: now,
8550            updated_at_unix_ms: now,
8551            last_error: None,
8552        };
8553        self.insert_record(record.clone())?;
8554        if let Err(error) = self.persist_state_locked() {
8555            self.remove_record_memory(&record.record_id);
8556            return Err(persistence_failure(error));
8557        }
8558        drop(transaction);
8559        self.publish_record(record.clone());
8560        Ok(record)
8561    }
8562
8563    fn bound_address_for_record(&self, record_id: &SessionRecordId) -> Option<SessionAddress> {
8564        self.session_bindings
8565            .lock()
8566            .unwrap_or_else(|poisoned| poisoned.into_inner())
8567            .iter()
8568            .find_map(|(instance_id, binding)| {
8569                (binding.record_id.as_ref() == Some(record_id)).then(|| SessionAddress {
8570                    workspace_id: binding.workspace_id.clone(),
8571                    session: SessionKey {
8572                        instance_id: *instance_id,
8573                        generation: binding.generation,
8574                    },
8575                })
8576            })
8577    }
8578
8579    async fn forget_session_record(
8580        &self,
8581        record_id: &SessionRecordId,
8582    ) -> Result<(), NodeFailure> {
8583        let record = self.record(record_id)?;
8584        if record.active_session.is_some()
8585            || matches!(
8586                record.state,
8587                ManagedSessionState::Live | ManagedSessionState::IdentityPending
8588            )
8589        {
8590            return Err(failure(
8591                NodeFailureCode::SessionRecordBusy,
8592                "stop the managed session before forgetting it",
8593            ));
8594        }
8595        if let Some(address) = self.bound_address_for_record(record_id) {
8596            self.remove_session(&address).await?;
8597        }
8598        self.remove_managed_session_record(record_id, &record.workspace_id).await
8599    }
8600
8601    /// Removes one managed session record from durable state and notifies
8602    /// every listener -- the shared transaction body behind both
8603    /// `forget_session_record`'s operator-driven path (busy-checked above
8604    /// it, and clears any still-live session binding before calling in
8605    /// here) and the retention sweep (`retire_unavailable_session_records`,
8606    /// which only ever selects `ManagedSessionState::Unavailable` records
8607    /// that are already known never-busy and never bound, so it calls
8608    /// straight into this with neither check). Cleans a managed-worktree
8609    /// lease if the record held one, removes the record's environment
8610    /// materialization (or, absent one, removes it from memory directly),
8611    /// persists under `state_transaction` with rollback on persist failure,
8612    /// then publishes `NodeEvent::SessionRecordRemoved` so c2's own
8613    /// legacy-provider bookkeeping (`record_is_legacy`) stays in sync
8614    /// exactly as it does for an operator-driven forget.
8615    async fn remove_managed_session_record(
8616        &self,
8617        record_id: &SessionRecordId,
8618        workspace_id: &WorkspaceId,
8619    ) -> Result<(), NodeFailure> {
8620        let managed_lease_id = self.managed_worktrees.lock()
8621            .unwrap_or_else(|poisoned| poisoned.into_inner())
8622            .lease_for_workspace(workspace_id);
8623        if !self.destroy_record_materialization(record_id)? {
8624            let transaction = self
8625                .state_transaction
8626                .lock()
8627                .unwrap_or_else(|poisoned| poisoned.into_inner());
8628            let removed = self.remove_record_memory(record_id).ok_or_else(|| {
8629                failure(
8630                    NodeFailureCode::UnknownSessionRecord,
8631                    "managed session record does not exist",
8632                )
8633            })?;
8634            if let Err(error) = self.persist_state_locked() {
8635                let _ = self.insert_record(removed);
8636                return Err(persistence_failure(error));
8637            }
8638            drop(transaction);
8639        }
8640        self.publish(NodeEvent::SessionRecordRemoved {
8641            record_id: record_id.clone(),
8642        });
8643        if let Some(lease_id) = managed_lease_id {
8644            if let Some(snapshot) = self.managed_worktrees.lock()
8645                .unwrap_or_else(|poisoned| poisoned.into_inner())
8646                .get(&lease_id)
8647                .map(ManagedWorktreeLeaseRecord::snapshot)
8648            {
8649                self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
8650            }
8651            let _ = self.cleanup_managed_worktree(&lease_id, false).await;
8652        }
8653        Ok(())
8654    }
8655
8656    /// Retires `ManagedSessionState::Unavailable` records against the
8657    /// node's configured `session_record_retention` age/keep-N policy.
8658    /// Never touches `Live`, `IdentityPending`, or `Dormant` records --
8659    /// `Dormant` is resumable and folding it into this sweep would delete
8660    /// records a caller can still act on. Both policy fields default to
8661    /// `0` (disabled); when both are `0` this is a no-op. When both are
8662    /// set, a record is retired only if it fails BOTH tests (older than
8663    /// `age_ms` AND beyond the newest `keep_per_workspace` in its
8664    /// workspace); when only one is set, that one alone decides. Called
8665    /// once at startup (alongside `reconcile_managed_worktrees`) and then
8666    /// on a `SESSION_RECORD_RETENTION_SWEEP_PERIOD` cadence from the drive
8667    /// loop -- see both call sites.
8668    async fn retire_unavailable_session_records(&self) {
8669        let policy = self.session_record_retention;
8670        let age_enabled = policy.age_ms > 0;
8671        let keep_enabled = policy.keep_per_workspace > 0;
8672        if !age_enabled && !keep_enabled {
8673            return;
8674        }
8675        let now = unix_time_ms();
8676        let candidates: Vec<(SessionRecordId, WorkspaceId, AgentId, u64)> = {
8677            self.session_records
8678                .lock()
8679                .unwrap_or_else(|poisoned| poisoned.into_inner())
8680                .records
8681                .values()
8682                .filter(|record| record.state == ManagedSessionState::Unavailable)
8683                .map(|record| {
8684                    (
8685                        record.record_id.clone(),
8686                        record.workspace_id.clone(),
8687                        record.provider.clone(),
8688                        record.updated_at_unix_ms,
8689                    )
8690                })
8691                .collect()
8692        };
8693        if candidates.is_empty() {
8694            return;
8695        }
8696        let mut by_workspace: BTreeMap<WorkspaceId, Vec<(SessionRecordId, AgentId, u64)>> =
8697            BTreeMap::new();
8698        for (record_id, workspace_id, provider, updated_at_unix_ms) in candidates {
8699            by_workspace
8700                .entry(workspace_id)
8701                .or_default()
8702                .push((record_id, provider, updated_at_unix_ms));
8703        }
8704        let reason = if age_enabled && keep_enabled {
8705            "age+keep-n"
8706        } else if age_enabled {
8707            "age"
8708        } else {
8709            "keep-n"
8710        };
8711        let mut retired: u64 = 0;
8712        for (workspace_id, mut records) in by_workspace {
8713            // Newest `updated_at_unix_ms` first, so index `>=
8714            // keep_per_workspace` names exactly "beyond the newest N".
8715            records.sort_by(|left, right| right.2.cmp(&left.2));
8716            for (index, (record_id, provider, updated_at_unix_ms)) in
8717                records.into_iter().enumerate()
8718            {
8719                let record_age_ms = now.saturating_sub(updated_at_unix_ms);
8720                let age_hit = age_enabled && record_age_ms >= policy.age_ms;
8721                let keep_hit = keep_enabled && index >= policy.keep_per_workspace as usize;
8722                let should_retire = if age_enabled && keep_enabled {
8723                    age_hit && keep_hit
8724                } else {
8725                    age_hit || keep_hit
8726                };
8727                if !should_retire {
8728                    continue;
8729                }
8730                match self.remove_managed_session_record(&record_id, &workspace_id).await {
8731                    Ok(()) => {
8732                        self.retired_records_total.fetch_add(1, Ordering::Relaxed);
8733                        retired += 1;
8734                        tracing::info!(
8735                            node_id = %self.node_id,
8736                            record_id = %record_id,
8737                            workspace_id = %workspace_id,
8738                            provider = %provider,
8739                            age_ms = record_age_ms,
8740                            reason,
8741                            "managed session record retired"
8742                        );
8743                    }
8744                    Err(error) => tracing::warn!(
8745                        node_id = %self.node_id,
8746                        record_id = %record_id,
8747                        workspace_id = %workspace_id,
8748                        code = ?error.code,
8749                        cause = %error.message,
8750                        "session record retention sweep failed to retire a record"
8751                    ),
8752                }
8753            }
8754        }
8755        if retired > 0 {
8756            let remaining_unavailable = self
8757                .session_records
8758                .lock()
8759                .unwrap_or_else(|poisoned| poisoned.into_inner())
8760                .records
8761                .values()
8762                .filter(|record| record.state == ManagedSessionState::Unavailable)
8763                .count();
8764            tracing::info!(
8765                node_id = %self.node_id,
8766                retired,
8767                remaining_unavailable,
8768                "session record retention sweep completed"
8769            );
8770        }
8771    }
8772
8773    async fn resume_session_record(
8774        &self,
8775        record_id: &SessionRecordId,
8776        terminal_size: gate4agent_types::TerminalSize,
8777        initial_prompt: Option<String>,
8778    ) -> Result<(ManagedSessionRecord, SessionAddress), NodeFailure> {
8779        let mut record = self.record(record_id)?;
8780        // claude's ACP agent has no session/load: an ACP claude conversation whose transcript
8781        // is on disk is resumed as a PTY `claude --resume <id>` of that same transcript.
8782        if record.mode == SessionMode::Acp
8783            && record.provider.as_str() == "claude"
8784            && record.provider_session.as_ref().is_some_and(|i| provider_session_exists("claude", &i.id))
8785        {
8786            record.mode = SessionMode::Pty;
8787            if let Some(r) = self.session_records.lock().unwrap_or_else(|p| p.into_inner()).records.get_mut(record_id) {
8788                r.mode = SessionMode::Pty;
8789            }
8790            tracing::info!(record = %record_id, "acp claude record resumed over pty (--resume of its transcript)");
8791        }
8792        if record.active_session.is_some()
8793            || matches!(
8794                record.state,
8795                ManagedSessionState::Live | ManagedSessionState::IdentityPending
8796            )
8797        {
8798            return Err(failure(
8799                NodeFailureCode::SessionRecordBusy,
8800                "managed session already has a live runtime binding",
8801            ));
8802        }
8803        let identity = record.provider_session.clone().ok_or_else(|| {
8804            failure(
8805                NodeFailureCode::SessionRecordNotResumable,
8806                "managed session has no verified provider session identity",
8807            )
8808        })?;
8809        let working_directory = self.workspace_root(&record.workspace_id)?;
8810        if !platform::roots_equal(
8811            &working_directory,
8812            windows_path_text(&record.canonical_root),
8813        ) {
8814            return Err(failure(
8815                NodeFailureCode::SessionRecordConflict,
8816                "managed session workspace root changed; refusing to resume in another directory",
8817            ));
8818        }
8819        {
8820            let records = self
8821                .session_records
8822                .lock()
8823                .unwrap_or_else(|poisoned| poisoned.into_inner());
8824            if records.records.values().any(|candidate| {
8825                candidate.record_id != *record_id
8826                    && candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8827                        session_registry::same_provider_session(
8828                            &candidate.provider,
8829                            candidate_identity,
8830                            &record.provider,
8831                            &identity,
8832                        )
8833                    })
8834                    && (candidate.active_session.is_some()
8835                        || matches!(
8836                            candidate.state,
8837                            ManagedSessionState::Live | ManagedSessionState::IdentityPending
8838                        ))
8839            }) {
8840                return Err(failure(
8841                    NodeFailureCode::SessionRecordConflict,
8842                    "provider session is already active through another managed record",
8843                ));
8844            }
8845        }
8846        let runtime_requirement = if initial_prompt.is_some() {
8847            ProviderRuntimeRequirement::ResumeWithPrompt
8848        } else {
8849            ProviderRuntimeRequirement::Resume
8850        };
8851        let runtime_policy = self.admit_provider_runtime(
8852            &record.provider,
8853            runtime_requirement,
8854        )
8855        .await?;
8856        // The resumed runtime runs in the session folder its record was named after.
8857        let working_directory = match self.m4a_accounts.as_ref() {
8858            Some(_) => {
8859                let dir = std::path::Path::new(&working_directory).join(".hatchery-sessions").join(&record.display_name);
8860                if !record.display_name.contains(' ') && dir.is_dir() { dir.to_string_lossy().into_owned() } else { working_directory }
8861            }
8862            None => working_directory,
8863        };
8864        let request = ResumeLaunchRequest {
8865            working_directory,
8866            terminal_size,
8867            initial_prompt,
8868        };
8869        request
8870            .validate()
8871            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8872        self.ensure_binding_capacity()?;
8873        let resolved_materialization = match self.resolve_record_materialization(
8874            record_id,
8875            &record.provider,
8876            record.mode,
8877            record.environment_profile.as_ref(),
8878            record.bundle.as_ref(),
8879            record.context.as_ref(),
8880        ) {
8881            Ok(materialization) => materialization,
8882            Err(error) => {
8883                let label = match error.code {
8884                    NodeFailureCode::UnknownBundle => "bundle-unavailable",
8885                    NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
8886                    NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
8887                    _ => "environment-profile-unavailable",
8888                };
8889                self.mark_record_error(record_id, label)?;
8890                return Err(error);
8891            }
8892        };
8893        let (materialization_id, environment_overlay) = match resolved_materialization {
8894            Some((id, overlay)) => (Some(id), overlay),
8895            None => (None, None),
8896        };
8897        let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
8898        // Resumed runtime: same auto-permit flags and launch options (model / effort) as a spawn.
8899        let environment_overlay = {
8900            let mut args: Vec<OsString> = Vec::new();
8901            if let Ok(cwd) = self.workspace_root(&record.workspace_id) {
8902                let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
8903                if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") { homes.extend(std::env::split_paths(&extra)); }
8904                crate::auto_permit::prepare(record.provider.as_str(), &homes, Some(std::path::Path::new(&cwd)));
8905            }
8906            args.extend(crate::auto_permit::launch_args(record.provider.as_str(), record.mode));
8907            let options = SPAWN_SESSION_OPTIONS.try_with(|o| o.clone()).ok().flatten();
8908            if record.mode == SessionMode::Pty && record.provider.as_str() == "claude" {
8909                if let Some(sel) = options.as_ref() {
8910                    args.push("--model".into()); args.push(sel.model.clone().into());
8911                    if let Some(effort) = sel.values.get("effort").and_then(|v| v.as_str()) {
8912                        args.push("--effort".into()); args.push(effort.to_owned().into());
8913                    }
8914                }
8915            }
8916            if matches!(record.mode, SessionMode::Pty | SessionMode::Acp) && crate::pty_resume::supported(record.provider.as_str()) {
8917                if let (Some(file), Some(identity)) = (self.session_resume_journal(), record.provider_session.as_ref()) {
8918                    crate::pty_resume::upsert(&file, crate::pty_resume::Entry {
8919                        instance_id: instance_id.0,
8920                        provider: record.provider.as_str().to_owned(),
8921                        workspace_id: record.workspace_id.as_str().to_owned(),
8922                        provider_session_id: identity.id.clone(),
8923                        session_key: self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).get(&instance_id).cloned().unwrap_or_default(),
8924                        cwd: String::new(),
8925                        acp: record.mode == SessionMode::Acp,
8926                        options,
8927                        cols: 0,
8928                        rows: 0,
8929                        origin: SPAWN_ORIGIN.try_with(|o| o.clone()).ok().flatten(),
8930                        parent_session_id: SPAWN_PARENT.try_with(|o| o.clone()).ok().flatten(),
8931                    });
8932                }
8933            }
8934            overlay_with_extra_args(&record.provider, record.mode, environment_overlay, args)?
8935        };
8936        // The resumed runtime keeps the m4a account of the runtime it replaces.
8937        let environment_overlay = match (self.m4a_accounts.as_ref(), crate::m4a_accounts::m4a_provider(record.provider.as_str())) {
8938            (Some(accounts), Some(m4a_provider)) if matches!(record.mode, SessionMode::Pty | SessionMode::Acp) => {
8939                if let Some(old) = self.bound_address_for_record(record_id) {
8940                    if let Some(key) = self.m4a_keys.lock().unwrap_or_else(|p| p.into_inner()).get(&old.session.instance_id).cloned() {
8941                        self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).insert(instance_id, key);
8942                    }
8943                }
8944                let session_key = self.m4a_session_key(instance_id);
8945                self.m4a_session_overlay(accounts, m4a_provider, &record.provider, instance_id, environment_overlay, &session_key, record.mode)?
8946            }
8947            _ => environment_overlay,
8948        };
8949        let mut environment_selection = match SpawnPreparer::select_environment(self, 
8950            instance_id,
8951            &record.provider,
8952            record.mode,
8953            record.environment_profile.as_ref(),
8954        ) {
8955            Ok(selection) => selection,
8956            Err(error) => {
8957                self.mark_record_error(record_id, "environment-profile-unavailable")?;
8958                return Err(error);
8959            }
8960        };
8961        let mut instance_overlay = environment_overlay
8962            .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
8963            .transpose()?
8964            .flatten();
8965        if let Some(stale_address) = self.bound_address_for_record(record_id) {
8966            self.remove_session(&stale_address).await?;
8967        }
8968        let address = SessionAddress {
8969            workspace_id: record.workspace_id.clone(),
8970            session: SessionKey {
8971                instance_id,
8972                generation: SessionGeneration(1),
8973            },
8974        };
8975        let transaction = self
8976            .state_transaction
8977            .lock()
8978            .unwrap_or_else(|poisoned| poisoned.into_inner());
8979        let current = self.record(record_id)?;
8980        if current.active_session.is_some()
8981            || matches!(
8982                current.state,
8983                ManagedSessionState::Live | ManagedSessionState::IdentityPending
8984            )
8985        {
8986            return Err(failure(
8987                NodeFailureCode::SessionRecordBusy,
8988                "managed session became active while resume was being prepared",
8989            ));
8990        }
8991        self.bind_managed_session_with_materialization(
8992            &address,
8993            record_id.clone(),
8994            runtime_policy,
8995            record.environment_profile.clone(),
8996            record.bundle.clone(),
8997            record.context.clone(),
8998            materialization_id,
8999        );
9000        if let Some(selection) = environment_selection.take() {
9001            selection.retain();
9002        }
9003        if let Some(overlay) = instance_overlay.take() {
9004            overlay.retain();
9005        }
9006        drop(transaction);
9007
9008        let transport = match record.mode {
9009            SessionMode::Pty => TransportKind::Pty,
9010            SessionMode::Inline => TransportKind::Pipe,
9011            SessionMode::Acp => TransportKind::Acp,
9012        };
9013        let agent_id = record.provider.clone();
9014        let dispatch_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
9015        if let Err(error) = self
9016            .dispatch_bounded(
9017                ControlCommand::Register {
9018                    instance_id,
9019                    agent_id,
9020                    transport,
9021                },
9022                dispatch_timeout,
9023            )
9024            .await
9025        {
9026            if let Some(binding) = self.remove_binding(&address) {
9027                SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
9028            }
9029            return Err(error);
9030        }
9031        let command = self.prepare_command(ControlCommand::Resume {
9032            instance_id,
9033            target: ResumeTarget::ProviderSession { identity },
9034            runtime_policy,
9035            request,
9036        });
9037        let started_after = self.current_sequence();
9038        if let Err(error) = self.arm_resume(&address, command.id, runtime_policy) {
9039            self.rollback_spawn(&address, dispatch_timeout).await?;
9040            return Err(error);
9041        }
9042        if let Err(error) = self.dispatch_envelope(command) {
9043            self.clear_armed_resume(&address);
9044            self.rollback_spawn(&address, dispatch_timeout).await?;
9045            return Err(error);
9046        }
9047        match self
9048            .wait_for_record_resume(&address, started_after)
9049            .await
9050        {
9051            Ok(settled_address) => {
9052                let managed_lease_id = {
9053                    self.managed_worktrees.lock()
9054                        .unwrap_or_else(|poisoned| poisoned.into_inner())
9055                        .lease_for_workspace(&record.workspace_id)
9056                };
9057                if let Some(lease_id) = managed_lease_id {
9058                    {
9059                        let mut bindings = self.session_bindings.lock()
9060                            .unwrap_or_else(|poisoned| poisoned.into_inner());
9061                        let binding = bindings.get_mut(&settled_address.session.instance_id)
9062                            .ok_or_else(|| failure(
9063                                NodeFailureCode::ManagedWorktreeRecoveryRequired,
9064                                "resumed managed session lost its exact binding",
9065                            ))?;
9066                        if binding.generation != settled_address.session.generation {
9067                            return Err(failure(
9068                                NodeFailureCode::ManagedWorktreeRecoveryRequired,
9069                                "resumed managed session generation diverged",
9070                            ));
9071                        }
9072                        binding.managed_worktree_lease_id = Some(lease_id.clone());
9073                    }
9074                    let snapshot = self.managed_worktrees.lock()
9075                        .unwrap_or_else(|poisoned| poisoned.into_inner())
9076                        .bind_session(
9077                            &lease_id,
9078                            ManagedWorktreeSessionHolder {
9079                                incarnation_id: self.incarnation_id,
9080                                instance_id: settled_address.session.instance_id,
9081                                generation: settled_address.session.generation,
9082                            },
9083                            Some(record_id.clone()),
9084                            unix_time_ms(),
9085                        ).map_err(|message| failure(
9086                            NodeFailureCode::ManagedWorktreeRecoveryRequired,
9087                            &message,
9088                        ))?;
9089                    if let Err(error) = self.persist_state() {
9090                        let _ = self.remove_session(&settled_address).await;
9091                        return Err(persistence_failure(error));
9092                    }
9093                    self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
9094                }
9095                Ok((self.record(record_id)?, settled_address))
9096            }
9097            Err(error) => {
9098                if matches!(
9099                    error.code,
9100                    NodeFailureCode::SessionRecordNotResumable
9101                        | NodeFailureCode::BackendOperationFailed
9102                ) {
9103                    if let Some(bound) = self.bound_address_for_record(record_id) {
9104                        let _ = self.remove_session(&bound).await;
9105                    }
9106                }
9107                Err(error)
9108            }
9109        }
9110    }
9111
9112    async fn wait_for_record_resume(
9113        &self,
9114        initial_address: &SessionAddress,
9115        after_sequence: u64,
9116    ) -> Result<SessionAddress, NodeFailure> {
9117        let deadline = Instant::now()
9118            + Duration::from_millis(MANAGED_RESUME_SETTLE_TIMEOUT_MS);
9119        let mut scan_after = after_sequence;
9120        loop {
9121            let events = self
9122                .history
9123                .lock()
9124                .unwrap_or_else(|poisoned| poisoned.into_inner())
9125                .events
9126                .iter()
9127                .filter(|event| event.sequence > scan_after)
9128                .cloned()
9129                .collect::<Vec<_>>();
9130            for envelope in events {
9131                scan_after = scan_after.max(envelope.sequence);
9132                let NodeEvent::Control { address, event } = envelope.event else {
9133                    continue;
9134                };
9135                if address.session.instance_id != initial_address.session.instance_id {
9136                    continue;
9137                }
9138                match event.event {
9139                    ControlEventKind::Resumed { .. } => return Ok(address),
9140                    ControlEventKind::ResumeDenied { reason } => {
9141                        return Err(failure(
9142                            NodeFailureCode::SessionRecordNotResumable,
9143                            &reason,
9144                        ));
9145                    }
9146                    ControlEventKind::ResumeFailed { message }
9147                    | ControlEventKind::CommandRejected { message } => {
9148                        return Err(failure(
9149                            NodeFailureCode::BackendOperationFailed,
9150                            &message,
9151                        ));
9152                    }
9153                    _ => {}
9154                }
9155            }
9156            if Instant::now() >= deadline {
9157                return Err(failure(
9158                    NodeFailureCode::BackendBusy,
9159                    "managed resume did not settle before the bounded deadline",
9160                ));
9161            }
9162            sleep(Duration::from_millis(5)).await;
9163        }
9164    }
9165
9166    fn workspace_root(&self, workspace_id: &WorkspaceId) -> Result<String, NodeFailure> {
9167        self.workspaces
9168            .read()
9169            .unwrap_or_else(|poisoned| poisoned.into_inner())
9170            .get(workspace_id)
9171            .cloned()
9172            .ok_or_else(|| failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"))
9173    }
9174
9175    fn workspace_roots(&self) -> Vec<PathBuf> {
9176        self.workspaces
9177            .read()
9178            .unwrap_or_else(|poisoned| poisoned.into_inner())
9179            .values()
9180            .map(PathBuf::from)
9181            .collect()
9182    }
9183
9184    async fn inspect_workspace(
9185        &self,
9186        workspace_id: WorkspaceId,
9187    ) -> Result<WorkspaceInspection, NodeFailure> {
9188        let _permit = self
9189            .inspection_slots
9190            .clone()
9191            .try_acquire_owned()
9192            .map_err(|_| {
9193                tracing::warn!(
9194                    workspace_id = %workspace_id,
9195                    "workspace inspection rejected: inspection capacity is busy",
9196                );
9197                failure(
9198                    NodeFailureCode::BackendBusy,
9199                    "workspace inspection capacity is busy",
9200                )
9201            })?;
9202        let canonical_root = self.workspace_root(&workspace_id).map_err(|error| {
9203            tracing::warn!(
9204                workspace_id = %workspace_id,
9205                code = ?error.code,
9206                "workspace inspection rejected: workspace is not registered",
9207            );
9208            error
9209        })?;
9210        let tree_root = canonical_root.clone();
9211        let started_at = Instant::now();
9212        let time_budget_ms = workspace_inspection_time_budget_ms();
9213        let entry_cap = workspace_inspection_entry_cap();
9214        let deadline = started_at + Duration::from_millis(time_budget_ms);
9215        let (entries, tree_truncated, walk_budget) = tokio::task::spawn_blocking(move || {
9216            collect_workspace_entries(Path::new(&tree_root), deadline, entry_cap)
9217        })
9218        .await
9219        .map_err(|error| {
9220            tracing::warn!(
9221                workspace_id = %workspace_id,
9222                error = %error,
9223                "workspace inspection rejected: walk task failed",
9224            );
9225            failure(
9226                NodeFailureCode::BackendOperationFailed,
9227                &format!("workspace inspection task failed: {error}"),
9228            )
9229        })?;
9230        let (mut git, git_time_budget_exceeded) =
9231            inspect_git_workspace(&canonical_root, deadline).await;
9232        if !git.worktrees.is_empty() {
9233            let registered = self
9234                .workspaces
9235                .read()
9236                .unwrap_or_else(|poisoned| poisoned.into_inner())
9237                .iter()
9238                .map(|(workspace_id, root)| (workspace_id.clone(), root.clone()))
9239                .collect::<Vec<_>>();
9240            for worktree in &mut git.worktrees {
9241                worktree.workspace_id = registered
9242                    .iter()
9243                    .find(|(_, root)| {
9244                        worktree_paths_equal(root, windows_path_text(&worktree.path))
9245                    })
9246                    .map(|(workspace_id, _)| workspace_id.clone());
9247            }
9248        }
9249        if git.is_repository {
9250            git.managed_worktree = self
9251                .managed_worktrees
9252                .lock()
9253                .unwrap_or_else(|poisoned| poisoned.into_inner())
9254                .git_scope(&workspace_id, git.branch.as_deref());
9255        }
9256        let truncation = workspace_inspection_truncation(
9257            &walk_budget,
9258            git_time_budget_exceeded,
9259            started_at.elapsed(),
9260        );
9261        if let Some(truncation) = &truncation {
9262            tracing::info!(
9263                workspace_id = %workspace_id,
9264                walk_time_budget_exceeded = truncation.walk_time_budget_exceeded,
9265                walk_entry_cap_exceeded = truncation.walk_entry_cap_exceeded,
9266                git_time_budget_exceeded = truncation.git_time_budget_exceeded,
9267                entries_visited = truncation.entries_visited,
9268                elapsed_ms = truncation.elapsed_ms,
9269                "workspace inspection truncated by its inner budget",
9270            );
9271        }
9272        Ok(WorkspaceInspection {
9273            workspace_id,
9274            entries,
9275            tree_truncated,
9276            git,
9277            truncation,
9278        })
9279    }
9280
9281    async fn browse_host_directories(
9282        &self,
9283        directory: Option<OpaqueHostPath>,
9284        after: Option<OpaqueHostPath>,
9285    ) -> Result<HostDirectoryListing, NodeFailure> {
9286        let permit = self
9287            .inspection_slots
9288            .clone()
9289            .try_acquire_owned()
9290            .map_err(|_| {
9291                failure(
9292                    NodeFailureCode::BackendBusy,
9293                    "host directory browse capacity is busy",
9294                )
9295            })?;
9296        let task = tokio::task::spawn_blocking(move || {
9297            let _permit = permit;
9298            browse_host_directories(directory, after)
9299        });
9300        timeout(Duration::from_millis(HOST_DIRECTORY_BROWSE_TIMEOUT_MS), task)
9301            .await
9302            .map_err(|_| {
9303                failure(
9304                    NodeFailureCode::HostDirectoryReadTimedOut,
9305                    "host directory browse exceeded its bounded deadline",
9306                )
9307            })?
9308            .map_err(|_| {
9309                failure(
9310                    NodeFailureCode::HostDirectoryReadFailed,
9311                    "host directory browse task failed",
9312                )
9313            })?
9314            .map_err(host_directory_failure)
9315    }
9316
9317    async fn read_workspace_file(
9318        &self,
9319        workspace_id: WorkspaceId,
9320        path: RepositoryPath,
9321    ) -> Result<WorkspaceFileRead, NodeFailure> {
9322        let canonical_root = self.workspace_root(&workspace_id)?;
9323        let permit = self
9324            .inspection_slots
9325            .clone()
9326            .try_acquire_owned()
9327            .map_err(|_| {
9328                failure(
9329                    NodeFailureCode::BackendBusy,
9330                    "workspace file read capacity is busy",
9331                )
9332            })?;
9333        let path_to_read = path.clone();
9334        let task = tokio::task::spawn_blocking(move || {
9335            let _permit = permit;
9336            read_workspace_file_from_disk(Path::new(&canonical_root), &path_to_read)
9337        });
9338        let content = timeout(
9339            Duration::from_millis(WORKSPACE_FILE_READ_TIMEOUT_MS),
9340            task,
9341        )
9342        .await
9343        .map_err(|_| {
9344            failure(
9345                NodeFailureCode::RepositoryFileReadTimedOut,
9346                "repository file read exceeded its bounded deadline",
9347            )
9348        })?
9349        .map_err(|_| {
9350            failure(
9351                NodeFailureCode::RepositoryFileReadFailed,
9352                "repository file read task failed",
9353            )
9354        })?
9355        .map_err(workspace_file_failure)?;
9356        let content = match content {
9357            WorkspaceFileBytes::Utf8(text) => {
9358                let revision = Some(workspace_file_revision(text.as_bytes()));
9359                return Ok(WorkspaceFileRead {
9360                    workspace_id,
9361                    path,
9362                    content: WorkspaceFileContent::Utf8 {
9363                        byte_len: u32::try_from(text.len())
9364                            .expect("bounded workspace text length must fit u32"),
9365                        text,
9366                    },
9367                    revision,
9368                });
9369            }
9370            WorkspaceFileBytes::NonUtf8 { byte_length } => {
9371                WorkspaceFileContent::NonUtf8 {
9372                    byte_len: u32::try_from(byte_length)
9373                        .expect("bounded workspace file length must fit u32"),
9374                }
9375            }
9376            WorkspaceFileBytes::TooLarge => WorkspaceFileContent::TooLarge {
9377                limit_bytes: MAX_WORKSPACE_FILE_BYTES as u32,
9378            },
9379        };
9380        Ok(WorkspaceFileRead {
9381            workspace_id,
9382            path,
9383            content,
9384            revision: None,
9385        })
9386    }
9387
9388    async fn write_workspace_file(
9389        &self,
9390        workspace_id: WorkspaceId,
9391        path: RepositoryPath,
9392        expected_revision: WorkspaceFileRevision,
9393        text: String,
9394    ) -> Result<WorkspaceFileRead, NodeFailure> {
9395        let canonical_root = self.workspace_root(&workspace_id)?;
9396        let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9397            failure(NodeFailureCode::BackendBusy, "workspace file write capacity is busy")
9398        })?;
9399        let path_to_write = path.clone();
9400        let expected = expected_revision.as_str().to_owned();
9401        let text_to_write = text.clone();
9402        let task = tokio::task::spawn_blocking(move || {
9403            let _permit = permit;
9404            write_workspace_file_to_disk(
9405                Path::new(&canonical_root),
9406                &path_to_write,
9407                &expected,
9408                &text_to_write,
9409            )
9410        });
9411        let revision = timeout(Duration::from_millis(WORKSPACE_FILE_WRITE_TIMEOUT_MS), task)
9412            .await
9413            .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteTimedOut, "repository file write exceeded its bounded deadline"))?
9414            .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteFailed, "repository file write task failed"))?
9415            .map_err(workspace_file_write_failure)?;
9416        Ok(WorkspaceFileRead {
9417            workspace_id,
9418            path,
9419            content: WorkspaceFileContent::Utf8 {
9420                byte_len: u32::try_from(text.len()).expect("bounded workspace text length must fit u32"),
9421                text,
9422            },
9423            revision: Some(WorkspaceFileRevision::new(revision)
9424                .expect("workspace writer returns a valid SHA-256 revision")),
9425        })
9426    }
9427
9428    async fn create_workspace_file(
9429        &self,
9430        workspace_id: WorkspaceId,
9431        path: RepositoryPath,
9432    ) -> Result<WorkspaceFileRead, NodeFailure> {
9433        let canonical_root = self.workspace_root(&workspace_id)?;
9434        let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9435            failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
9436        })?;
9437        let path_to_create = path.clone();
9438        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
9439        let task_commit_state = Arc::clone(&commit_state);
9440        let task = tokio::task::spawn_blocking(move || {
9441            let _permit = permit;
9442            create_workspace_file_on_disk(
9443                Path::new(&canonical_root),
9444                &path_to_create,
9445                &task_commit_state,
9446            )
9447        });
9448        let revision = settle_workspace_entry_create(
9449            task,
9450            commit_state,
9451            Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
9452            "repository file create exceeded its bounded deadline",
9453            "repository file create task failed",
9454        )
9455        .await?;
9456        Ok(WorkspaceFileRead {
9457            workspace_id,
9458            path,
9459            content: WorkspaceFileContent::Utf8 {
9460                text: String::new(),
9461                byte_len: 0,
9462            },
9463            revision: Some(WorkspaceFileRevision::new(revision)
9464                .expect("workspace creator returns a valid SHA-256 revision")),
9465        })
9466    }
9467
9468    async fn create_workspace_directory(
9469        &self,
9470        workspace_id: WorkspaceId,
9471        path: RepositoryPath,
9472    ) -> Result<WorkspaceEntry, NodeFailure> {
9473        let canonical_root = self.workspace_root(&workspace_id)?;
9474        let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9475            failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
9476        })?;
9477        let path_to_create = path.clone();
9478        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
9479        let task_commit_state = Arc::clone(&commit_state);
9480        let task = tokio::task::spawn_blocking(move || {
9481            let _permit = permit;
9482            create_workspace_directory_on_disk(
9483                Path::new(&canonical_root),
9484                &path_to_create,
9485                &task_commit_state,
9486            )
9487        });
9488        settle_workspace_entry_create(
9489            task,
9490            commit_state,
9491            Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
9492            "repository directory create exceeded its bounded deadline",
9493            "repository directory create task failed",
9494        )
9495        .await?;
9496        Ok(WorkspaceEntry {
9497            relative_path: path,
9498            kind: WorkspaceEntryKind::Directory,
9499        })
9500    }
9501
9502    async fn read_git_history(
9503        &self,
9504        workspace_id: WorkspaceId,
9505        path: Option<RepositoryPath>,
9506        before: Option<GitObjectId>,
9507        limit: u16,
9508    ) -> Result<GitHistoryPage, NodeFailure> {
9509        let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9510            failure(NodeFailureCode::BackendBusy, "git history capacity is busy")
9511        })?;
9512        let root = self.workspace_root(&workspace_id)?;
9513        read_git_history_bounded(&root, path.as_ref(), before.as_ref(), limit).await
9514    }
9515
9516    async fn read_git_diff(
9517        &self,
9518        workspace_id: WorkspaceId,
9519        request: GitDiffRequest,
9520    ) -> Result<GitDiff, NodeFailure> {
9521        let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9522            failure(NodeFailureCode::BackendBusy, "git diff capacity is busy")
9523        })?;
9524        let root = self.workspace_root(&workspace_id)?;
9525        read_git_diff_bounded(&root, request).await
9526    }
9527
9528    fn native_session_route_root(
9529        &self,
9530        route: &NativeSessionCatalogRoute,
9531    ) -> Result<Option<String>, NodeFailure> {
9532        route
9533            .validate()
9534            .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9535        match route.scope {
9536            gate4agent_types::NativeSessionCatalogScope::Workspace => route
9537                .workspace_id
9538                .as_ref()
9539                .map(|workspace_id| self.workspace_root(workspace_id))
9540                .transpose(),
9541            gate4agent_types::NativeSessionCatalogScope::Unregistered => Ok(None),
9542        }
9543    }
9544
9545    fn project_native_session_entries(
9546        &self,
9547        route: &NativeSessionCatalogRoute,
9548        entries: Vec<ScopedNativeSessionCatalogEntry>,
9549    ) -> Vec<crate::protocol::NativeSessionCatalogEntry> {
9550        let current_root = route
9551            .workspace_id
9552            .as_ref()
9553            .and_then(|workspace_id| self.workspace_root(workspace_id).ok());
9554        let records = self
9555            .session_records
9556            .lock()
9557            .unwrap_or_else(|poisoned| poisoned.into_inner());
9558        let mut seen_records = std::collections::HashSet::new();
9559        entries
9560            .into_iter()
9561            .map(|entry| {
9562                let record_id = route.workspace_id.as_ref().and_then(|workspace_id| {
9563                    let matches = records
9564                        .records
9565                        .values()
9566                        .filter(|record| {
9567                            record.provider == route.provider
9568                                && record.workspace_id == *workspace_id
9569                                && current_root.as_deref().is_some_and(|root| {
9570                                    platform::roots_equal(
9571                                        root,
9572                                        windows_path_text(&record.canonical_root),
9573                                    )
9574                                })
9575                                && record.provider_session.as_ref().is_some_and(|identity| {
9576                                    session_registry::same_provider_session(
9577                                        &record.provider,
9578                                        identity,
9579                                        &route.provider,
9580                                        &entry.provider_session,
9581                                    )
9582                                })
9583                        })
9584                        .map(|record| record.record_id.clone())
9585                        .collect::<Vec<_>>();
9586                    (matches.len() == 1
9587                        && seen_records.insert(matches[0].clone()))
9588                        .then(|| matches[0].clone())
9589                });
9590                let metadata = entry.metadata;
9591                crate::protocol::NativeSessionCatalogEntry {
9592                    selection_id: metadata.selection_id,
9593                    title: metadata.title,
9594                    modified_at_unix_ms: metadata.modified_at_unix_ms,
9595                    model: metadata.model,
9596                    message_count: metadata.message_count,
9597                    completed_turn_count: metadata.completed_turn_count,
9598                    external_group: entry.external_group,
9599                    record_id,
9600                }
9601            })
9602            .collect()
9603    }
9604
9605    async fn catalog_native_sessions(
9606        &self,
9607        route: NativeSessionCatalogRoute,
9608        limit: u16,
9609    ) -> Result<(
9610        Vec<crate::protocol::NativeSessionCatalogEntry>,
9611        crate::protocol::NativeSessionCatalogSummary,
9612    ), NodeFailure> {
9613        let canonical_root = self.native_session_route_root(&route)?;
9614        let registered_workspace_roots = self.workspace_roots();
9615        let provider = route.provider.clone();
9616        let scope = route.scope;
9617        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9618            failure(
9619                NodeFailureCode::InvalidRequest,
9620                "native session catalog is not configured",
9621            )
9622        })?;
9623        let _permit = Arc::clone(&self.inspection_slots)
9624            .try_acquire_owned()
9625            .map_err(|_| {
9626                failure(
9627                    NodeFailureCode::BackendBusy,
9628                    "native session catalog capacity is busy",
9629                )
9630            })?;
9631        let page = run_native_session_catalog_operation(
9632            catalog,
9633            "native session catalog",
9634            move |catalog| {
9635                catalog.catalog_initial_for_scope(
9636                    &provider,
9637                    scope,
9638                    canonical_root.as_deref().map(Path::new),
9639                    &registered_workspace_roots,
9640                    limit,
9641                )
9642            },
9643        )
9644        .await?;
9645        let summary = crate::protocol::NativeSessionCatalogSummary {
9646            catalog_revision: page.revision,
9647            recent_cutoff_unix_ms: page.cutoff_unix_ms,
9648            recent_total_count: page.recent_total_count,
9649            older_total_count: page.older_total_count,
9650            recent_next_after_selection_id: page.next_after_selection_id,
9651            recent_has_more: page.remaining_count > 0,
9652        };
9653        Ok((self.project_native_session_entries(&route, page.entries), summary))
9654    }
9655
9656    async fn page_native_sessions(
9657        &self,
9658        route: NativeSessionCatalogRoute,
9659        window: crate::protocol::NativeSessionCatalogWindow,
9660        catalog_revision: u64,
9661        recent_cutoff_unix_ms: u64,
9662        after_selection_id: Option<String>,
9663        limit: u16,
9664    ) -> Result<crate::protocol::NativeSessionCatalogPage, NodeFailure> {
9665        let canonical_root = self.native_session_route_root(&route)?;
9666        let registered_workspace_roots = self.workspace_roots();
9667        let provider = route.provider.clone();
9668        let scope = route.scope;
9669        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9670            failure(
9671                NodeFailureCode::InvalidRequest,
9672                "native session catalog is not configured",
9673            )
9674        })?;
9675        let _permit = Arc::clone(&self.inspection_slots)
9676            .try_acquire_owned()
9677            .map_err(|_| {
9678                failure(
9679                    NodeFailureCode::BackendBusy,
9680                    "native session catalog capacity is busy",
9681                )
9682            })?;
9683        let page = run_native_session_catalog_operation(
9684            catalog,
9685            "native session catalog page",
9686            move |catalog| {
9687                Ok::<_, std::convert::Infallible>(catalog.catalog_page_for_scope(
9688                    &provider,
9689                    scope,
9690                    canonical_root.as_deref().map(Path::new),
9691                    &registered_workspace_roots,
9692                    window,
9693                    catalog_revision,
9694                    recent_cutoff_unix_ms,
9695                    after_selection_id.as_deref(),
9696                    limit,
9697                ))
9698            },
9699        )
9700        .await?;
9701        let page = page.map_err(|error| match error {
9702            NativeSessionCatalogError::StaleCatalog => failure(
9703                NodeFailureCode::StaleNativeSessionCatalog,
9704                "native session catalog revision or cursor is stale",
9705            ),
9706            _ => failure(
9707                NodeFailureCode::BackendOperationFailed,
9708                "native session catalog page failed",
9709            ),
9710        })?;
9711        let entries = self.project_native_session_entries(&route, page.entries);
9712        Ok(crate::protocol::NativeSessionCatalogPage {
9713            window: page.window,
9714            revision: page.revision,
9715            entries,
9716            next_after_selection_id: page.next_after_selection_id,
9717            remaining_count: page.remaining_count,
9718            has_more: page.remaining_count > 0,
9719        })
9720    }
9721
9722    async fn preview_native_session(
9723        &self,
9724        selection: NativeSessionSelection,
9725        message_limit: u16,
9726    ) -> Result<crate::protocol::NativeSessionPreview, NodeFailure> {
9727        selection
9728            .validate()
9729            .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9730        let canonical_root = self.native_session_route_root(&selection.route)?;
9731        let registered_workspace_roots = self.workspace_roots();
9732        let provider = selection.route.provider.clone();
9733        let scope = selection.route.scope;
9734        let catalog_revision = selection.catalog_revision;
9735        let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9736        let selection_id = selection.selection_id.clone();
9737        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9738            failure(
9739                NodeFailureCode::InvalidRequest,
9740                "native session preview is not configured",
9741            )
9742        })?;
9743        let _permit = Arc::clone(&self.inspection_slots)
9744            .try_acquire_owned()
9745            .map_err(|_| {
9746                failure(
9747                    NodeFailureCode::BackendBusy,
9748                    "native session preview capacity is busy",
9749                )
9750            })?;
9751        let preview = run_native_session_catalog_operation(
9752            catalog,
9753            "native session preview",
9754            move |catalog| {
9755                Ok::<_, std::convert::Infallible>(catalog.preview_for_scope(
9756                    &provider,
9757                    scope,
9758                    canonical_root.as_deref().map(Path::new),
9759                    &registered_workspace_roots,
9760                    catalog_revision,
9761                    recent_cutoff_unix_ms,
9762                    &selection_id,
9763                    message_limit,
9764                ))
9765            },
9766        )
9767        .await?;
9768        preview
9769            .map(Into::into)
9770            .map_err(native_session_preview_failure)
9771    }
9772
9773    async fn index_native_session(
9774        &self,
9775        selection: NativeSessionSelection,
9776        display_name: String,
9777    ) -> Result<ManagedSessionRecord, NodeFailure> {
9778        selection
9779            .validate()
9780            .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9781        if selection.route.scope
9782            != gate4agent_types::NativeSessionCatalogScope::Workspace
9783        {
9784            return Err(failure(
9785                NodeFailureCode::WorkspaceRegistrationRequired,
9786                "register the external project as a workspace before indexing or resuming it",
9787            ));
9788        }
9789        let workspace_id = selection
9790            .route
9791            .workspace_id
9792            .clone()
9793            .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9794        let canonical_root = self
9795            .native_session_route_root(&selection.route)?
9796            .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9797        let registered_workspace_roots = self.workspace_roots();
9798        let provider = selection.route.provider.clone();
9799        let catalog_revision = selection.catalog_revision;
9800        let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9801        let selection_id = selection.selection_id.clone();
9802        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9803            failure(
9804                NodeFailureCode::InvalidRequest,
9805                "native session index is not configured",
9806            )
9807        })?;
9808        let _permit = Arc::clone(&self.inspection_slots)
9809            .try_acquire_owned()
9810            .map_err(|_| {
9811                failure(
9812                    NodeFailureCode::BackendBusy,
9813                    "native session index capacity is busy",
9814                )
9815            })?;
9816        let resolved = run_native_session_catalog_operation(
9817            catalog,
9818            "native session index resolution",
9819            move |catalog| {
9820                Ok::<_, std::convert::Infallible>(catalog.resolve_selection_for_scope(
9821                    &provider,
9822                    gate4agent_types::NativeSessionCatalogScope::Workspace,
9823                    Some(Path::new(&canonical_root)),
9824                    &registered_workspace_roots,
9825                    catalog_revision,
9826                    recent_cutoff_unix_ms,
9827                    &selection_id,
9828                ))
9829            },
9830        )
9831        .await?
9832        .map_err(native_session_preview_failure)?;
9833        self.index_provider_session_with_policy(
9834            workspace_id,
9835            selection.route.provider,
9836            resolved.identity,
9837            display_name,
9838            true,
9839        )
9840    }
9841
9842    async fn preview_native_session_by_session_id(
9843        &self,
9844        workspace_id: WorkspaceId,
9845        provider: AgentId,
9846        session_id: String,
9847        message_limit: u16,
9848    ) -> Result<gate4agent_types::NativeSessionPreview, NodeFailure> {
9849        let canonical_root = self.workspace_root(&workspace_id)?;
9850        let registered_workspace_roots = self.workspace_roots();
9851        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9852            failure(
9853                NodeFailureCode::InvalidRequest,
9854                "native session preview is not configured",
9855            )
9856        })?;
9857        let _permit = Arc::clone(&self.inspection_slots)
9858            .try_acquire_owned()
9859            .map_err(|_| {
9860                failure(
9861                    NodeFailureCode::BackendBusy,
9862                    "native session preview capacity is busy",
9863                )
9864            })?;
9865        run_native_session_catalog_operation(
9866            catalog,
9867            "native session preview",
9868            move |catalog| {
9869                catalog.preview_session_id_for_workspace(
9870                    &provider,
9871                    Path::new(&canonical_root),
9872                    &registered_workspace_roots,
9873                    &session_id,
9874                    message_limit,
9875                )
9876            },
9877        )
9878        .await
9879    }
9880
9881    async fn register_workspace(
9882        &self,
9883        workspace_id: WorkspaceId,
9884        root: String,
9885    ) -> Result<WorkspaceSnapshot, NodeFailure> {
9886        validate_workspace_request_root(&root)?;
9887        let canonicalize_workspace_id = workspace_id.clone();
9888        let workspace = tokio::task::spawn_blocking(move || {
9889            WorkspaceConfig::new(canonicalize_workspace_id, root)
9890        })
9891        .await
9892        .map_err(|error| {
9893            failure(
9894                NodeFailureCode::BackendOperationFailed,
9895                &format!("workspace canonicalization task failed: {error}"),
9896            )
9897        })?
9898        .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
9899        let transaction = self
9900            .state_transaction
9901            .lock()
9902            .unwrap_or_else(|poisoned| poisoned.into_inner());
9903        let mut workspaces = self
9904            .workspaces
9905            .write()
9906            .unwrap_or_else(|poisoned| poisoned.into_inner());
9907        if workspaces.contains_key(&workspace_id) {
9908            return Err(failure(
9909                NodeFailureCode::DuplicateWorkspaceId,
9910                "workspace ID is already registered",
9911            ));
9912        }
9913        if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
9914            platform::roots_equal(existing_root, workspace.canonical_root())
9915        }) {
9916            return Err(failure(
9917                NodeFailureCode::DuplicateWorkspaceRoot,
9918                &format!("workspace root is already registered as '{existing_id}'"),
9919            ));
9920        }
9921        {
9922            let records = self
9923                .session_records
9924                .lock()
9925                .unwrap_or_else(|poisoned| poisoned.into_inner());
9926            if let Some(record) = records.records.values().find(|record| {
9927                record.workspace_id == workspace_id
9928                    && !record
9929                        .canonical_root
9930                        .as_utf8()
9931                        .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9932            }) {
9933                return Err(failure(
9934                    NodeFailureCode::SessionRecordConflict,
9935                    &format!(
9936                        "workspace ID is retained by session record '{}' at another root",
9937                        record.record_id,
9938                    ),
9939                ));
9940            }
9941        }
9942        let snapshot = WorkspaceSnapshot {
9943            workspace_id: workspace_id.clone(),
9944            canonical_root: opaque_windows_path(workspace.canonical_root().to_owned()),
9945            sessions: Vec::new(),
9946            worktree_service_mode: Some(WorktreeServiceMode::Manual),
9947            managed_worktree_profiles: Some(WorktreeProfileInventory {
9948                profiles: Vec::new(),
9949            }),
9950        };
9951        workspaces.insert(workspace_id.clone(), workspace.canonical_root().to_owned());
9952        drop(workspaces);
9953        let previous_records = {
9954            let mut records = self
9955                .session_records
9956                .lock()
9957                .unwrap_or_else(|poisoned| poisoned.into_inner());
9958            let previous = records.records.clone();
9959            for record in records.records.values_mut().filter(|record| {
9960                record.workspace_id == workspace_id
9961                    && record
9962                        .canonical_root
9963                        .as_utf8()
9964                        .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9965            }) {
9966                if record.active_session.is_none() {
9967                    record.state = detached_record_state(record);
9968                }
9969                if record.last_error.as_deref() == Some(WORKSPACE_UNAVAILABLE_ERROR) {
9970                    record.last_error = None;
9971                }
9972                record.updated_at_unix_ms = unix_time_ms();
9973            }
9974            previous
9975        };
9976        if let Err(error) = self.persist_state_locked() {
9977            self.workspaces
9978                .write()
9979                .unwrap_or_else(|poisoned| poisoned.into_inner())
9980                .remove(&workspace_id);
9981            self.session_records
9982                .lock()
9983                .unwrap_or_else(|poisoned| poisoned.into_inner())
9984                .records = previous_records;
9985            return Err(persistence_failure(error));
9986        }
9987        let restored = self
9988            .session_records
9989            .lock()
9990            .unwrap_or_else(|poisoned| poisoned.into_inner())
9991            .records
9992            .values()
9993            .filter(|record| record.workspace_id == workspace_id)
9994            .cloned()
9995            .collect::<Vec<_>>();
9996        drop(transaction);
9997        self.publish(NodeEvent::WorkspaceAdded {
9998            workspace: snapshot.clone(),
9999        });
10000        for record in restored {
10001            self.publish_record(record);
10002        }
10003        Ok(snapshot)
10004    }
10005
10006    async fn create_standalone_workspace(
10007        &self,
10008        workspace_id: WorkspaceId,
10009        root: String,
10010        initial_branch: Option<String>,
10011    ) -> Result<WorkspaceSnapshot, NodeFailure> {
10012        validate_workspace_request_root(&root)?;
10013        validate_git_revision("initial branch", initial_branch.as_deref())?;
10014        let preflight_workspace_id = workspace_id.clone();
10015        let preflight_root = root.clone();
10016        let canonical_candidate = tokio::task::spawn_blocking(move || {
10017            canonical_standalone_workspace_candidate(&preflight_workspace_id, &preflight_root)
10018        })
10019        .await
10020        .map_err(|_| failure(
10021            NodeFailureCode::BackendOperationFailed,
10022            "standalone workspace root preflight task failed",
10023        ))?
10024        .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
10025        {
10026            let workspaces = self
10027                .workspaces
10028                .read()
10029                .unwrap_or_else(|poisoned| poisoned.into_inner());
10030            if workspaces.contains_key(&workspace_id) {
10031                return Err(failure(
10032                    NodeFailureCode::DuplicateWorkspaceId,
10033                    "workspace ID is already registered",
10034                ));
10035            }
10036            if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
10037                platform::roots_equal(existing_root, &canonical_candidate)
10038            }) {
10039                return Err(failure(
10040                    NodeFailureCode::DuplicateWorkspaceRoot,
10041                    &format!("workspace root is already registered as '{existing_id}'"),
10042                ));
10043            }
10044        }
10045        let prepared = prepare_standalone_workspace(root, initial_branch.as_deref())
10046            .await
10047            .map_err(standalone_workspace_failure)?;
10048        prepared
10049            .verify_for_registration()
10050            .await
10051            .map_err(standalone_workspace_failure)?;
10052        match self
10053            .register_workspace(workspace_id, prepared.root().to_owned())
10054            .await
10055        {
10056            Ok(workspace) => Ok(workspace),
10057            Err(registration_error) => {
10058                if prepared.compensate_registration_failure().await {
10059                    Err(registration_error)
10060                } else {
10061                    Err(failure(
10062                        NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
10063                        "standalone workspace initialization succeeded but registration failed; recovery is required",
10064                    ))
10065                }
10066            }
10067        }
10068    }
10069
10070    fn unregister_workspace(&self, workspace_id: &WorkspaceId) -> Result<(), NodeFailure> {
10071        let bindings = self
10072            .session_bindings
10073            .lock()
10074            .unwrap_or_else(|poisoned| poisoned.into_inner());
10075        if bindings.values().any(|binding| &binding.workspace_id == workspace_id) {
10076            return Err(failure(
10077                NodeFailureCode::WorkspaceBusy,
10078                "workspace retains a bound session; remove every session before unregistering it",
10079            ));
10080        }
10081        drop(bindings);
10082        let transaction = self
10083            .state_transaction
10084            .lock()
10085            .unwrap_or_else(|poisoned| poisoned.into_inner());
10086        let mut workspaces = self
10087            .workspaces
10088            .write()
10089            .unwrap_or_else(|poisoned| poisoned.into_inner());
10090        if !workspaces.contains_key(workspace_id) {
10091            return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
10092        }
10093        if workspaces.len() == 1 {
10094            return Err(failure(
10095                NodeFailureCode::LastWorkspace,
10096                "node must retain at least one workspace",
10097            ));
10098        }
10099        let removed_root = workspaces
10100            .remove(workspace_id)
10101            .expect("workspace presence was validated");
10102        drop(workspaces);
10103        let previous_records = {
10104            let mut records = self
10105                .session_records
10106                .lock()
10107                .unwrap_or_else(|poisoned| poisoned.into_inner());
10108            let previous = records.records.clone();
10109            for record in records
10110                .records
10111                .values_mut()
10112                .filter(|record| &record.workspace_id == workspace_id)
10113            {
10114                record.active_session = None;
10115                record.state = ManagedSessionState::Unavailable;
10116                record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
10117                record.updated_at_unix_ms = unix_time_ms();
10118            }
10119            previous
10120        };
10121        if let Err(error) = self.persist_state_locked() {
10122            self.workspaces
10123                .write()
10124                .unwrap_or_else(|poisoned| poisoned.into_inner())
10125                .insert(workspace_id.clone(), removed_root);
10126            self.session_records
10127                .lock()
10128                .unwrap_or_else(|poisoned| poisoned.into_inner())
10129                .records = previous_records;
10130            return Err(persistence_failure(error));
10131        }
10132        let unavailable = self
10133            .session_records
10134            .lock()
10135            .unwrap_or_else(|poisoned| poisoned.into_inner())
10136            .records
10137            .values()
10138            .filter(|record| &record.workspace_id == workspace_id)
10139            .cloned()
10140            .collect::<Vec<_>>();
10141        drop(transaction);
10142        self.publish(NodeEvent::WorkspaceRemoved {
10143            workspace_id: workspace_id.clone(),
10144        });
10145        for record in unavailable {
10146            self.publish_record(record);
10147        }
10148        Ok(())
10149    }
10150
10151    async fn create_worktree(
10152        &self,
10153        source_workspace_id: WorkspaceId,
10154        workspace_id: WorkspaceId,
10155        target_root: String,
10156        branch: String,
10157        base: Option<String>,
10158    ) -> Result<(GitWorktreeSnapshot, WorkspaceSnapshot), NodeFailure> {
10159        self.require_worktree_service(&source_workspace_id)?;
10160        validate_workspace_request_root(&target_root)?;
10161        validate_git_revision("worktree base", base.as_deref())?;
10162        let source_root = self.workspace_root(&source_workspace_id)?;
10163        {
10164            let workspaces = self
10165                .workspaces
10166                .read()
10167                .unwrap_or_else(|poisoned| poisoned.into_inner());
10168            if workspaces.contains_key(&workspace_id) {
10169                return Err(failure(
10170                    NodeFailureCode::DuplicateWorkspaceId,
10171                    "workspace ID is already registered",
10172                ));
10173            }
10174            if let Some((existing_id, _)) = workspaces
10175                .iter()
10176                .find(|(_, root)| worktree_paths_equal(root, &target_root))
10177            {
10178                return Err(failure(
10179                    NodeFailureCode::DuplicateWorkspaceRoot,
10180                    &format!("workspace root is already registered as '{existing_id}'"),
10181                ));
10182            }
10183        }
10184        let mut worktree = create_git_worktree(
10185            &source_root,
10186            &target_root,
10187            &branch,
10188            base.as_deref(),
10189        )
10190        .await
10191        .map_err(git_worktree_failure)?;
10192        let workspace = match self
10193            .register_workspace(workspace_id, worktree.path.clone())
10194            .await
10195        {
10196            Ok(workspace) => workspace,
10197            Err(registration_error) => {
10198                if remove_git_worktree(&source_root, &worktree.path).await.is_err() {
10199                    return Err(failure(
10200                        NodeFailureCode::ManagedWorktreeRecoveryRequired,
10201                        "worktree registration failed and compensation requires recovery",
10202                    ));
10203                }
10204                return Err(failure(
10205                    registration_error.code,
10206                    &registration_error.message,
10207                ));
10208            }
10209        };
10210        worktree.workspace_id = Some(workspace.workspace_id.clone());
10211        Ok((protocol_worktree(worktree), workspace))
10212    }
10213
10214    async fn remove_worktree(
10215        &self,
10216        source_workspace_id: WorkspaceId,
10217        target_root: String,
10218    ) -> Result<Option<WorkspaceId>, NodeFailure> {
10219        self.require_worktree_service(&source_workspace_id)?;
10220        validate_workspace_request_root(&target_root)?;
10221        let source_root = self.workspace_root(&source_workspace_id)?;
10222        let target_root = normalize_worktree_removal_target(&target_root)
10223            .map_err(git_worktree_failure)?;
10224        let target = list_git_worktrees(&source_root)
10225            .await
10226            .map_err(git_worktree_failure)?
10227            .into_iter()
10228            .find(|worktree| worktree_paths_equal(&worktree.path, &target_root))
10229            .ok_or_else(|| {
10230                failure(
10231                    NodeFailureCode::WorktreeProtected,
10232                    "refusing to remove a path that is not in Git's worktree listing",
10233                )
10234            })?;
10235        let registered_workspace_id = {
10236            let workspaces = self
10237                .workspaces
10238                .read()
10239                .unwrap_or_else(|poisoned| poisoned.into_inner());
10240            let matched = workspaces
10241                .iter()
10242                .find(|(_, root)| worktree_paths_equal(root, &target.path))
10243                .map(|(workspace_id, _)| workspace_id.clone());
10244            if matched.is_some() && workspaces.len() == 1 {
10245                return Err(failure(
10246                    NodeFailureCode::LastWorkspace,
10247                    "node must retain at least one workspace",
10248                ));
10249            }
10250            matched
10251        };
10252        if let Some(workspace_id) = registered_workspace_id.as_ref() {
10253            let bindings = self
10254                .session_bindings
10255                .lock()
10256                .unwrap_or_else(|poisoned| poisoned.into_inner());
10257            if bindings
10258                .values()
10259                .any(|binding| &binding.workspace_id == workspace_id)
10260            {
10261                return Err(failure(
10262                    NodeFailureCode::WorkspaceBusy,
10263                    "worktree workspace retains a bound session; remove every session first",
10264                ));
10265            }
10266        }
10267        remove_git_worktree(&source_root, &target.path)
10268            .await
10269            .map_err(git_worktree_failure)?;
10270        if let Some(workspace_id) = registered_workspace_id.as_ref() {
10271            self.unregister_workspace(workspace_id)
10272                .map_err(|error| failure(error.code, &error.message))?;
10273        }
10274        Ok(registered_workspace_id)
10275    }
10276
10277    async fn cleanup_managed_worktree(
10278        &self,
10279        lease_id: &ManagedWorktreeLeaseId,
10280        explicit: bool,
10281    ) -> Result<ManagedWorktreeLeaseSnapshot, NodeFailure> {
10282        let lease = self.managed_worktrees.lock()
10283            .unwrap_or_else(|poisoned| poisoned.into_inner())
10284            .get(lease_id)
10285            .cloned()
10286            .ok_or_else(|| failure(
10287                NodeFailureCode::UnknownManagedWorktreeLease,
10288                "managed worktree lease does not exist",
10289            ))?;
10290        if lease.state == ManagedWorktreeLeaseState::Removed {
10291            return Ok(lease.snapshot());
10292        }
10293        if lease.has_holders() {
10294            return Err(failure(
10295                NodeFailureCode::ManagedWorktreeBusy,
10296                "managed worktree lease still has a session or durable-record holder",
10297            ));
10298        }
10299        if self
10300            .materializations
10301            .lock()
10302            .unwrap_or_else(|poisoned| poisoned.into_inner())
10303            .values()
10304            .any(|record| record.managed_lease_id() == Some(lease_id))
10305        {
10306            return Err(failure(
10307                NodeFailureCode::ManagedWorktreeRecoveryRequired,
10308                "managed worktree cleanup is blocked by session environment ownership",
10309            ));
10310        }
10311        if !explicit && lease.retention == ManagedWorktreeRetention::Retain {
10312            let snapshot = {
10313                let mut registry = self.managed_worktrees.lock()
10314                    .unwrap_or_else(|poisoned| poisoned.into_inner());
10315                let current = registry.get_mut(lease_id)
10316                    .expect("managed worktree lease remains present");
10317                current.state = ManagedWorktreeLeaseState::Retained;
10318                current.cleanup_failure = None;
10319                current.updated_at_unix_ms = unix_time_ms();
10320                current.snapshot()
10321            };
10322            if self.persist_state().is_err() {
10323                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
10324            }
10325            self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
10326            return Ok(snapshot);
10327        }
10328        let profile = self.managed_profile(&lease.source_workspace_id, &lease.profile_id)?;
10329        if profile.revision() != &lease.profile_revision {
10330            self.mark_managed_recovery_required(
10331                lease_id,
10332                ManagedWorktreeCleanupFailure::OwnershipConflict,
10333            );
10334            return Err(failure(
10335                NodeFailureCode::ManagedWorktreeRecoveryRequired,
10336                "managed worktree profile revision changed",
10337            ));
10338        }
10339        profile.validate_target_authority(&lease.lease_id, &lease.target_root).map_err(|message| {
10340            self.mark_managed_recovery_required(
10341                lease_id,
10342                ManagedWorktreeCleanupFailure::OwnershipConflict,
10343            );
10344            failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message)
10345        })?;
10346        let source_root = self.workspace_root(&lease.source_workspace_id)?;
10347        let listed = list_git_worktrees(&source_root).await.map_err(|error| {
10348            self.mark_managed_cleanup_blocked(lease_id, ManagedWorktreeCleanupFailure::Backend);
10349            managed_git_worktree_failure(error)
10350        })?;
10351        let target = listed.iter()
10352            .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
10353        if let Some(target) = target {
10354            if target.is_main || target.is_bare || !exact_owned_worktree(
10355                &lease,
10356                &target.path,
10357                target.branch.as_deref(),
10358                &target.head,
10359            ) {
10360                self.mark_managed_recovery_required(
10361                    lease_id,
10362                    ManagedWorktreeCleanupFailure::OwnershipConflict,
10363                );
10364                return Err(failure(
10365                    NodeFailureCode::ManagedWorktreeOwnershipConflict,
10366                    "Git worktree identity no longer matches the managed lease",
10367                ));
10368            }
10369            if target.prunable {
10370                self.mark_managed_cleanup_blocked(
10371                    lease_id,
10372                    ManagedWorktreeCleanupFailure::Prunable,
10373                );
10374                return Err(failure(
10375                    NodeFailureCode::ManagedWorktreeRecoveryRequired,
10376                    "managed worktree is prunable and requires recovery",
10377                ));
10378            }
10379            if let Err(error) = remove_git_worktree(&source_root, &target.path).await {
10380                let failure_kind = match error.kind {
10381                    GitWorktreeErrorKind::Dirty => ManagedWorktreeCleanupFailure::Dirty,
10382                    GitWorktreeErrorKind::Locked => ManagedWorktreeCleanupFailure::Locked,
10383                    GitWorktreeErrorKind::Protected => ManagedWorktreeCleanupFailure::OwnershipConflict,
10384                    GitWorktreeErrorKind::Conflict => ManagedWorktreeCleanupFailure::Busy,
10385                    GitWorktreeErrorKind::Invalid | GitWorktreeErrorKind::NotRepository
10386                    | GitWorktreeErrorKind::Failed => ManagedWorktreeCleanupFailure::Backend,
10387                };
10388                if failure_kind == ManagedWorktreeCleanupFailure::OwnershipConflict {
10389                    self.mark_managed_recovery_required(lease_id, failure_kind);
10390                } else {
10391                    self.mark_managed_cleanup_blocked(lease_id, failure_kind);
10392                }
10393                return Err(managed_git_worktree_failure(error));
10394            }
10395        } else {
10396            match std::fs::symlink_metadata(&lease.target_root) {
10397                Err(error) if error.kind() == io::ErrorKind::NotFound => {}
10398                Ok(_) => {
10399                    self.mark_managed_recovery_required(
10400                        lease_id,
10401                        ManagedWorktreeCleanupFailure::OwnershipConflict,
10402                    );
10403                    return Err(failure(
10404                        NodeFailureCode::ManagedWorktreeOwnershipConflict,
10405                        "managed target exists but is not owned by Git",
10406                    ));
10407                }
10408                Err(_) => {
10409                    self.mark_managed_cleanup_blocked(
10410                        lease_id,
10411                        ManagedWorktreeCleanupFailure::Backend,
10412                    );
10413                    return Err(failure(
10414                        NodeFailureCode::BackendOperationFailed,
10415                        "managed target identity could not be inspected",
10416                    ));
10417                }
10418            }
10419        }
10420        let registered = self.workspaces.read()
10421            .unwrap_or_else(|poisoned| poisoned.into_inner())
10422            .contains_key(&lease.workspace_id);
10423        if registered {
10424            self.unregister_workspace(&lease.workspace_id)?;
10425        }
10426        let removed = {
10427            self.managed_worktrees.lock()
10428                .unwrap_or_else(|poisoned| poisoned.into_inner())
10429                .tombstone(lease_id, unix_time_ms())
10430                .expect("managed worktree lease remains present during cleanup")
10431        };
10432        self.persist_state().map_err(persistence_failure)?;
10433        self.publish(NodeEvent::ManagedWorktreeRemoved { lease_id: lease_id.clone() });
10434        Ok(removed.snapshot())
10435    }
10436
10437    fn mark_managed_cleanup_blocked(
10438        &self,
10439        lease_id: &ManagedWorktreeLeaseId,
10440        failure_kind: ManagedWorktreeCleanupFailure,
10441    ) {
10442        let snapshot = {
10443            let mut registry = self.managed_worktrees.lock()
10444                .unwrap_or_else(|poisoned| poisoned.into_inner());
10445            let Some(lease) = registry.get_mut(lease_id) else { return };
10446            lease.state = ManagedWorktreeLeaseState::CleanupBlocked;
10447            lease.cleanup_failure = Some(failure_kind);
10448            lease.updated_at_unix_ms = unix_time_ms();
10449            lease.snapshot()
10450        };
10451        let _ = self.persist_state();
10452        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
10453    }
10454
10455    fn require_worktree_service(
10456        &self,
10457        source_workspace_id: &WorkspaceId,
10458    ) -> Result<(), NodeFailure> {
10459        if self.managed_worktrees.lock()
10460            .unwrap_or_else(|poisoned| poisoned.into_inner())
10461            .lease_for_workspace(source_workspace_id)
10462            .is_some()
10463        {
10464            return Err(failure(
10465                NodeFailureCode::ManagedWorktreeOwnershipConflict,
10466                "managed worktree targets are internal-only workspaces",
10467            ));
10468        }
10469        match self
10470            .worktree_service_modes
10471            .get(source_workspace_id)
10472            .copied()
10473            .unwrap_or(WorktreeServiceMode::Manual)
10474        {
10475            WorktreeServiceMode::Manual => Ok(()),
10476            WorktreeServiceMode::Managed | WorktreeServiceMode::Off => Err(failure(
10477                NodeFailureCode::UnsupportedSpawnCapability,
10478                "Git worktree service mode is unavailable in this node build",
10479            )),
10480        }
10481    }
10482
10483    fn managed_reservation_conflict(
10484        &self,
10485        workspace_id: Option<&WorkspaceId>,
10486        root: Option<&str>,
10487    ) -> bool {
10488        self.managed_worktrees.lock()
10489            .unwrap_or_else(|poisoned| poisoned.into_inner())
10490            .active_records()
10491            .any(|lease| {
10492                workspace_id.is_some_and(|workspace_id| {
10493                    workspace_id == &lease.source_workspace_id
10494                        || workspace_id == &lease.workspace_id
10495                }) || root.is_some_and(|root| worktree_paths_equal(root, &lease.target_root))
10496            })
10497    }
10498
10499    fn reject_managed_reservation(
10500        &self,
10501        workspace_id: Option<&WorkspaceId>,
10502        root: Option<&str>,
10503    ) -> Result<(), NodeFailure> {
10504        if self.managed_reservation_conflict(workspace_id, root) {
10505            Err(failure(
10506                NodeFailureCode::ManagedWorktreeOwnershipConflict,
10507                "managed worktree ownership may only be mutated through its lease cleanup",
10508            ))
10509        } else {
10510            Ok(())
10511        }
10512    }
10513
10514    fn bound_workspace_root(&self, address: &SessionAddress) -> Result<String, NodeFailure> {
10515        self.validate_address(address)?;
10516        self.workspace_root(&address.workspace_id)
10517    }
10518
10519    /// `Resume`/`ResumeWithPrompt` are always a raw-PTY relaunch (see
10520    /// `require_policy`'s own doc on those two requirements), so they keep
10521    /// gating on `binding.runtime_policy`'s PTY-terminal-text-inference
10522    /// flags exactly as before; `pipe_transport`/`acp_transport` stay
10523    /// `false` on the `require_policy` call below for them because a PTY
10524    /// resume never needs those facts, not because this method cannot look
10525    /// them up.
10526    ///
10527    /// `SemanticPrompt` -- the requirement `Prompt`/`Paste` share -- reads
10528    /// the SAME `binding.runtime_policy` every other requirement reads here
10529    /// (one computed policy, read by every gate). That policy is no longer
10530    /// PTY-terminal-verification-shaped for an ACP session --
10531    /// `spawn_session_with_deadline` computes it BY TRANSPORT now, through
10532    /// `provider_runtime::policy_for_transport` -- so consulting it for
10533    /// `Prompt`/`Paste` on ACP is sound where it used to gate the wrong fact
10534    /// (this is what made an ACP `Prompt` come back `UnsupportedCapability`
10535    /// even though a prompt is an ACP session's only interface). Pipe keeps
10536    /// reading the same PTY-probe-derived value it always has --
10537    /// `policy_for_transport` passes Pipe through unchanged this pass (see
10538    /// its own doc for why).
10539    ///
10540    /// What this method still adds on top, for ACP/Pipe only, is a
10541    /// turn-in-flight PRECONDITION ahead of that policy check: a session
10542    /// takes one turn at a time, so a `Prompt`/`Paste` is refused by name
10543    /// (`NodeFailureCode::TurnInFlight`) while a turn is already running,
10544    /// read off the same live `SessionSnapshot` state `require_acp_session`
10545    /// already reads for the ACP control verbs -- `TurnStarted` observed
10546    /// with no matching `TurnCompleted` yet is exactly `ProviderSnapshot::
10547    /// lead_activity != Idle`. Sending a second prompt mid-turn would put
10548    /// its fate in vendor-specific behaviour this node cannot observe (one
10549    /// agent may queue it, another drop it, another interleave it), so this
10550    /// refuses it outright rather than gambling on that -- a rule with no
10551    /// PTY analogue, which is why PTY does not take this extra precondition.
10552    fn require_session_runtime_policy(
10553        &self,
10554        address: &SessionAddress,
10555        requirement: ProviderRuntimeRequirement,
10556    ) -> Result<ProviderRuntimePolicy, NodeFailure> {
10557        let runtime_policy = {
10558            let bindings = self
10559                .session_bindings
10560                .lock()
10561                .unwrap_or_else(|poisoned| poisoned.into_inner());
10562            let binding = bindings
10563                .get(&address.session.instance_id)
10564                .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10565            if binding.workspace_id != address.workspace_id {
10566                return Err(failure(
10567                    NodeFailureCode::SessionWorkspaceMismatch,
10568                    "session belongs to another workspace",
10569                ));
10570            }
10571            if binding.generation != address.session.generation {
10572                return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10573            }
10574            binding.runtime_policy
10575        };
10576        if requirement == ProviderRuntimeRequirement::SemanticPrompt {
10577            let snapshot = self.internal_session_snapshot(address)?;
10578            if matches!(snapshot.transport, TransportKind::Acp | TransportKind::Pipe)
10579                && snapshot.provider.lead_activity != ProviderActivity::Idle
10580            {
10581                return Err(failure(
10582                    NodeFailureCode::TurnInFlight,
10583                    "session already has a turn in flight; wait for it to complete before sending another prompt",
10584                ));
10585            }
10586        }
10587        require_policy(runtime_policy, false, false, requirement)
10588            .map(|()| runtime_policy)
10589            .map_err(|_| failure(
10590                NodeFailureCode::UnsupportedCapability,
10591                "session runtime policy does not admit this semantic operation",
10592            ))
10593    }
10594
10595    fn remove_binding(&self, address: &SessionAddress) -> Option<SessionBinding> {
10596        let mut bindings = self
10597            .session_bindings
10598            .lock()
10599            .unwrap_or_else(|poisoned| poisoned.into_inner());
10600        if bindings.get(&address.session.instance_id).is_some_and(|binding| {
10601            binding.workspace_id == address.workspace_id
10602                && binding.generation == address.session.generation
10603        }) {
10604            let removed = bindings.remove(&address.session.instance_id);
10605            self.clear_terminal_frame_watermark(address);
10606            SessionLifecycleHook::on_binding_removed(self, address, removed.as_ref());
10607            return removed;
10608        }
10609        None
10610    }
10611
10612    fn cleanup_session_owned_materialization(
10613        &self,
10614        address: &SessionAddress,
10615        binding: &SessionBinding,
10616    ) -> Result<(), NodeFailure> {
10617        let Some(id) = binding.materialization_id.as_ref() else {
10618            return Ok(());
10619        };
10620        let owner = self
10621            .materializations
10622            .lock()
10623            .unwrap_or_else(|poisoned| poisoned.into_inner())
10624            .get(id)
10625            .map(|record| record.owner().clone());
10626        match owner {
10627            None | Some(MaterializationOwner::Record { .. }) => Ok(()),
10628            Some(MaterializationOwner::Session {
10629                incarnation_id,
10630                instance_id,
10631                generation,
10632            }) if incarnation_id == self.incarnation_id
10633                && instance_id == address.session.instance_id
10634                && generation == address.session.generation =>
10635            {
10636                self.cleanup_materialization(id)
10637            }
10638            Some(MaterializationOwner::Session { .. }) => Err(failure(
10639                NodeFailureCode::BackendOperationFailed,
10640                "session environment ownership requires recovery",
10641            )),
10642        }
10643    }
10644
10645    fn clear_terminal_frame_watermark(&self, address: &SessionAddress) {
10646        let mut watermarks = self
10647            .terminal_frame_watermarks
10648            .lock()
10649            .unwrap_or_else(|poisoned| poisoned.into_inner());
10650        if watermarks
10651            .get(&address.session.instance_id)
10652            .is_some_and(|(current, _)| current == address)
10653        {
10654            watermarks.remove(&address.session.instance_id);
10655        }
10656    }
10657
10658    #[cfg(test)]
10659    fn address_for(
10660        &self,
10661        instance_id: AgentInstanceId,
10662        generation: SessionGeneration,
10663    ) -> Option<SessionAddress> {
10664        let binding = self.session_bindings
10665            .lock()
10666            .unwrap_or_else(|poisoned| poisoned.into_inner())
10667            .get(&instance_id)
10668            .cloned()?;
10669        (binding.generation == generation).then_some(SessionAddress {
10670            workspace_id: binding.workspace_id,
10671            session: SessionKey { instance_id, generation },
10672        })
10673    }
10674
10675    fn arm_resume(
10676        &self,
10677        address: &SessionAddress,
10678        command_id: CommandId,
10679        runtime_policy: ProviderRuntimePolicy,
10680    ) -> Result<(), NodeFailure> {
10681        let mut bindings = self
10682            .session_bindings
10683            .lock()
10684            .unwrap_or_else(|poisoned| poisoned.into_inner());
10685        let binding = bindings
10686            .get_mut(&address.session.instance_id)
10687            .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10688        if binding.workspace_id != address.workspace_id {
10689            return Err(failure(
10690                NodeFailureCode::SessionWorkspaceMismatch,
10691                "session belongs to another workspace",
10692            ));
10693        }
10694        if binding.generation != address.session.generation {
10695            return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10696        }
10697        binding.pending_resume = Some((binding.generation, command_id, runtime_policy));
10698        Ok(())
10699    }
10700
10701    fn clear_armed_resume(&self, address: &SessionAddress) {
10702        let mut bindings = self
10703            .session_bindings
10704            .lock()
10705            .unwrap_or_else(|poisoned| poisoned.into_inner());
10706        if let Some(binding) = bindings.get_mut(&address.session.instance_id) {
10707            if binding.workspace_id == address.workspace_id
10708                && binding.generation == address.session.generation
10709            {
10710                binding.pending_resume = None;
10711            }
10712        }
10713    }
10714
10715    fn publish_control(&self, event: ControlEvent) {
10716        let revokes_harness_mcp = matches!(
10717            &event.event,
10718            ControlEventKind::Exited { .. }
10719                | ControlEventKind::Failed { .. }
10720                | ControlEventKind::Removed
10721        );
10722        let (address, previous_address, record_id, identity_admitted, resume_command_rejected) = {
10723            let mut bindings = self
10724                .session_bindings
10725                .lock()
10726                .unwrap_or_else(|poisoned| poisoned.into_inner());
10727            let Some(binding) = bindings.get_mut(&event.instance_id) else {
10728                return;
10729            };
10730            let previous_address = SessionAddress {
10731                workspace_id: binding.workspace_id.clone(),
10732                session: SessionKey {
10733                    instance_id: event.instance_id,
10734                    generation: binding.generation,
10735                },
10736            };
10737            let mut resume_command_rejected = false;
10738            if binding.generation == event.generation {
10739                if let Some((pending_generation, pending_command_id, _)) = binding.pending_resume {
10740                    let rejected_resume = matches!(
10741                        &event.event,
10742                        ControlEventKind::CommandRejected { .. }
10743                    ) && event.command_id == Some(pending_command_id);
10744                    let resume_failed = matches!(
10745                        &event.event,
10746                        ControlEventKind::ResumeDenied { .. }
10747                            | ControlEventKind::ResumeFailed { .. }
10748                    );
10749                    if pending_generation == binding.generation
10750                        && (rejected_resume || resume_failed)
10751                    {
10752                        resume_command_rejected = rejected_resume;
10753                        binding.pending_resume = None;
10754                    }
10755                }
10756            } else {
10757                let expected = binding.generation.0.checked_add(1);
10758                let authorized = matches!(&event.event, ControlEventKind::ResumeAuthorized { .. });
10759                if !binding.pending_resume.is_some_and(|(generation, _, _)| {
10760                    generation == binding.generation
10761                })
10762                    || expected != Some(event.generation.0)
10763                    || !authorized
10764                {
10765                    return;
10766                }
10767                let (_, _, runtime_policy) = binding
10768                    .pending_resume
10769                    .expect("authorized resume retains its pending runtime policy");
10770                binding.generation = event.generation;
10771                binding.runtime_policy = runtime_policy;
10772                binding.pending_resume = None;
10773            }
10774            (
10775                SessionAddress {
10776                    workspace_id: binding.workspace_id.clone(),
10777                    session: SessionKey {
10778                        instance_id: event.instance_id,
10779                        generation: event.generation,
10780                    },
10781                },
10782                (previous_address.session.generation != event.generation)
10783                    .then_some(previous_address),
10784                binding.record_id.clone(),
10785                binding.runtime_policy.provider_session_identity,
10786                resume_command_rejected,
10787            )
10788        };
10789        if let Some(previous_address) = previous_address.as_ref() {
10790            self.clear_terminal_frame_watermark(previous_address);
10791        }
10792        if let Some(record_id) = record_id {
10793            self.reconcile_managed_record(
10794                &record_id,
10795                &address,
10796                &event,
10797                identity_admitted,
10798                resume_command_rejected,
10799            );
10800        }
10801        // Every state change a session's process lifecycle can reach gets a
10802        // line here: this is the single chokepoint every `ControlEventKind`
10803        // passes through regardless of transport (PTY/pipe/ACP) or whether it
10804        // came from a fresh spawn or a resume. Per-message provider chatter
10805        // (`ProviderEvent`/`ProviderGap`/terminal frames) is deliberately not
10806        // matched here -- that is exactly the per-frame noise this crate
10807        // family's level discipline forbids at INFO.
10808        match &event.event {
10809            ControlEventKind::Running { process_id } => {
10810                #[cfg(unix)]
10811                if let (Some(proxy), Some(pid)) = (self.signer_proxy.get(), process_id) {
10812                    proxy.attach_pid(&format!("{}-{}", self.incarnation_id, address.session.instance_id.0), *pid);
10813                }
10814                tracing::info!(
10815                    node_id = %self.node_id,
10816                    workspace_id = %address.workspace_id,
10817                    session = ?address.session,
10818                    pid = ?process_id,
10819                    "session process is running",
10820                );
10821            }
10822            ControlEventKind::Exited { exit_code, forced } => {
10823                #[cfg(unix)]
10824                if let Some(proxy) = self.signer_proxy.get().cloned() {
10825                    let key = format!("{}-{}", self.incarnation_id, address.session.instance_id.0);
10826                    if proxy.has_session(&key) {
10827                        // Revoke at core off the event path (blocking uplink call).
10828                        std::thread::spawn(move || proxy.release(&key));
10829                    }
10830                }
10831                tracing::info!(
10832                    node_id = %self.node_id,
10833                    workspace_id = %address.workspace_id,
10834                    session = ?address.session,
10835                    exit_code = ?exit_code,
10836                    forced = forced,
10837                    "session process exited",
10838                );
10839            }
10840            ControlEventKind::Failed { message } => {
10841                tracing::warn!(
10842                    node_id = %self.node_id,
10843                    workspace_id = %address.workspace_id,
10844                    session = ?address.session,
10845                    cause = %message,
10846                    "session failed",
10847                );
10848            }
10849            ControlEventKind::CommandRejected { message } => {
10850                tracing::warn!(
10851                    node_id = %self.node_id,
10852                    workspace_id = %address.workspace_id,
10853                    session = ?address.session,
10854                    cause = %message,
10855                    "session command rejected",
10856                );
10857            }
10858            ControlEventKind::Removed => {
10859                tracing::info!(
10860                    node_id = %self.node_id,
10861                    workspace_id = %address.workspace_id,
10862                    session = ?address.session,
10863                    "session removed",
10864                );
10865            }
10866            ControlEventKind::ResumeAuthorized { session: provider_session } => {
10867                tracing::info!(
10868                    node_id = %self.node_id,
10869                    workspace_id = %address.workspace_id,
10870                    session = ?address.session,
10871                    provider_session = ?provider_session,
10872                    "session resume authorized",
10873                );
10874            }
10875            ControlEventKind::Resumed { session: provider_session, process_id } => {
10876                tracing::info!(
10877                    node_id = %self.node_id,
10878                    workspace_id = %address.workspace_id,
10879                    session = ?address.session,
10880                    provider_session = ?provider_session,
10881                    pid = ?process_id,
10882                    "session resumed",
10883                );
10884            }
10885            ControlEventKind::ResumeDenied { reason } => {
10886                tracing::warn!(
10887                    node_id = %self.node_id,
10888                    workspace_id = %address.workspace_id,
10889                    session = ?address.session,
10890                    cause = %reason,
10891                    "session resume denied",
10892                );
10893            }
10894            ControlEventKind::ResumeFailed { message } => {
10895                tracing::warn!(
10896                    node_id = %self.node_id,
10897                    workspace_id = %address.workspace_id,
10898                    session = ?address.session,
10899                    cause = %message,
10900                    "session resume failed",
10901                );
10902            }
10903            _ => {}
10904        }
10905        self.apply_pending_acp_options(&address, &event);
10906        self.apply_acp_auto_permit(&address, &event);
10907        let stream_chunk = agent_stream_chunk(&event);
10908        self.publish(NodeEvent::Control {
10909            address: address.clone(),
10910            event,
10911        });
10912        if let Some(chunk) = stream_chunk {
10913            self.publish_agent_stream_chunk(address.clone(), chunk);
10914        }
10915        if revokes_harness_mcp {
10916            SessionLifecycleHook::on_session_ended(self, &address);
10917        }
10918    }
10919
10920    fn reconcile_managed_record(
10921        &self,
10922        record_id: &SessionRecordId,
10923        address: &SessionAddress,
10924        event: &ControlEvent,
10925        identity_admitted: bool,
10926        resume_command_rejected: bool,
10927    ) {
10928        let _transaction = self
10929            .state_transaction
10930            .lock()
10931            .unwrap_or_else(|poisoned| poisoned.into_inner());
10932        let observed_identity = match &event.event {
10933            ControlEventKind::ProviderEvent {
10934                event: gate4agent_types::ProviderEvent::SessionIdentityObserved { identity },
10935                ..
10936            } if identity_admitted => Some(identity.clone()),
10937            _ => None,
10938        };
10939        let identity_was_observed = observed_identity.is_some();
10940        let replacement_id = observed_identity
10941            .as_ref()
10942            .and_then(|_| self.allocate_record_id().ok());
10943        let mut upserts = Vec::new();
10944        let mut removals = Vec::new();
10945        let mut rebind = None;
10946        let original_records;
10947        let original_materializations = self
10948            .materializations
10949            .lock()
10950            .unwrap_or_else(|poisoned| poisoned.into_inner())
10951            .clone();
10952        {
10953            let mut records = self
10954                .session_records
10955                .lock()
10956                .unwrap_or_else(|poisoned| poisoned.into_inner());
10957            original_records = records.records.clone();
10958            let Some(current_snapshot) = records.records.get(record_id).cloned() else {
10959                return;
10960            };
10961            if let Some(identity) = observed_identity {
10962                let existing_id = records
10963                    .records
10964                    .values()
10965                    .find(|record| {
10966                        record.record_id != *record_id
10967                            && record.provider_session.as_ref().is_some_and(|record_identity| {
10968                                session_registry::same_provider_session(
10969                                    &record.provider,
10970                                    record_identity,
10971                                    &current_snapshot.provider,
10972                                    &identity,
10973                                )
10974                            })
10975                    })
10976                    .map(|record| record.record_id.clone());
10977                if let Some(existing_id) = existing_id {
10978                    let scope_matches = records.records.get(&existing_id).is_some_and(|record| {
10979                        record.mode == current_snapshot.mode
10980                            && record.workspace_id == current_snapshot.workspace_id
10981                            && record.environment_profile == current_snapshot.environment_profile
10982                            && record.bundle == current_snapshot.bundle
10983                            && record.context_id == current_snapshot.context_id
10984                            && record.context == current_snapshot.context
10985                            && record
10986                                .canonical_root
10987                                .as_utf8()
10988                                .zip(current_snapshot.canonical_root.as_utf8())
10989                                .is_some_and(|(left, right)| platform::roots_equal(left, right))
10990                    });
10991                    if !scope_matches {
10992                        if let Some(current) = records.records.get_mut(record_id) {
10993                            current.state = ManagedSessionState::Unavailable;
10994                            current.last_error =
10995                                Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR.to_owned());
10996                            current.updated_at_unix_ms = unix_time_ms();
10997                            upserts.push(current.clone());
10998                        }
10999                    } else {
11000                        let existing_is_active = records
11001                            .records
11002                            .get(&existing_id)
11003                            .is_some_and(|record| record.active_session.is_some());
11004                        if existing_is_active {
11005                            if let Some(current) = records.records.get_mut(record_id) {
11006                                current.state = ManagedSessionState::Unavailable;
11007                                current.last_error =
11008                                    Some(PROVIDER_SESSION_LIVE_CONFLICT_ERROR.to_owned());
11009                                current.updated_at_unix_ms = unix_time_ms();
11010                                upserts.push(current.clone());
11011                            }
11012                        } else {
11013                            if current_snapshot.provider_session.is_some() {
11014                                if let Some(current) = records.records.get_mut(record_id) {
11015                                    current.active_session = None;
11016                                    current.state = ManagedSessionState::Dormant;
11017                                    current.updated_at_unix_ms = unix_time_ms();
11018                                    upserts.push(current.clone());
11019                                }
11020                            } else {
11021                                records.records.remove(record_id);
11022                                removals.push(record_id.clone());
11023                            }
11024                            if let Some(existing) = records.records.get_mut(&existing_id) {
11025                                existing.provider_session = Some(identity.clone());
11026                                existing.active_session = Some(address.clone());
11027                                existing.state = ManagedSessionState::Live;
11028                                existing.updated_at_unix_ms = unix_time_ms();
11029                                existing.last_error = None;
11030                                upserts.push(existing.clone());
11031                            }
11032                            rebind = Some(existing_id);
11033                        }
11034                    }
11035                } else if current_snapshot
11036                    .provider_session
11037                    .as_ref()
11038                    .is_some_and(|current| {
11039                        !session_registry::same_provider_session(
11040                            &current_snapshot.provider,
11041                            current,
11042                            &current_snapshot.provider,
11043                            &identity,
11044                        )
11045                    })
11046                {
11047                    if let Some(current) = records.records.get_mut(record_id) {
11048                        current.active_session = None;
11049                        if current.bundle.is_some() || current.context.is_some() {
11050                            let had_bundle = current.bundle.is_some();
11051                            current.provider_session = None;
11052                            current.bundle = None;
11053                            current.context_id = None;
11054                            current.context = None;
11055                            current.state = ManagedSessionState::Unavailable;
11056                            current.last_error = Some(if had_bundle {
11057                                "bundle-unavailable".to_owned()
11058                            } else {
11059                                "context-unavailable".to_owned()
11060                            });
11061                        } else {
11062                            current.state = ManagedSessionState::Dormant;
11063                        }
11064                        current.updated_at_unix_ms = unix_time_ms();
11065                        upserts.push(current.clone());
11066                    }
11067                    if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
11068                        if let Some(current) = records.records.get_mut(record_id) {
11069                            current.state = ManagedSessionState::Unavailable;
11070                            current.last_error =
11071                                Some(MANAGED_SESSION_CAPACITY_ERROR.to_owned());
11072                            current.updated_at_unix_ms = unix_time_ms();
11073                            upserts.push(current.clone());
11074                        }
11075                    } else if let Some(new_record_id) = replacement_id {
11076                        let now = unix_time_ms();
11077                        let replacement = ManagedSessionRecord {
11078                            record_id: new_record_id.clone(),
11079                            display_name: format!(
11080                                "{} #{}",
11081                                current_snapshot.provider.as_str(),
11082                                records
11083                                    .records
11084                                    .values()
11085                                    .filter(|record| {
11086                                        record.provider == current_snapshot.provider
11087                                    })
11088                                    .count()
11089                                    .saturating_add(1),
11090                            ),
11091                            provider: current_snapshot.provider.clone(),
11092                            mode: current_snapshot.mode,
11093                            state: ManagedSessionState::Live,
11094                            workspace_id: current_snapshot.workspace_id,
11095                            canonical_root: current_snapshot.canonical_root,
11096                            provider_session: Some(identity),
11097                            active_session: Some(address.clone()),
11098                            environment_profile: current_snapshot.environment_profile,
11099                            bundle: current_snapshot.bundle,
11100                            context_id: current_snapshot.context_id,
11101                            context: current_snapshot.context,
11102                            // This is a fresh record_id representing the session under its
11103                            // newly-observed identity; it has not yet produced any clean
11104                            // exit of its own, so it starts with no exported pack (the old
11105                            // record, still addressable under its own record_id, keeps
11106                            // whatever exported_context it already had).
11107                            exported_context: None,
11108                            task_binding: current_snapshot.task_binding,
11109                            created_at_unix_ms: now,
11110                            updated_at_unix_ms: now,
11111                            last_error: None,
11112                        };
11113                        records
11114                            .records
11115                            .insert(new_record_id.clone(), replacement.clone());
11116                        upserts.push(replacement);
11117                        rebind = Some(new_record_id);
11118                    } else if let Some(current) = records.records.get_mut(record_id) {
11119                        current.state = ManagedSessionState::Unavailable;
11120                        current.last_error =
11121                            Some(PROVIDER_IDENTITY_ALLOCATION_ERROR.to_owned());
11122                        upserts.push(current.clone());
11123                    }
11124                } else if let Some(current) = records.records.get_mut(record_id) {
11125                    current.provider_session = Some(identity);
11126                    current.active_session = Some(address.clone());
11127                    current.state = ManagedSessionState::Live;
11128                    current.updated_at_unix_ms = unix_time_ms();
11129                    current.last_error = None;
11130                    upserts.push(current.clone());
11131                }
11132            } else if let Some(current) = records.records.get_mut(record_id) {
11133                if resume_command_rejected {
11134                    current.active_session = None;
11135                    current.state = detached_record_state(current);
11136                    current.updated_at_unix_ms = unix_time_ms();
11137                    current.last_error =
11138                        Some(PROVIDER_RESUME_REJECTED_ERROR.to_owned());
11139                    upserts.push(current.clone());
11140                } else {
11141                    match &event.event {
11142                    ControlEventKind::Running { .. }
11143                    | ControlEventKind::ResumeAuthorized { .. }
11144                    | ControlEventKind::Resumed { .. } => {
11145                        current.active_session = Some(address.clone());
11146                        current.state = active_record_state(
11147                            identity_admitted,
11148                            current.provider_session.is_some(),
11149                        );
11150                        current.updated_at_unix_ms = unix_time_ms();
11151                        current.last_error = None;
11152                        upserts.push(current.clone());
11153                    }
11154                    ControlEventKind::Exited { .. } | ControlEventKind::Removed => {
11155                        current.active_session = None;
11156                        current.state = detached_record_state(current);
11157                        current.updated_at_unix_ms = unix_time_ms();
11158                        upserts.push(current.clone());
11159                    }
11160                    ControlEventKind::Failed { message }
11161                    | ControlEventKind::ResumeFailed { message } => {
11162                        current.active_session = None;
11163                        current.state = detached_record_state(current);
11164                        current.updated_at_unix_ms = unix_time_ms();
11165                        current.last_error = Some(
11166                            session_registry::sanitized_record_error_summary(message),
11167                        );
11168                        upserts.push(current.clone());
11169                    }
11170                    ControlEventKind::ResumeDenied { reason } => {
11171                        current.active_session = None;
11172                        current.state = detached_record_state(current);
11173                        current.updated_at_unix_ms = unix_time_ms();
11174                        current.last_error = Some(
11175                            session_registry::sanitized_record_error_summary(reason),
11176                        );
11177                        upserts.push(current.clone());
11178                    }
11179                        _ => {}
11180                    }
11181                }
11182            }
11183        }
11184        let materialization_owner_record = identity_was_observed.then(|| {
11185            rebind.clone().unwrap_or_else(|| record_id.clone())
11186        }).and_then(|candidate| {
11187            self.session_records
11188                .lock()
11189                .unwrap_or_else(|poisoned| poisoned.into_inner())
11190                .records
11191                .get(&candidate)
11192                .filter(|record| {
11193                    record.provider_session.is_some()
11194                        && record.active_session.as_ref() == Some(address)
11195                })
11196                .map(|_| candidate)
11197        });
11198        if let Some(owner_record_id) = materialization_owner_record {
11199            let materialization_id = self
11200                .session_bindings
11201                .lock()
11202                .unwrap_or_else(|poisoned| poisoned.into_inner())
11203                .get(&address.session.instance_id)
11204                .and_then(|binding| binding.materialization_id.clone());
11205            if let Some(materialization_id) = materialization_id {
11206                let mut transfer_failed = false;
11207                {
11208                    let mut materializations = self
11209                        .materializations
11210                        .lock()
11211                        .unwrap_or_else(|poisoned| poisoned.into_inner());
11212                    if let Some(ownership) = materializations.get_mut(&materialization_id) {
11213                        match ownership.owner() {
11214                            MaterializationOwner::Session {
11215                                incarnation_id,
11216                                instance_id,
11217                                generation,
11218                            } if *incarnation_id == self.incarnation_id
11219                                && *instance_id == address.session.instance_id
11220                                && *generation == address.session.generation =>
11221                            {
11222                                transfer_failed = ownership
11223                                    .transfer_to_record(owner_record_id.clone(), unix_time_ms())
11224                                    .is_err();
11225                            }
11226                            MaterializationOwner::Record { record_id }
11227                                if record_id == &owner_record_id => {}
11228                            MaterializationOwner::Record { record_id } => {
11229                                let expected = record_id.clone();
11230                                transfer_failed = ownership
11231                                    .transfer_record_owner(
11232                                        &expected,
11233                                        owner_record_id.clone(),
11234                                        unix_time_ms(),
11235                                    )
11236                                    .is_err();
11237                            }
11238                            _ => transfer_failed = true,
11239                        }
11240                        if transfer_failed {
11241                            let _ = ownership.mark_recovery_required(unix_time_ms());
11242                        }
11243                    } else {
11244                        transfer_failed = true;
11245                    }
11246                }
11247                if transfer_failed {
11248                    if let Some(record) = self
11249                        .session_records
11250                        .lock()
11251                        .unwrap_or_else(|poisoned| poisoned.into_inner())
11252                        .records
11253                        .get_mut(&owner_record_id)
11254                    {
11255                        record.state = ManagedSessionState::Unavailable;
11256                        record.last_error = Some("environment-profile-unavailable".to_owned());
11257                        record.updated_at_unix_ms = unix_time_ms();
11258                        upserts.push(record.clone());
11259                    }
11260                }
11261            }
11262        }
11263        if let Some(new_record_id) = rebind.clone() {
11264            if let Some(binding) = self
11265                .session_bindings
11266                .lock()
11267                .unwrap_or_else(|poisoned| poisoned.into_inner())
11268                .get_mut(&address.session.instance_id)
11269            {
11270                binding.record_id = Some(new_record_id);
11271            }
11272        }
11273        if upserts.is_empty() && removals.is_empty() {
11274            return;
11275        }
11276        if self.persist_state_locked().is_err() {
11277            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
11278            let mut records = self
11279                .session_records
11280                .lock()
11281                .unwrap_or_else(|poisoned| poisoned.into_inner());
11282            records.records = original_records;
11283            *self
11284                .materializations
11285                .lock()
11286                .unwrap_or_else(|poisoned| poisoned.into_inner()) = original_materializations;
11287            if let Some(current) = records.records.get_mut(record_id) {
11288                current.state = ManagedSessionState::Unavailable;
11289                current.last_error = Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned());
11290                current.updated_at_unix_ms = unix_time_ms();
11291                upserts = vec![current.clone()];
11292            } else {
11293                upserts.clear();
11294            }
11295            removals.clear();
11296            drop(records);
11297            if let Some(binding) = self
11298                .session_bindings
11299                .lock()
11300                .unwrap_or_else(|poisoned| poisoned.into_inner())
11301                .get_mut(&address.session.instance_id)
11302            {
11303                binding.record_id = Some(record_id.clone());
11304            }
11305            eprintln!("gate4agent-node: {DURABLE_STATE_COMMIT_FAILED_ERROR}");
11306        }
11307        for record in upserts {
11308            self.publish_record(record);
11309        }
11310        for record_id in removals {
11311            self.publish(NodeEvent::SessionRecordRemoved { record_id });
11312        }
11313    }
11314
11315    fn snapshot(&self) -> NodeSnapshot {
11316        let control = self.handle.snapshot();
11317        let bindings = self
11318            .session_bindings
11319            .lock()
11320            .unwrap_or_else(|poisoned| poisoned.into_inner())
11321            .clone();
11322        let workspace_roots = self
11323            .workspaces
11324            .read()
11325            .unwrap_or_else(|poisoned| poisoned.into_inner())
11326            .clone();
11327        let environment_profiles = self
11328            .environment_profiles
11329            .read()
11330            .unwrap_or_else(|poisoned| poisoned.into_inner());
11331        let mut workspaces = workspace_roots
11332            .iter()
11333            .map(|(workspace_id, canonical_root)| {
11334                (
11335                    workspace_id.clone(),
11336                    WorkspaceSnapshot {
11337                        workspace_id: workspace_id.clone(),
11338                        canonical_root: opaque_windows_path(canonical_root.clone()),
11339                        sessions: Vec::new(),
11340                        worktree_service_mode: Some(
11341                            self.worktree_service_modes
11342                                .get(workspace_id)
11343                                .copied()
11344                                .unwrap_or(WorktreeServiceMode::Manual),
11345                        ),
11346                        managed_worktree_profiles: Some(WorktreeProfileInventory {
11347                            profiles: self
11348                                .managed_worktree_profiles
11349                                .get(workspace_id)
11350                                .into_iter()
11351                                .flat_map(BTreeMap::values)
11352                                .map(|profile| ManagedWorktreeProfileSummary {
11353                                    id: profile.profile_id().clone(),
11354                                    revision: profile.revision().clone(),
11355                                    retention: profile.retention(),
11356                                })
11357                                .collect(),
11358                        }),
11359                    },
11360                )
11361            })
11362            .collect::<BTreeMap<_, _>>();
11363        let mut agent_progress = Vec::new();
11364        for session in &control.sessions {
11365            let Some(binding) = bindings.get(&session.instance_id) else {
11366                continue;
11367            };
11368            if binding.generation != session.generation {
11369                continue;
11370            }
11371            if let Some(workspace) = workspaces.get_mut(&binding.workspace_id) {
11372                let address = SessionAddress {
11373                    workspace_id: binding.workspace_id.clone(),
11374                    session: SessionKey {
11375                        instance_id: session.instance_id,
11376                        generation: session.generation,
11377                    },
11378                };
11379                if let Some(progress) =
11380                    agent_progress_from_provider_snapshot(address, &session.provider)
11381                {
11382                    agent_progress.push(progress);
11383                }
11384                workspace.sessions.push(session.clone());
11385            }
11386        }
11387        NodeSnapshot {
11388            node_id: self.node_id.clone(),
11389            enabled_providers: self.enabled_providers.clone(),
11390            provider_runtime_statuses: {
11391                let updates = self
11392                    .provider_runtime_status_updates
11393                    .read()
11394                    .unwrap_or_else(|poisoned| poisoned.into_inner());
11395                ProviderRuntimeStatuses::new(
11396                    self.provider_runtime_statuses
11397                        .iter()
11398                        .filter(|status| !updates.contains_key(status.provider()))
11399                        .cloned()
11400                        .chain(updates.values().cloned()),
11401                )
11402                .expect("refreshed provider runtime inventory remains bounded and unique")
11403            },
11404            workspaces: workspaces.into_values().collect(),
11405            session_records: self
11406                .session_records
11407                .lock()
11408                .unwrap_or_else(|poisoned| poisoned.into_inner())
11409                .records
11410                .values()
11411                .cloned()
11412                .collect(),
11413            managed_worktrees: self.managed_worktrees
11414                .lock()
11415                .unwrap_or_else(|poisoned| poisoned.into_inner())
11416                .snapshots(),
11417            launch_inventory: Some(LaunchInventory {
11418                spawn_profiles: Some(
11419                    self.spawn_profiles
11420                        .iter()
11421                        .filter_map(|profile| {
11422                            resolved_spawn_profile_summary(profile, &environment_profiles)
11423                        })
11424                        .collect(),
11425                ),
11426                bundles: Some(
11427                    self.bundle_catalog
11428                        .read()
11429                        .unwrap_or_else(|poisoned| poisoned.into_inner())
11430                        .iter()
11431                        .map(NodeBundle::receipt)
11432                        .collect(),
11433                ),
11434                // Opaque station allowlist ids from empty-default catalog /
11435                // optional startup loader. Never secrets. Dig2 probe stays stub.
11436                network_allowlists: {
11437                    let catalog = self
11438                        .network_allowlist_catalog
11439                        .read()
11440                        .unwrap_or_else(|poisoned| poisoned.into_inner());
11441                    if catalog.is_empty() {
11442                        None
11443                    } else {
11444                        Some(catalog.ids().cloned().collect())
11445                    }
11446                },
11447            }),
11448            agent_progress,
11449        }
11450    }
11451
11452    fn publish(&self, event: NodeEvent) -> NodeEventEnvelope {
11453        assert!(
11454            !matches!(
11455                &event,
11456                NodeEvent::TerminalFrame { .. } | NodeEvent::AgentStream { .. }
11457            ),
11458            "terminal frame and agent stream events must use their own dedicated live channel",
11459        );
11460        let mut history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11461        let sequence = history
11462            .last_sequence
11463            .checked_add(1)
11464            .expect("node event sequence exhausted");
11465        if history.events.len() == NODE_EVENT_HISTORY_MAX {
11466            if let Some(evicted) = history.events.pop_front() {
11467                history.replay_floor_sequence = evicted
11468                    .sequence
11469                    .checked_add(1)
11470                    .expect("node replay floor sequence exhausted");
11471                history.removed_record_providers.remove(&evicted.sequence);
11472            }
11473        }
11474        match &event {
11475            NodeEvent::HarnessMcpReadCall { .. } => {
11476                panic!("transient harness MCP calls must not enter durable event history")
11477            }
11478            NodeEvent::SessionRecordUpserted { record } => {
11479                history
11480                    .record_providers
11481                    .insert(record.record_id.clone(), record.provider.clone());
11482            }
11483            NodeEvent::SessionRecordRemoved { record_id } => {
11484                if let Some(provider) = history.record_providers.remove(record_id) {
11485                    history.removed_record_providers.insert(sequence, provider);
11486                }
11487            }
11488            NodeEvent::ManagedWorktreeUpserted { .. }
11489            | NodeEvent::ManagedWorktreeRemoved { .. }
11490            | NodeEvent::Control { .. }
11491            | NodeEvent::ControllerChanged { .. }
11492            | NodeEvent::SessionRecordHistorySummarized { .. }
11493            | NodeEvent::WorkspaceAdded { .. }
11494            | NodeEvent::WorkspaceRemoved { .. }
11495            | NodeEvent::ResyncRequired { .. }
11496            | NodeEvent::AgentStream { .. }
11497            | NodeEvent::TerminalFrame { .. } => {}
11498        }
11499        let envelope = NodeEventEnvelope { sequence, event };
11500        history.events.push_back(envelope.clone());
11501        history.last_sequence = sequence;
11502        let _ = self.event_tx.send(envelope.clone());
11503        drop(history);
11504        envelope
11505    }
11506
11507    /// Publishes a transient (never durable) `NodeEvent` -- today only
11508    /// `NodeEvent::HarnessMcpReadCall` -- onto the dedicated
11509    /// `harness_mcp_proxy_event_tx` broadcast (see that field's own doc for
11510    /// why this must never ride the shared `event_tx` bus). Returns whether
11511    /// the send reached at least one subscriber: `broadcast::Sender::send`
11512    /// fails in exactly one way, no active receiver at that instant, and
11513    /// the caller (`HarnessMcpProxyRegistry::begin_call`) uses a `false`
11514    /// return to fail the pending call immediately rather than leave it to
11515    /// expire silently at `LOCAL_READ_TIMEOUT`/
11516    /// `MAX_HARNESS_MCP_CALL_DEADLINE_MS`.
11517    fn publish_transient(&self, event: NodeEvent) -> bool {
11518        assert!(event.requires_harness_mcp_proxy_capability());
11519        assert!(event.harness_mcp_contract_is_valid_at(unix_time_ms()));
11520        let envelope = NodeEventEnvelope { sequence: 0, event };
11521        match self.harness_mcp_proxy_event_tx.send(Arc::new(vec![envelope])) {
11522            Ok(_) => true,
11523            Err(broadcast::error::SendError(undelivered)) => {
11524                if let Some(NodeEventEnvelope {
11525                    event: NodeEvent::HarnessMcpReadCall { call_id, request, .. },
11526                    ..
11527                }) = undelivered.first() {
11528                    // `request.content_type` only, never a per-tool label --
11529                    // this crate does not decode `request.body` and does not
11530                    // hold `hatchery-harness-api` (Nested Control Plane
11531                    // doctrine, Law 3; see this crate's dependency on
11532                    // `gate4agent-node-protocol`'s own `Forbidden:` line).
11533                    tracing::warn!(
11534                        call_id = call_id.as_str(),
11535                        content_type = ?request.content_type,
11536                        cause = "no-subscribers",
11537                        "harness MCP proxy event could not be published",
11538                    );
11539                }
11540                false
11541            }
11542        }
11543    }
11544
11545    fn publish_terminal_frames(&self) {
11546        let snapshot = self.handle.snapshot();
11547        let bindings = self
11548            .session_bindings
11549            .lock()
11550            .unwrap_or_else(|poisoned| poisoned.into_inner());
11551        let candidates = snapshot
11552            .sessions
11553            .iter()
11554            .filter_map(|session| {
11555                let binding = bindings.get(&session.instance_id)?;
11556                if binding.generation != session.generation {
11557                    return None;
11558                }
11559                let frame = session.terminal_frame.clone()?;
11560                crate::session_signals::feed(
11561                    binding.workspace_id.as_str(),
11562                    session.instance_id.0,
11563                    session.agent_id.as_str(),
11564                    &frame.contents,
11565                );
11566                Some((
11567                    SessionAddress {
11568                        workspace_id: binding.workspace_id.clone(),
11569                        session: SessionKey {
11570                            instance_id: session.instance_id,
11571                            generation: session.generation,
11572                        },
11573                    },
11574                    frame,
11575                ))
11576            })
11577            .collect();
11578        drop(bindings);
11579        self.publish_terminal_frame_candidates(candidates);
11580    }
11581
11582    fn publish_terminal_frame_candidates(
11583        &self,
11584        candidates: Vec<(SessionAddress, TerminalFrame)>,
11585    ) {
11586        let mut watermarks = self
11587            .terminal_frame_watermarks
11588            .lock()
11589            .unwrap_or_else(|poisoned| poisoned.into_inner());
11590        let mut events = Vec::with_capacity(candidates.len());
11591        for (address, frame) in candidates {
11592            let is_first_frame = !watermarks.contains_key(&address.session.instance_id);
11593            let advanced = watermarks
11594                .get(&address.session.instance_id)
11595                .map_or(true, |(current_address, sequence)| {
11596                    current_address != &address || frame.sequence > *sequence
11597                });
11598            if !advanced {
11599                continue;
11600            }
11601            if is_first_frame {
11602                // Do NOT sessionize on first PTY output: a session is a
11603                // provider conversation id, not bytes on the wire. Drafts
11604                // leave the provisional map only when HQ attaches
11605                // (Resize / TerminalBytes). The attach-never-arrived timer
11606                // (PROVISIONAL_SPAWN_GRACE_MS) covers deploy lag; HQ's
11607                // disconnect grace starts only on a real client WS close.
11608                // This is the first PTY output this node has observed for
11609                // this session -- a one-time event per spawn, not per-frame
11610                // chatter (the watermark makes sure of that). A session that
11611                // reaches this line with no controller attached anywhere on
11612                // the node is running unobserved: exactly how processes from
11613                // repeated spawn attempts went orphaned for hours without a
11614                // single line about it.
11615                if self.controller_state().is_none() {
11616                    tracing::warn!(
11617                        node_id = %self.node_id,
11618                        workspace_id = %address.workspace_id,
11619                        session = ?address.session,
11620                        terminal_size = ?frame.size,
11621                        "session producing output with no controller attached",
11622                    );
11623                } else {
11624                    tracing::info!(
11625                        node_id = %self.node_id,
11626                        workspace_id = %address.workspace_id,
11627                        session = ?address.session,
11628                        terminal_size = ?frame.size,
11629                        "session produced its first output",
11630                    );
11631                }
11632            }
11633            watermarks.insert(
11634                address.session.instance_id,
11635                (address.clone(), frame.sequence),
11636            );
11637            if let Some((at, reported)) = self
11638                .pty_input_marks
11639                .lock()
11640                .unwrap_or_else(|poisoned| poisoned.into_inner())
11641                .get_mut(&address.session.instance_id)
11642            {
11643                // First frame after an input: PTY write -> output published.
11644                if !*reported && at.elapsed() < Duration::from_secs(5) {
11645                    *reported = true;
11646                    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");
11647                }
11648            }
11649            events.push(NodeEvent::TerminalFrame { address, frame });
11650        }
11651        drop(watermarks);
11652        if events.is_empty() {
11653            return;
11654        }
11655        let mut history = self
11656            .history
11657            .lock()
11658            .unwrap_or_else(|poisoned| poisoned.into_inner());
11659        let mut envelopes = Vec::with_capacity(events.len());
11660        for event in events {
11661            let sequence = history
11662                .last_sequence
11663                .checked_add(1)
11664                .expect("node event sequence exhausted");
11665            history.last_sequence = sequence;
11666            envelopes.push(NodeEventEnvelope { sequence, event });
11667        }
11668        let _ = self.terminal_event_tx.send(Arc::new(envelopes));
11669        drop(history);
11670    }
11671
11672    /// Publishes one `AgentStreamChunkV1` onto the dedicated agent-stream
11673    /// channel, mirroring `publish_terminal_frame_candidates` above: the
11674    /// envelope's `sequence` is drawn from the same `history.last_sequence`
11675    /// counter every other event kind shares, never a counter of its own --
11676    /// `queue_connection_event`/`queue_connection_event_batch` compare
11677    /// `envelope.sequence` against one `discard_events_through` watermark
11678    /// regardless of which broadcast channel it arrived on, so a
11679    /// per-channel counter would desynchronize that comparison.
11680    fn publish_agent_stream_chunk(&self, address: SessionAddress, chunk: AgentStreamChunkV1) {
11681        let mut history = self
11682            .history
11683            .lock()
11684            .unwrap_or_else(|poisoned| poisoned.into_inner());
11685        let sequence = history
11686            .last_sequence
11687            .checked_add(1)
11688            .expect("node event sequence exhausted");
11689        history.last_sequence = sequence;
11690        drop(history);
11691        // Debug, not info/warn -- this fires on every single chunk, but it
11692        // is exactly the counter the next drop-measurement pass needs: what
11693        // the node actually emitted (`source_sequence`, `kind`) versus what
11694        // a harness subscriber's own forwarder later logs having received
11695        // (`hatchery-harness-service::runtime`'s "forwarding agent stream
11696        // event"), so a gap between the two logs names itself instead of
11697        // needing a new counter added ad hoc.
11698        tracing::debug!(
11699            source_sequence = chunk.source_sequence,
11700            kind = agent_stream_chunk_kind_label(&chunk.kind),
11701            envelope_sequence = sequence,
11702            "publishing agent stream chunk",
11703        );
11704        let envelope = NodeEventEnvelope {
11705            sequence,
11706            event: NodeEvent::AgentStream { address, chunk },
11707        };
11708        let _ = self.agent_stream_event_tx.send(Arc::new(vec![envelope]));
11709    }
11710
11711    fn current_sequence(&self) -> u64 {
11712        self.history
11713            .lock()
11714            .unwrap_or_else(|poisoned| poisoned.into_inner())
11715            .last_sequence
11716    }
11717
11718    fn controller_state(&self) -> Option<ControllerState> {
11719        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11720        let lease = controller.as_ref().copied()?;
11721        let remaining = lease.expires_at.saturating_duration_since(Instant::now());
11722        if remaining.is_zero() {
11723            *controller = None;
11724            return None;
11725        }
11726        Some(ControllerState {
11727            connection_id: lease.connection_id,
11728            lease_remaining_ms: remaining.as_millis().min(u64::MAX as u128) as u64,
11729        })
11730    }
11731
11732    fn acquire_controller(&self, connection_id: u64, role: ClientRole, lease_ms: u64) -> Result<ControllerState, NodeFailure> {
11733        if role != ClientRole::Operator {
11734            tracing::warn!(
11735                node_id = %self.node_id,
11736                connection_id,
11737                "controller lease rejected: observer connections cannot acquire control",
11738            );
11739            return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections cannot acquire control"));
11740        }
11741        let lease_ms = if lease_ms == 0 {
11742            DEFAULT_CONTROLLER_LEASE_MS
11743        } else {
11744            lease_ms.clamp(MIN_CONTROLLER_LEASE_MS, MAX_CONTROLLER_LEASE_MS)
11745        };
11746        let now = Instant::now();
11747        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11748        if let Some(active) = controller.as_ref() {
11749            if active.connection_id != connection_id && active.expires_at > now {
11750                tracing::warn!(
11751                    node_id = %self.node_id,
11752                    connection_id,
11753                    holder_connection_id = active.connection_id,
11754                    "controller lease rejected: another operator holds it",
11755                );
11756                return Err(failure(NodeFailureCode::ControllerBusy, "another operator holds the controller lease"));
11757            }
11758        }
11759        // A reconnect (this connection already holds the lease, renewing
11760        // before expiry) happens on a periodic cadence; logging it at INFO
11761        // every renewal would be exactly the heartbeat chatter this crate
11762        // family's level discipline forbids. A genuine attach -- the
11763        // controller identity actually changing -- is a state change and
11764        // gets INFO; a same-connection renewal gets DEBUG.
11765        let is_new_attach = match controller.as_ref() {
11766            Some(active) => active.connection_id != connection_id,
11767            None => true,
11768        };
11769        *controller = Some(ControllerLease {
11770            connection_id,
11771            expires_at: now + Duration::from_millis(lease_ms),
11772        });
11773        drop(controller);
11774        let state = ControllerState { connection_id, lease_remaining_ms: lease_ms };
11775        if is_new_attach {
11776            tracing::info!(
11777                node_id = %self.node_id,
11778                connection_id,
11779                lease_ms,
11780                "controller lease acquired",
11781            );
11782        } else {
11783            tracing::debug!(
11784                node_id = %self.node_id,
11785                connection_id,
11786                lease_ms,
11787                "controller lease renewed",
11788            );
11789        }
11790        self.publish(NodeEvent::ControllerChanged { controller: Some(state.clone()) });
11791        Ok(state)
11792    }
11793
11794    fn release_controller(&self, connection_id: u64) -> bool {
11795        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11796        let released = controller.as_ref().is_some_and(|active| active.connection_id == connection_id);
11797        if released {
11798            *controller = None;
11799        }
11800        drop(controller);
11801        if released {
11802            tracing::info!(
11803                node_id = %self.node_id,
11804                connection_id,
11805                "controller lease released",
11806            );
11807            self.publish(NodeEvent::ControllerChanged { controller: None });
11808        }
11809        released
11810    }
11811
11812    /// Mark a fresh Spawn as provisional and arm the attach-never-arrived
11813    /// reaper (PROVISIONAL_SPAWN_GRACE_MS from spawn). Removed from this map
11814    /// only when HQ attaches (Resize / TerminalBytes) — that is *not* a
11815    /// provider session; it only cancels the orphan timer. Disconnect/reload
11816    /// draft kills are owned by HQ and arm only on a real client WS close.
11817    /// ACP path of launch options: once the agent advertises its config options, write the
11818    /// pending model / effort through session/set_config_option (session/set_model when the
11819    /// agent has no model option). Gaps are logged, never forced.
11820    fn apply_pending_acp_options(&self, address: &SessionAddress, event: &ControlEvent) {
11821        let ControlEventKind::ProviderEvent { event: ProviderEvent::ConfigOptionsUpdated { options }, .. } = &event.event else {
11822            return;
11823        };
11824        let id = address.session.instance_id;
11825        let Some((model, effort)) = self.pending_acp_options.lock().unwrap_or_else(|p| p.into_inner()).remove(&id) else {
11826            return;
11827        };
11828        let advertised: Vec<(String, Option<String>, Vec<String>)> = options
11829            .iter()
11830            .map(|o| (o.id.clone(), o.category.clone(), o.choices.iter().map(|c| c.value_json.clone()).collect()))
11831            .collect();
11832        let (writes, set_model, effort_gap) =
11833            backend_paths::acp_option_writes(&advertised, model.as_deref(), effort.as_deref());
11834        if set_model {
11835            if let Some(model_id) = model.clone() {
11836                if let Err(e) = self.dispatch(ControlCommand::SetSessionModel { instance_id: id, model_id }) {
11837                    tracing::warn!(instance_id = id.0, message = %e.message, "acp set_model failed");
11838                }
11839            }
11840        }
11841        for (option_id, value_json) in writes {
11842            if let Err(e) = self.dispatch(ControlCommand::SetSessionConfigOption { instance_id: id, option_id, value_json }) {
11843                tracing::warn!(instance_id = id.0, message = %e.message, "acp set_config_option failed");
11844            }
11845        }
11846        if effort_gap {
11847            tracing::warn!(instance_id = id.0, effort = ?effort, "acp agent advertises no matching thought_level option; effort not applied");
11848        }
11849        tracing::info!(instance_id = id.0, model = ?model, effort = ?effort, "launch options applied over acp");
11850    }
11851
11852    /// ACP auto-permit: switch to the provider's no-ask mode once modes are advertised, and
11853    /// answer every session/request_permission with approve.
11854    fn apply_acp_auto_permit(&self, address: &SessionAddress, event: &ControlEvent) {
11855        let id = address.session.instance_id;
11856        let Some(provider) = crate::auto_permit::acp_provider(id.0) else { return };
11857        if matches!(&event.event, ControlEventKind::Exited { .. } | ControlEventKind::Failed { .. } | ControlEventKind::Removed) {
11858            crate::auto_permit::forget_acp(id.0);
11859            return;
11860        }
11861        let ControlEventKind::ProviderEvent { sequence, event: provider_event, .. } = &event.event else { return };
11862        if let ProviderEvent::InteractionRequested { request_id, interaction_kind, options, .. } = provider_event {
11863            tracing::debug!(instance_id = id.0, sequence = *sequence, request_id = ?request_id, kind = ?interaction_kind, options = options.len(), "acp interaction requested");
11864        }
11865        match provider_event {
11866            ProviderEvent::ModeChanged { mode_id, available } => {
11867                if let Some(want) = crate::auto_permit::pick_acp_mode(provider, mode_id, available.iter().map(|m| m.id.as_str())) {
11868                    match self.dispatch(ControlCommand::SetSessionMode { instance_id: id, mode_id: want.clone() }) {
11869                        Ok(_) => tracing::info!(instance_id = id.0, provider, mode = %want, "acp auto-permit mode set"),
11870                        Err(e) => tracing::warn!(instance_id = id.0, provider, message = %e.message, "acp auto-permit set_mode failed"),
11871                    }
11872                }
11873            }
11874            ProviderEvent::InteractionRequested { interaction_kind: gate4agent_types::ProviderInteractionKind::Approval, tool_name, options, .. } if !options.is_empty() => {
11875                match self.dispatch(ControlCommand::ResolveInteraction {
11876                    instance_id: id,
11877                    generation: address.session.generation,
11878                    interaction_id: gate4agent_types::ProviderInteractionId(*sequence),
11879                    response: gate4agent_types::ProviderInteractionResponse::ApproveOnce,
11880                }) {
11881                    Ok(_) => tracing::info!(instance_id = id.0, provider, tool = %tool_name, "acp permission auto-approved"),
11882                    Err(e) => tracing::warn!(instance_id = id.0, provider, message = %e.message, "acp auto-approve failed"),
11883                }
11884            }
11885            // NB: ACP emits SessionEnded at the end of every *turn* (prompt stopReason), so it
11886            // must not drop the auto-permit registration; that happens on process exit below.
11887            _ => {}
11888        }
11889    }
11890
11891    fn mark_spawn_provisional(&self, address: SessionAddress) {
11892        let id = address.session.instance_id;
11893        self.provisional_spawns
11894            .lock()
11895            .unwrap_or_else(|poisoned| poisoned.into_inner())
11896            .insert(id, (address, Instant::now()));
11897        tracing::info!(
11898            node_id = %self.node_id,
11899            instance_id = id.0,
11900            grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
11901            "draft spawn; waiting for HQ view",
11902        );
11903    }
11904
11905    fn sessionize_spawn(&self, instance_id: AgentInstanceId) {
11906        let removed = self
11907            .provisional_spawns
11908            .lock()
11909            .unwrap_or_else(|poisoned| poisoned.into_inner())
11910            .remove(&instance_id);
11911        if removed.is_some() {
11912            tracing::info!(
11913                node_id = %self.node_id,
11914                instance_id = instance_id.0,
11915                "draft spawn claimed by HQ view",
11916            );
11917        }
11918    }
11919
11920    fn take_due_provisional_spawns(&self) -> Vec<SessionAddress> {
11921        let grace = Duration::from_millis(PROVISIONAL_SPAWN_GRACE_MS);
11922        let mut map = self
11923            .provisional_spawns
11924            .lock()
11925            .unwrap_or_else(|poisoned| poisoned.into_inner());
11926        let due: Vec<AgentInstanceId> = map
11927            .iter()
11928            .filter(|(_, (_, at))| at.elapsed() >= grace)
11929            .map(|(id, _)| *id)
11930            .collect();
11931        due.into_iter()
11932            .filter_map(|id| map.remove(&id).map(|(addr, _)| addr))
11933            .collect()
11934    }
11935
11936    async fn reap_provisional_spawn(&self, address: SessionAddress) {
11937        let instance_id = address.session.instance_id;
11938        tracing::warn!(
11939            node_id = %self.node_id,
11940            instance_id = instance_id.0,
11941            workspace = %address.workspace_id,
11942            reason = "attach_never_arrived",
11943            grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
11944            "draft spawn never viewed; stopping PTY",
11945        );
11946        let _ = self.dispatch(ControlCommand::Stop {
11947            instance_id,
11948            force: true,
11949        });
11950        let _ = self.remove_session(&address).await;
11951    }
11952
11953    fn require_controller(&self, connection_id: u64, role: ClientRole) -> Result<(), NodeFailure> {
11954        if role != ClientRole::Operator {
11955            return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections are read-only"));
11956        }
11957        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11958        let now = Instant::now();
11959        match controller.as_ref().copied() {
11960            Some(active) if active.connection_id == connection_id && active.expires_at > now => Ok(()),
11961            Some(active) if active.expires_at <= now => {
11962                *controller = None;
11963                Err(failure(NodeFailureCode::ControllerRequired, "controller lease expired"))
11964            }
11965            _ => Err(failure(NodeFailureCode::ControllerRequired, "controller lease is required")),
11966        }
11967    }
11968
11969    fn validate_address(&self, address: &SessionAddress) -> Result<AgentId, NodeFailure> {
11970        if !self
11971            .workspaces
11972            .read()
11973            .unwrap_or_else(|poisoned| poisoned.into_inner())
11974            .contains_key(&address.workspace_id)
11975        {
11976            return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
11977        }
11978        let binding = self
11979            .session_bindings
11980            .lock()
11981            .unwrap_or_else(|poisoned| poisoned.into_inner())
11982            .get(&address.session.instance_id)
11983            .cloned()
11984            .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
11985        if binding.workspace_id != address.workspace_id {
11986            return Err(failure(
11987                NodeFailureCode::SessionWorkspaceMismatch,
11988                "session belongs to another workspace",
11989            ));
11990        }
11991        if binding.generation != address.session.generation {
11992            return Err(failure(
11993                NodeFailureCode::StaleGeneration,
11994                &format!(
11995                    "stale session generation: expected {}, received {}",
11996                    binding.generation.0,
11997                    address.session.generation.0,
11998                ),
11999            ));
12000        }
12001        let snapshot = self.handle.snapshot();
12002        let Some(current) = snapshot
12003            .sessions
12004            .iter()
12005            .find(|item| item.instance_id == address.session.instance_id)
12006        else {
12007            return Err(failure(NodeFailureCode::UnknownSession, "session instance does not exist"));
12008        };
12009        if current.generation != address.session.generation {
12010            return Err(failure(
12011                NodeFailureCode::StaleGeneration,
12012                &format!(
12013                    "stale session generation: expected {}, received {}",
12014                    current.generation.0,
12015                    address.session.generation.0,
12016                ),
12017            ));
12018        }
12019        Ok(current.agent_id.clone())
12020    }
12021
12022    fn dispatch(&self, command: ControlCommand) -> Result<CommandId, NodeFailure> {
12023        self.dispatch_envelope(self.prepare_command(command))
12024    }
12025
12026    fn prepare_command(&self, command: ControlCommand) -> CommandEnvelope {
12027        let id = self.next_command_id.fetch_add(1, Ordering::AcqRel);
12028        CommandEnvelope {
12029            id: CommandId(id),
12030            command,
12031        }
12032    }
12033
12034    fn dispatch_envelope(&self, envelope: CommandEnvelope) -> Result<CommandId, NodeFailure> {
12035        let command_id = envelope.id;
12036        self.handle
12037            .dispatch(envelope)
12038            .map_err(|error| match error {
12039                PortDispatchError::Full => failure(NodeFailureCode::BackendBusy, "backend command ingress is full"),
12040                PortDispatchError::Disconnected => failure(NodeFailureCode::BackendDisconnected, "backend command ingress is disconnected"),
12041            })?;
12042        Ok(command_id)
12043    }
12044
12045    /// Records the kernel's own reason for asynchronously refusing
12046    /// `command_id` -- see `rejected_command_reasons`'s doc for why a
12047    /// spawn-dispatch waiter otherwise has no way to learn it. Bounded by
12048    /// `REJECTED_COMMAND_REASON_MAX_ENTRIES`: expired entries are swept
12049    /// first, and if the map is still at capacity after that, the single
12050    /// oldest surviving entry is evicted -- `CommandId` is monotonically
12051    /// assigned (`next_command_id.fetch_add`), so the `BTreeMap`'s first key
12052    /// is always the oldest.
12053    fn record_rejected_command(
12054        &self,
12055        command_id: CommandId,
12056        message: String,
12057        unsupported_transport: bool,
12058    ) {
12059        let now = Instant::now();
12060        let mut reasons = self.rejected_command_reasons.lock()
12061            .unwrap_or_else(|poisoned| poisoned.into_inner());
12062        reasons.retain(|_, reason| reason.expires_at > now);
12063        if reasons.len() >= REJECTED_COMMAND_REASON_MAX_ENTRIES {
12064            if let Some(&oldest) = reasons.keys().next() {
12065                reasons.remove(&oldest);
12066            }
12067        }
12068        reasons.insert(
12069            command_id,
12070            RejectedCommandReason {
12071                message,
12072                unsupported_transport,
12073                expires_at: now + Duration::from_millis(REJECTED_COMMAND_REASON_TTL_MS),
12074            },
12075        );
12076    }
12077
12078    /// Removes and returns the kernel's reason for refusing `command_id`,
12079    /// if the drive loop has recorded one and it has not expired -- see
12080    /// `rejected_command_reasons`'s doc. Consumed at most once: a spawn
12081    /// wait loop calls this instead of blindly polling out its whole
12082    /// deadline when the command it is waiting on turns out to have been
12083    /// refused rather than merely slow.
12084    fn take_rejected_command_reason(&self, command_id: CommandId) -> Option<RejectedCommandReason> {
12085        let now = Instant::now();
12086        let mut reasons = self.rejected_command_reasons.lock()
12087            .unwrap_or_else(|poisoned| poisoned.into_inner());
12088        match reasons.remove(&command_id) {
12089            Some(reason) if reason.expires_at > now => Some(reason),
12090            _ => None,
12091        }
12092    }
12093
12094    async fn dispatch_bounded(
12095        &self,
12096        command: ControlCommand,
12097        timeout_duration: Duration,
12098    ) -> Result<CommandId, NodeFailure> {
12099        let deadline = Instant::now() + timeout_duration;
12100        loop {
12101            match self.dispatch(command.clone()) {
12102                Ok(command_id) => return Ok(command_id),
12103                Err(error) if error.code == NodeFailureCode::BackendBusy && Instant::now() < deadline => {
12104                    sleep(Duration::from_millis(2)).await;
12105                }
12106                Err(error) => return Err(error),
12107            }
12108        }
12109    }
12110
12111    fn internal_session_snapshot(
12112        &self,
12113        address: &SessionAddress,
12114    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
12115        self.validate_address(address)?;
12116        self.handle
12117            .snapshot()
12118            .sessions
12119            .clone()
12120            .into_iter()
12121            .find(|session| {
12122                session.instance_id == address.session.instance_id
12123                    && session.generation == address.session.generation
12124            })
12125            .ok_or_else(|| {
12126                failure(
12127                    NodeFailureCode::UnknownSession,
12128                    "session snapshot is unavailable",
12129                )
12130            })
12131    }
12132
12133    /// Confirms `address` names a live session spawned over the ACP
12134    /// transport and returns its current snapshot -- the gate every
12135    /// `acp-control-v1` verb shares. `require_session_runtime_policy`
12136    /// cannot answer this by itself: `ProviderRuntimePolicy` carries no
12137    /// transport bit for `Acp`/`Inline` (see that method's own doc), only
12138    /// the session's own recorded `TransportKind` does, and that lives on
12139    /// the live snapshot, not the binding.
12140    fn require_acp_session(
12141        &self,
12142        address: &SessionAddress,
12143    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
12144        let snapshot = self.internal_session_snapshot(address)?;
12145        if snapshot.transport != TransportKind::Acp {
12146            return Err(failure(
12147                NodeFailureCode::UnsupportedTransport,
12148                "this verb requires a session spawned over the ACP transport",
12149            ));
12150        }
12151        Ok(snapshot)
12152    }
12153
12154    async fn dispatch_history_bounded(
12155        &self,
12156        address: &SessionAddress,
12157        command: ControlCommand,
12158        operation: HistoryOperation,
12159    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
12160        self.validate_address(address)?;
12161        let started_after = self.current_sequence();
12162        let command_id = self
12163            .dispatch_bounded(
12164                command,
12165                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12166            )
12167            .await?;
12168        let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
12169        let mut scan_after = started_after;
12170        let mut accepted_after = None;
12171        loop {
12172            let events = self
12173                .history
12174                .lock()
12175                .unwrap_or_else(|poisoned| poisoned.into_inner())
12176                .events
12177                .iter()
12178                .filter(|envelope| envelope.sequence > scan_after)
12179                .cloned()
12180                .collect::<Vec<_>>();
12181            for envelope in events {
12182                scan_after = scan_after.max(envelope.sequence);
12183                let NodeEvent::Control {
12184                    address: event_address,
12185                    event,
12186                } = envelope.event
12187                else {
12188                    continue;
12189                };
12190                if event_address != *address {
12191                    continue;
12192                }
12193                if event.command_id == Some(command_id) {
12194                    match &event.event {
12195                        ControlEventKind::CommandRejected { .. } => {
12196                            return Err(failure(
12197                                NodeFailureCode::InvalidRequest,
12198                                "history request was rejected",
12199                            ));
12200                        }
12201                        ControlEventKind::HistoryRequested {
12202                            operation: requested,
12203                            ..
12204                        } if requested == &operation => {
12205                            accepted_after = Some(envelope.sequence);
12206                            continue;
12207                        }
12208                        _ => {}
12209                    }
12210                }
12211                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12212                    match (&operation, &event.event) {
12213                        (
12214                            HistoryOperation::Discover { .. },
12215                            ControlEventKind::HistoryDiscovered { .. },
12216                        )
12217                        | (
12218                            HistoryOperation::Load { .. },
12219                            ControlEventKind::HistoryLoaded { .. },
12220                        ) => return self.internal_session_snapshot(address),
12221                        (_, ControlEventKind::HistoryFailed { .. }) => {
12222                            return Err(failure(
12223                                NodeFailureCode::BackendOperationFailed,
12224                                "history operation failed",
12225                            ));
12226                        }
12227                        _ => {}
12228                    }
12229                }
12230            }
12231            if Instant::now() >= deadline {
12232                return Err(failure(
12233                    NodeFailureCode::BackendBusy,
12234                    if accepted_after.is_some() {
12235                        "history operation did not settle before the bounded deadline"
12236                    } else {
12237                        "history operation was not accepted before the bounded deadline"
12238                    },
12239                ));
12240            }
12241            sleep(Duration::from_millis(2)).await;
12242        }
12243    }
12244
12245    async fn discover_history(
12246        &self,
12247        address: &SessionAddress,
12248        limit: u16,
12249    ) -> Result<Vec<HistoryCandidateSummary>, NodeFailure> {
12250        let query = HistoryQuery {
12251            working_directory: Some(self.workspace_root(&address.workspace_id)?),
12252            limit,
12253        };
12254        query
12255            .validate()
12256            .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history query"))?;
12257        let snapshot = self
12258            .dispatch_history_bounded(
12259                address,
12260                ControlCommand::DiscoverHistory {
12261                    instance_id: address.session.instance_id,
12262                    query: query.clone(),
12263                },
12264                HistoryOperation::Discover { query },
12265            )
12266            .await?;
12267        if snapshot.history.pending.is_some() || snapshot.history.loaded.is_some() {
12268            return Err(failure(
12269                NodeFailureCode::BackendOperationFailed,
12270                "history discovery settled into an invalid state",
12271            ));
12272        }
12273        Ok(snapshot.history.candidates)
12274    }
12275
12276    async fn load_history(
12277        &self,
12278        address: &SessionAddress,
12279        candidate_id: String,
12280    ) -> Result<HistorySessionRecord, NodeFailure> {
12281        validate_candidate_id(&candidate_id)
12282            .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history candidate"))?;
12283        let snapshot = self
12284            .dispatch_history_bounded(
12285                address,
12286                ControlCommand::LoadHistory {
12287                    instance_id: address.session.instance_id,
12288                    candidate_id: candidate_id.clone(),
12289                },
12290                HistoryOperation::Load {
12291                    candidate_id: candidate_id.clone(),
12292                },
12293            )
12294            .await?;
12295        if snapshot.history.pending.is_some()
12296            || snapshot.history.loaded_candidate_id.as_ref() != Some(&candidate_id)
12297        {
12298            return Err(failure(
12299                NodeFailureCode::BackendOperationFailed,
12300                "history load settled into an invalid state",
12301            ));
12302        }
12303        snapshot.history.loaded.ok_or_else(|| {
12304            failure(
12305                NodeFailureCode::BackendOperationFailed,
12306                "history load produced no bounded session",
12307            )
12308        })
12309    }
12310
12311    /// `allow_clean_detachment`: `false` for the on-demand
12312    /// `NodeRequest::ExportContextPackForSessionRecord` API path (the
12313    /// managed record must stay untouched, still live, for the whole call);
12314    /// `true` only for the reactive auto-export-at-exit path (`§2.1`), which
12315    /// reacts to the very clean-exit event that detaches the record out from
12316    /// under this same call — see `session_record_export_target_matches`.
12317    async fn export_context_pack_for_session_record(
12318        &self,
12319        record_id: &SessionRecordId,
12320        session: &SessionAddress,
12321        allow_clean_detachment: bool,
12322    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12323        // C2 replaces a relayed failure's prose with a canned per-code
12324        // string (`C2NodeFailure`'s own `From`), so this path's dozen
12325        // distinct refusals are indistinguishable to anyone downstream --
12326        // and a refusal here expires a continuation, which keeps no field
12327        // for why. The prose stays on the node, where it was written.
12328        let outcome = self
12329            .export_context_pack_for_session_record_inner(record_id, session, allow_clean_detachment)
12330            .await;
12331        if let Err(failure) = &outcome {
12332            tracing::warn!(
12333                record = %record_id,
12334                code = ?failure.code,
12335                reason = %failure.message,
12336                "session-record context-pack export refused",
12337            );
12338        }
12339        outcome
12340    }
12341
12342    async fn export_context_pack_for_session_record_inner(
12343        &self,
12344        record_id: &SessionRecordId,
12345        session: &SessionAddress,
12346        allow_clean_detachment: bool,
12347    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12348        // Several steps here refuse with the same BackendOperationFailed
12349        // code and no prose of their own, so which step failed is
12350        // otherwise unrecoverable -- and a refusal here expires a
12351        // continuation.
12352        let step = |name: &'static str| move |failure: NodeFailure| {
12353            tracing::warn!(step = name, code = ?failure.code, "context-pack export step refused");
12354            failure
12355        };
12356        let record = self.record(record_id).map_err(step("record-lookup"))?;
12357        // `provider_session` is `Some` only for a provider whose resolved
12358        // runtime policy admits live identity verification (raw PTY plus a
12359        // semantic PTY adapter -- `provider_runtime::policy_from_capability_flags`).
12360        // The PTY-sidecar-observation shape (no fleet provider today, but
12361        // capability-keyed rather than vendor-locked) hardcodes
12362        // `provider_session_identity: false` in `provider_runtime::evaluate`,
12363        // so its records go straight to `Live` with `provider_session: None`
12364        // and stay that way for the record's entire life -- that is this
12365        // shape's permanent resting state, not a race with observation, and
12366        // `discover_history`/`load_history` below do not need an identity to
12367        // do their job for it. `IdentityPending` is the actual race (an
12368        // identity-admitted provider whose observation has not landed yet):
12369        // it keeps refusing here rather than falling back, because that
12370        // provider's own history the fallback would otherwise trust is not
12371        // guaranteed to be exact without the identity it is still waiting on.
12372        let identity = match record.provider_session.clone() {
12373            Some(identity) => {
12374                if identity.key != ProviderSessionKey::SessionId {
12375                    return Err(step("provider-identity-key")(failure(
12376                        NodeFailureCode::BackendOperationFailed,
12377                        "managed session provider identity cannot bind history exactly",
12378                    )));
12379                }
12380                Some(identity)
12381            }
12382            None if record.state == ManagedSessionState::IdentityPending => {
12383                return Err(step("provider-session-identity")(failure(
12384                    NodeFailureCode::BackendOperationFailed,
12385                    "managed session has not yet observed its provider session identity",
12386                )));
12387            }
12388            None => None,
12389        };
12390        self.revalidate_session_record_context_export(
12391            &record,
12392            identity.as_ref(),
12393            session,
12394            allow_clean_detachment,
12395        ).map_err(step("revalidate-before-discovery"))?;
12396        let candidates = self
12397            .discover_history(session, HISTORY_DISCOVERY_LIMIT_MAX)
12398            .await
12399            .map_err(step("discover-history"))?;
12400        // With no verified identity to filter by, the discovered set must
12401        // still resolve to exactly one candidate: the same ambiguity refusal
12402        // an identity filter would otherwise produce is the only safety
12403        // property left available for this shape.
12404        let candidate_id = select_history_candidate(
12405            &candidates,
12406            identity.as_ref().map(|identity| identity.id.as_str()),
12407        ).map_err(step("unique-candidate"))?;
12408        let loaded = self.load_history(session, candidate_id.clone()).await
12409            .map_err(step("load-history"))?;
12410        if let Some(identity) = identity.as_ref() {
12411            if loaded.session_id != identity.id {
12412                return Err(failure(
12413                    NodeFailureCode::SessionRecordConflict,
12414                    "loaded history identity does not match the managed session record",
12415                ));
12416            }
12417        }
12418        self.revalidate_session_record_context_export(
12419            &record,
12420            identity.as_ref(),
12421            session,
12422            allow_clean_detachment,
12423        )?;
12424        self.revalidate_loaded_history(session, &candidate_id, &loaded)
12425            .map_err(step("revalidate-loaded-history"))?;
12426        let pack = self.materialize_context_pack(session).await
12427            .map_err(step("materialize-context-pack"))?;
12428        self.commit_context_pack_for_session_record(
12429            &record,
12430            identity.as_ref(),
12431            session,
12432            &candidate_id,
12433            &loaded,
12434            pack,
12435            allow_clean_detachment,
12436        )
12437    }
12438
12439    async fn export_context_pack(
12440        &self,
12441        address: &SessionAddress,
12442    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12443        let pack = self.materialize_context_pack(address).await?;
12444        self.commit_context_pack(pack)
12445    }
12446
12447    async fn materialize_context_pack(
12448        &self,
12449        address: &SessionAddress,
12450    ) -> Result<NodeContextPack, NodeFailure> {
12451        let snapshot = self.internal_session_snapshot(address)?;
12452        if !context_pack_source_status_is_usable(&snapshot.status) {
12453            return Err(failure(
12454                NodeFailureCode::InvalidRequest,
12455                "context export requires a running or successfully exited source session",
12456            ));
12457        }
12458        if snapshot.history.pending.is_some()
12459            || snapshot.history.loaded_candidate_id.is_none()
12460        {
12461            return Err(failure(
12462                NodeFailureCode::InvalidRequest,
12463                "context export requires settled loaded history",
12464            ));
12465        }
12466        let loaded = snapshot.history.loaded.clone().ok_or_else(|| {
12467            failure(
12468                NodeFailureCode::InvalidRequest,
12469                "context export requires settled loaded history",
12470            )
12471        })?;
12472        let source_provider = snapshot.agent_id.clone();
12473        let loaded_candidate_id = snapshot.history.loaded_candidate_id.clone();
12474        let repository = self
12475            .context_pack_repository(&address.workspace_id)
12476            .await?;
12477        let current = self.internal_session_snapshot(address)?;
12478        if !context_pack_source_status_is_usable(&current.status)
12479            || current.agent_id != source_provider
12480            || current.history.pending.is_some()
12481            || current.history.loaded_candidate_id != loaded_candidate_id
12482            || current.history.loaded.as_ref() != Some(&loaded)
12483        {
12484            return Err(failure(
12485                NodeFailureCode::ContextPackMaterializationFailed,
12486                "context source changed during bounded repository capture",
12487            ));
12488        }
12489        NodeContextPack::export_with_repository(
12490            ContextPackLineageReceipt {
12491                source_node_id: self.node_id.clone(),
12492                source_session: address.clone(),
12493                source_provider,
12494            },
12495            &loaded,
12496            Some(repository),
12497        )
12498        .map_err(|_| {
12499            failure(
12500                NodeFailureCode::ContextPackMaterializationFailed,
12501                "bounded context pack export failed",
12502            )
12503        })
12504    }
12505
12506    /// Write-through to the durable [`ContextPackStore`], if one is
12507    /// configured (`state_path` is `Some`). A node with no durable state
12508    /// path — fixtures, tests, ephemeral runs — has no `ContextPackStore`
12509    /// open at all and this is a no-op, matching `persist_state`'s own
12510    /// "durability optional, degrade gracefully when unconfigured" idiom.
12511    fn commit_context_pack_durably(&self, pack: &NodeContextPack) -> Result<(), NodeFailure> {
12512        let mut store = self
12513            .context_pack_store
12514            .lock()
12515            .unwrap_or_else(|poisoned| poisoned.into_inner());
12516        let Some(store) = store.as_mut() else {
12517            return Ok(());
12518        };
12519        store.commit(pack).map_err(|_| {
12520            failure(
12521                NodeFailureCode::ContextPackMaterializationFailed,
12522                "durable context pack commit failed",
12523            )
12524        })
12525    }
12526
12527    fn commit_context_pack(
12528        &self,
12529        pack: NodeContextPack,
12530    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12531        self.commit_context_pack_durably(&pack)?;
12532        self.context_catalog
12533            .write()
12534            .unwrap_or_else(|poisoned| poisoned.into_inner())
12535            .insert(pack)
12536            .map_err(|_| {
12537                failure(
12538                    NodeFailureCode::ContextPackMaterializationFailed,
12539                    "context pack catalog is full",
12540                )
12541            })
12542    }
12543
12544    fn commit_context_pack_for_session_record(
12545        &self,
12546        expected: &ManagedSessionRecord,
12547        identity: Option<&ProviderSessionIdentity>,
12548        session: &SessionAddress,
12549        candidate_id: &str,
12550        loaded: &HistorySessionRecord,
12551        pack: NodeContextPack,
12552        allow_clean_detachment: bool,
12553    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12554        let current_root = self.workspace_root(&expected.workspace_id)?;
12555        let _transaction = self
12556            .state_transaction
12557            .lock()
12558            .unwrap_or_else(|poisoned| poisoned.into_inner());
12559        let records = self
12560            .session_records
12561            .lock()
12562            .unwrap_or_else(|poisoned| poisoned.into_inner());
12563        let current = records.records.get(&expected.record_id).ok_or_else(|| {
12564            failure(
12565                NodeFailureCode::SessionRecordConflict,
12566                "managed session record disappeared during context export",
12567            )
12568        })?;
12569        if !session_record_export_target_matches(expected, current, session, allow_clean_detachment) {
12570            return Err(failure(
12571                NodeFailureCode::SessionRecordConflict,
12572                "managed session record changed during context export",
12573            ));
12574        }
12575        let bindings = self
12576            .session_bindings
12577            .lock()
12578            .unwrap_or_else(|poisoned| poisoned.into_inner());
12579        let binding = bindings.get(&session.session.instance_id).ok_or_else(|| {
12580            failure(
12581                NodeFailureCode::SessionRecordConflict,
12582                "managed session runtime binding disappeared during context export",
12583            )
12584        })?;
12585        let runtime = self
12586            .handle
12587            .snapshot()
12588            .sessions
12589            .iter()
12590            .find(|snapshot| {
12591                snapshot.instance_id == session.session.instance_id
12592                    && snapshot.generation == session.session.generation
12593            })
12594            .cloned()
12595            .ok_or_else(|| {
12596                failure(
12597                    NodeFailureCode::SessionRecordConflict,
12598                    "managed session runtime disappeared during context export",
12599                )
12600            })?;
12601        let receipt = pack.receipt();
12602        if !session_record_context_export_binding_is_exact(
12603            expected,
12604            current,
12605            identity,
12606            session,
12607            binding,
12608            &runtime.agent_id,
12609            &current_root,
12610            allow_clean_detachment,
12611        ) || !session_record_context_export_source_is_usable(
12612            current,
12613            &runtime.status,
12614            allow_clean_detachment,
12615        ) || runtime.history.pending.is_some()
12616            || runtime.history.loaded_candidate_id.as_deref() != Some(candidate_id)
12617            || runtime.history.loaded.as_ref() != Some(loaded)
12618            || receipt.lineage.source_node_id != self.node_id
12619            || receipt.lineage.source_session != *session
12620            || receipt.lineage.source_provider != current.provider
12621            || receipt.source_message_count != loaded.message_count
12622        {
12623            return Err(failure(
12624                NodeFailureCode::SessionRecordConflict,
12625                "managed session source changed before context catalog commit",
12626            ));
12627        }
12628        self.commit_context_pack_durably(&pack)?;
12629        self.context_catalog
12630            .write()
12631            .unwrap_or_else(|poisoned| poisoned.into_inner())
12632            .insert(pack)
12633            .map_err(|_| {
12634                failure(
12635                    NodeFailureCode::ContextPackMaterializationFailed,
12636                    "context pack catalog is full",
12637                )
12638            })
12639    }
12640
12641    async fn context_pack_repository(
12642        &self,
12643        workspace_id: &WorkspaceId,
12644    ) -> Result<ContextPackRepository, NodeFailure> {
12645        let first = self
12646            .context_pack_repository_observation(workspace_id)
12647            .await?;
12648        let second = self
12649            .context_pack_repository_observation(workspace_id)
12650            .await?;
12651        stable_context_pack_repository(first, second)
12652    }
12653
12654    async fn context_pack_repository_observation(
12655        &self,
12656        workspace_id: &WorkspaceId,
12657    ) -> Result<ContextPackRepositoryObservation, NodeFailure> {
12658        let git = self.inspect_workspace(workspace_id.clone()).await?.git;
12659        let head = if git.is_repository {
12660            let root = self.workspace_root(workspace_id)?;
12661            match resolve_base_commit_with_timeout(
12662                &root,
12663                "HEAD",
12664                GIT_COMMAND_TIMEOUT_MS,
12665            )
12666            .await
12667            {
12668                Ok(commit) => ContextPackRepositoryHead::Commit(commit),
12669                Err(error) if error.kind == GitWorktreeErrorKind::Conflict => {
12670                    ContextPackRepositoryHead::Unborn
12671                }
12672                Err(_) => {
12673                    return Err(failure(
12674                        NodeFailureCode::ContextPackMaterializationFailed,
12675                        "context pack HEAD observation failed",
12676                    ));
12677                }
12678            }
12679        } else {
12680            ContextPackRepositoryHead::NotRepository
12681        };
12682        let mut files = Vec::with_capacity(CONTEXT_PACK_SELECTED_FILES.len());
12683        for selected_path in CONTEXT_PACK_SELECTED_FILES {
12684            let path = RepositoryPath::utf8(selected_path.to_owned()).map_err(|_| {
12685                failure(
12686                    NodeFailureCode::InvalidRepositoryPath,
12687                    "context pack selected file path is invalid",
12688                )
12689            })?;
12690            match self.read_workspace_file(workspace_id.clone(), path).await {
12691                Ok(WorkspaceFileRead {
12692                    content: WorkspaceFileContent::Utf8 { text, byte_len },
12693                    ..
12694                }) => files.push(ContextPackRepositoryFileSource::utf8(
12695                    selected_path,
12696                    text,
12697                    byte_len,
12698                )),
12699                Ok(WorkspaceFileRead {
12700                    content: WorkspaceFileContent::NonUtf8 { byte_len },
12701                    ..
12702                }) => files.push(ContextPackRepositoryFileSource::skipped(
12703                    selected_path,
12704                    ContextPackSelectedFileSkipReason::NonUtf8,
12705                    Some(byte_len),
12706                )),
12707                Ok(WorkspaceFileRead {
12708                    content: WorkspaceFileContent::TooLarge { .. },
12709                    ..
12710                }) => files.push(ContextPackRepositoryFileSource::skipped(
12711                    selected_path,
12712                    ContextPackSelectedFileSkipReason::TooLarge,
12713                    None,
12714                )),
12715                Err(error) => {
12716                    let reason = if matches!(
12717                        error.code,
12718                        NodeFailureCode::InvalidRepositoryPath
12719                            | NodeFailureCode::RepositoryFileNotRegular
12720                            | NodeFailureCode::RepositoryPathUnsafe
12721                    ) {
12722                        ContextPackSelectedFileSkipReason::Unsafe
12723                    } else {
12724                        ContextPackSelectedFileSkipReason::Unavailable
12725                    };
12726                    files.push(ContextPackRepositoryFileSource::skipped(
12727                        selected_path,
12728                        reason,
12729                        None,
12730                    ));
12731                }
12732            }
12733        }
12734        Ok(ContextPackRepositoryObservation { head, git, files })
12735    }
12736
12737    fn forget_context_pack(&self, context_id: &SpawnContextId) -> Result<(), NodeFailure> {
12738        let referenced_by_binding = self
12739            .session_bindings
12740            .lock()
12741            .unwrap_or_else(|poisoned| poisoned.into_inner())
12742            .values()
12743            .any(|binding| {
12744                binding.context.as_ref().map(|receipt| &receipt.id) == Some(context_id)
12745            });
12746        let referenced_by_record = self
12747            .session_records
12748            .lock()
12749            .unwrap_or_else(|poisoned| poisoned.into_inner())
12750            .records
12751            .values()
12752            .any(|record| record.context_id.as_ref() == Some(context_id));
12753        let referenced_by_materialization = self
12754            .materializations
12755            .lock()
12756            .unwrap_or_else(|poisoned| poisoned.into_inner())
12757            .values()
12758            .any(|ownership| {
12759                ownership.context().map(|receipt| &receipt.id) == Some(context_id)
12760            });
12761        if referenced_by_binding
12762            || referenced_by_record
12763            || referenced_by_materialization
12764        {
12765            return Err(failure(
12766                NodeFailureCode::ContextPackBusy,
12767                "context pack is still referenced",
12768            ));
12769        }
12770        let mut catalog = self.context_catalog
12771            .write()
12772            .unwrap_or_else(|poisoned| poisoned.into_inner());
12773        if catalog.get(context_id).is_none() {
12774            return Err(failure(
12775                NodeFailureCode::UnknownContextPack,
12776                "context pack is unavailable",
12777            ));
12778        }
12779        self.spawn_idempotency
12780            .lock()
12781            .unwrap_or_else(|poisoned| poisoned.into_inner())
12782            .entries
12783            .retain(|_, entry| !entry.value.references_context(context_id));
12784        let removed = catalog.remove(context_id);
12785        debug_assert!(removed.is_some(), "validated context pack remains present");
12786        Ok(())
12787    }
12788
12789    async fn dispatch_input_bounded(
12790        &self,
12791        address: &SessionAddress,
12792        action: InputAction,
12793    ) -> Result<(), NodeFailure> {
12794        let started_after = self.current_sequence();
12795        self.pty_input_marks
12796            .lock()
12797            .unwrap_or_else(|poisoned| poisoned.into_inner())
12798            .insert(address.session.instance_id, (Instant::now(), false));
12799        self.pty_input_wake.notify_one();
12800        let command_id = self
12801            .dispatch_bounded(
12802                ControlCommand::SendInput {
12803                    instance_id: address.session.instance_id,
12804                    action,
12805                },
12806                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12807            )
12808            .await?;
12809        // A semantic prompt may spend the provider's full readiness budget before
12810        // the runtime emits InputCompleted. Keep the node request deadline bounded,
12811        // but strictly beyond every readiness policy in this node's catalog.
12812        let deadline = Instant::now() + Duration::from_millis(self.input_settle_timeout_ms);
12813        let mut scan_after = started_after;
12814        let mut accepted_after = None;
12815        loop {
12816            let events = self
12817                .history
12818                .lock()
12819                .unwrap_or_else(|poisoned| poisoned.into_inner())
12820                .events
12821                .iter()
12822                .filter(|envelope| envelope.sequence > scan_after)
12823                .cloned()
12824                .collect::<Vec<_>>();
12825            for envelope in events {
12826                scan_after = scan_after.max(envelope.sequence);
12827                let NodeEvent::Control {
12828                    address: event_address,
12829                    event,
12830                } = envelope.event
12831                else {
12832                    continue;
12833                };
12834                if event_address != *address {
12835                    continue;
12836                }
12837                if event.command_id == Some(command_id) {
12838                    match &event.event {
12839                        ControlEventKind::CommandRejected { message } => {
12840                            return Err(failure(NodeFailureCode::InvalidRequest, message));
12841                        }
12842                        ControlEventKind::InputRequested { .. } => {
12843                            accepted_after = Some(envelope.sequence);
12844                            continue;
12845                        }
12846                        _ => {}
12847                    }
12848                }
12849                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12850                    match &event.event {
12851                        ControlEventKind::InputCompleted { .. } => return Ok(()),
12852                        ControlEventKind::InputFailed { message, .. } => {
12853                            return Err(failure(
12854                                NodeFailureCode::BackendOperationFailed,
12855                                message,
12856                            ));
12857                        }
12858                        _ => {}
12859                    }
12860                }
12861            }
12862            if Instant::now() >= deadline {
12863                return Err(failure(
12864                    NodeFailureCode::BackendBusy,
12865                    if accepted_after.is_some() {
12866                        "input was accepted but did not settle before the bounded deadline"
12867                    } else {
12868                        "input was not accepted before the bounded deadline"
12869                    },
12870                ));
12871            }
12872            sleep(Duration::from_millis(2)).await;
12873        }
12874    }
12875
12876    async fn dispatch_resize_bounded(
12877        &self,
12878        address: &SessionAddress,
12879        size: gate4agent_types::TerminalSize,
12880    ) -> Result<(), NodeFailure> {
12881        let started_after = self.current_sequence();
12882        let command_id = self
12883            .dispatch_bounded(
12884                ControlCommand::Resize {
12885                    instance_id: address.session.instance_id,
12886                    size,
12887                },
12888                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12889            )
12890            .await?;
12891        let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
12892        let mut scan_after = started_after;
12893        let mut accepted_after = None;
12894        loop {
12895            let events = self
12896                .history
12897                .lock()
12898                .unwrap_or_else(|poisoned| poisoned.into_inner())
12899                .events
12900                .iter()
12901                .filter(|envelope| envelope.sequence > scan_after)
12902                .cloned()
12903                .collect::<Vec<_>>();
12904            for envelope in events {
12905                scan_after = scan_after.max(envelope.sequence);
12906                let NodeEvent::Control {
12907                    address: event_address,
12908                    event,
12909                } = envelope.event
12910                else {
12911                    continue;
12912                };
12913                if event_address != *address {
12914                    continue;
12915                }
12916                if event.command_id == Some(command_id) {
12917                    match &event.event {
12918                        ControlEventKind::CommandRejected { message } => {
12919                            return Err(failure(NodeFailureCode::InvalidRequest, message));
12920                        }
12921                        ControlEventKind::ResizeRequested {
12922                            size: requested_size,
12923                            ..
12924                        } if *requested_size == size => {
12925                            accepted_after = Some(envelope.sequence);
12926                            continue;
12927                        }
12928                        _ => {}
12929                    }
12930                }
12931                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12932                    match &event.event {
12933                        ControlEventKind::Resized { size: settled_size }
12934                            if *settled_size == size =>
12935                        {
12936                            return Ok(());
12937                        }
12938                        ControlEventKind::ResizeFailed { message } => {
12939                            return Err(failure(
12940                                NodeFailureCode::BackendOperationFailed,
12941                                message,
12942                            ));
12943                        }
12944                        _ => {}
12945                    }
12946                }
12947            }
12948            if Instant::now() >= deadline {
12949                return Err(failure(
12950                    NodeFailureCode::BackendBusy,
12951                    if accepted_after.is_some() {
12952                        "resize was accepted but did not settle before the bounded deadline"
12953                    } else {
12954                        "resize was not accepted before the bounded deadline"
12955                    },
12956                ));
12957            }
12958            sleep(Duration::from_millis(2)).await;
12959        }
12960    }
12961
12962    async fn spawn_session(
12963        &self,
12964        workspace_id: WorkspaceId,
12965        provider: AgentId,
12966        mode: SessionMode,
12967        terminal_size: gate4agent_types::TerminalSize,
12968        initial_prompt: Option<String>,
12969    ) -> Result<SessionAddress, NodeFailure> {
12970        if self.managed_worktrees.lock()
12971            .unwrap_or_else(|poisoned| poisoned.into_inner())
12972            .lease_for_workspace(&workspace_id)
12973            .is_some()
12974        {
12975            return Err(failure(
12976                NodeFailureCode::ManagedWorktreeOwnershipConflict,
12977                "managed worktree targets may only be spawned through their lease request",
12978            ));
12979        }
12980        self
12981            .spawn_session_with_deadline(
12982                workspace_id,
12983                provider.clone(),
12984                mode,
12985                terminal_size,
12986                initial_prompt,
12987                None,
12988                None,
12989                None,
12990                None,
12991                None,
12992                SpawnRecordPolicy::ProviderIdentityOnly,
12993                None,
12994                &[],
12995                None,
12996                // `NodeRequest::Spawn` (this method's only caller) carries no
12997                // approval-level selection of its own -- see
12998                // `HarnessOperatorRequestV1::SpawnSession`'s own field for
12999                // the one operator-wire path that can choose something other
13000                // than the axis default. Auto-permit may lower it where the agent
13001                // cannot offer its full-auto mode (claude ACP under root).
13002                crate::auto_permit::spawn_approval_level(provider.as_str(), mode).unwrap_or_default(),
13003                None,
13004                None,
13005            )
13006            .await
13007            .map(|(session, _runtime_policy)| session)
13008    }
13009
13010    async fn spawn_session_with_deadline(
13011        &self,
13012        workspace_id: WorkspaceId,
13013        provider: AgentId,
13014        mode: SessionMode,
13015        terminal_size: gate4agent_types::TerminalSize,
13016        initial_prompt: Option<String>,
13017        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
13018        bundle: Option<ResolvedBundleReceipt>,
13019        context: Option<ResolvedContextPackReceipt>,
13020        managed_authority: Option<ManagedWorktreeLeaseId>,
13021        admitted_runtime_policy: Option<ProviderRuntimePolicy>,
13022        record_policy: SpawnRecordPolicy,
13023        deadline: Option<Instant>,
13024        required_capabilities: &[ProviderRuntimeCapability],
13025        harness_mcp: Option<&PreparedHarnessMcpSpawn>,
13026        approval_level: ApprovalLevel,
13027        network_allowlist: Option<crate::protocol::SpawnNetworkAllowlistId>,
13028        // Opaque dig2browser station profile id (ids only). Feature
13029        // `dig2-station-probe`: after probe at resolve, spawn takes an
13030        // exclusive node-local lease; cleared on session end. Never
13031        // ImportSession / cookies on C2. Feature off: ignored (stub echo
13032        // already happened on the receipt path).
13033        browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
13034    ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
13035        let reserved_lease = self.managed_worktrees.lock()
13036            .unwrap_or_else(|poisoned| poisoned.into_inner())
13037            .lease_for_workspace(&workspace_id);
13038        match (reserved_lease.as_ref(), managed_authority.as_ref()) {
13039            (Some(expected), Some(provided)) if expected == provided => {}
13040            (None, None) => {}
13041            _ => {
13042                return Err(failure(
13043                    NodeFailureCode::ManagedWorktreeOwnershipConflict,
13044                    "spawn lacks exact managed worktree lease authority",
13045                ));
13046            }
13047        }
13048        // Refuse concurrent distinct environment profiles (incl. ProviderHome
13049        // bindings) on the same workspace cwd. Parallel path: managed worktree
13050        // spawn so each profile gets its own workspace_id.
13051        self.ensure_workspace_environment_profile_compatible(
13052            &workspace_id,
13053            environment_profile.as_ref(),
13054        )?;
13055        let runtime_requirement = match (mode, initial_prompt.is_some()) {
13056            (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
13057            (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
13058            (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
13059            (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
13060        };
13061        // This session's own transport -- computed once, here, from `mode`,
13062        // and reused both to admit spawn (below) and, further down, to
13063        // compute the final `runtime_policy` this session actually operates
13064        // under (`provider_runtime::policy_for_transport`). Registration
13065        // reuses this exact value rather than recomputing it a second time.
13066        let transport = match mode {
13067            SessionMode::Pty => TransportKind::Pty,
13068            SessionMode::Inline => TransportKind::Pipe,
13069            SessionMode::Acp => TransportKind::Acp,
13070        };
13071        let pipe_transport = self.provider_supports_pipe_transport(&provider);
13072        let acp_transport = self.provider_supports_acp_transport(&provider);
13073        let runtime_policy = if let Some(runtime_policy) = admitted_runtime_policy {
13074            require_policy(runtime_policy, pipe_transport, acp_transport, runtime_requirement).map_err(|_| failure(
13075                NodeFailureCode::UnsupportedSpawnCapability,
13076                "pre-admitted provider runtime no longer satisfies spawn requirements",
13077            ))?;
13078            runtime_policy
13079        } else if let Some(deadline) = deadline {
13080            let remaining = spawn_deadline_remaining(deadline)?;
13081            timeout(
13082                remaining,
13083                self.admit_provider_runtime(&provider, runtime_requirement),
13084            )
13085            .await
13086            .map_err(|_| {
13087                failure(
13088                    NodeFailureCode::SpawnDeadlineExceeded,
13089                    "provider admission exceeded the spawn deadline",
13090                )
13091            })??
13092        } else {
13093            self.admit_provider_runtime(&provider, runtime_requirement).await?
13094        };
13095        let runtime_policy = self.effective_spawn_runtime_policy(runtime_policy);
13096        // The single point where this session's `runtime_policy` becomes
13097        // transport-correct: `admit_provider_runtime` (and the pre-admitted
13098        // branch above) only ever compute a PTY-terminal-verification-shaped
13099        // value, sound outright only for `TransportKind::Pty` (Pipe still
13100        // relies on that same value downstream -- see `policy_for_transport`'s
13101        // own doc for why). `policy_for_transport` is the explicit,
13102        // exhaustive match Item 1 asks for -- this is the ONE place its
13103        // output becomes the session's `runtime_policy`, so every later
13104        // reader (`ControlCommand::Start`, `session_bindings`, and every
13105        // engine gate that reads `state.runtime_policy`) sees the same,
13106        // correctly-computed value.
13107        let runtime_policy =
13108            crate::provider_runtime::policy_for_transport(transport, runtime_policy);
13109        if required_capabilities
13110            .iter()
13111            .any(|capability| !runtime_policy.admits(*capability))
13112        {
13113            return Err(failure(
13114                NodeFailureCode::UnsupportedSpawnCapability,
13115                "provider runtime does not admit a required spawn capability",
13116            ));
13117        }
13118        if let Some(deadline) = deadline {
13119            spawn_deadline_remaining(deadline)?;
13120        }
13121        self.ensure_binding_capacity()?;
13122        let mut working_directory = self.workspace_root(&workspace_id)?;
13123        let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
13124        let session = SessionKey {
13125            instance_id,
13126            generation: SessionGeneration(1),
13127        };
13128        let address = SessionAddress {
13129            workspace_id: workspace_id.clone(),
13130            session,
13131        };
13132        // This is the request-accepted checkpoint: admission (managed
13133        // worktree authority, runtime capability, dispatch deadline) already
13134        // passed and a concrete session identity is minted. It is a distinct
13135        // state from the process actually running (`ControlEventKind::Running`,
13136        // logged in `publish_control`) or the terminal outcome (`Exited`/
13137        // `Failed`, logged there too) -- this line exists so that gap is no
13138        // longer silent.
13139        tracing::info!(
13140            node_id = %self.node_id,
13141            workspace_id = %workspace_id,
13142            provider = %provider,
13143            environment_profile = ?environment_profile.as_ref().map(|receipt| &receipt.profile_id),
13144            session = ?address.session,
13145            "session spawn request accepted",
13146        );
13147        let mut prepared_spawn = SpawnPreparer::prepare(
13148            self,
13149            SpawnPrepareRequest {
13150                instance_id,
13151                address: &address,
13152                provider: &provider,
13153                mode,
13154                environment_profile: environment_profile.as_ref(),
13155                bundle: bundle.as_ref(),
13156                context: context.as_ref(),
13157                managed_authority: managed_authority.as_ref(),
13158                harness_mcp,
13159                approval_level,
13160                network_allowlist: network_allowlist.as_ref(),
13161                browser_profile_id,
13162            },
13163        )?;
13164        let materialization_id = prepared_spawn.materialization_id.take();
13165        let dispatch_timeout = spawn_dispatch_timeout(deadline)?;
13166        let record_id = self.bind_spawn_session_with_materialization(
13167            &address,
13168            provider.clone(),
13169            mode,
13170            runtime_policy,
13171            record_policy,
13172            environment_profile,
13173            bundle,
13174            context,
13175            materialization_id,
13176        )?;
13177        prepared_spawn.retain_session_guards();
13178        // `transport` was already computed above, alongside this session's
13179        // `runtime_policy`; reused here for `Register` rather than derived a
13180        // second time.
13181        let session_provider = provider.clone();
13182        let agent_id = provider;
13183        let register_command_id = match self.dispatch_bounded(
13184            ControlCommand::Register {
13185                instance_id,
13186                agent_id,
13187                transport,
13188            },
13189            dispatch_timeout,
13190        )
13191        .await {
13192            Ok(command_id) => command_id,
13193            Err(error) => {
13194                if let Some(binding) = self.remove_binding(&address) {
13195                    SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
13196                }
13197                if let Some(record_id) = record_id.as_ref() {
13198                    self.discard_record(record_id)?;
13199                }
13200                return Err(spawn_dispatch_error(error, deadline));
13201            }
13202        };
13203        let start_timeout = match spawn_dispatch_timeout(deadline) {
13204            Ok(timeout) => timeout,
13205            Err(error) => {
13206                let recovery = self
13207                    .rollback_spawn(
13208                        &address,
13209                        Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13210                    )
13211                    .await;
13212                return match recovery {
13213                    Ok(()) => {
13214                        if let Some(record_id) = record_id.as_ref() {
13215                            self.discard_record(record_id)?;
13216                        }
13217                        Err(error)
13218                    }
13219                    Err(recovery_error) => {
13220                        if let Some(record_id) = record_id.as_ref() {
13221                            self.mark_record_error(
13222                                record_id,
13223                                "spawn deadline elapsed and registration recovery failed",
13224                            )?;
13225                        }
13226                        Err(recovery_error)
13227                    }
13228                };
13229            }
13230        };
13231        if let Err(start_error) = self.verify_harness_mcp_before_start(harness_mcp) {
13232            if let Err(recovery_error) = self
13233                .rollback_spawn(&address, Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS))
13234                .await
13235            {
13236                return Err(recovery_error);
13237            }
13238            if let Some(record_id) = record_id.as_ref() {
13239                self.discard_record(record_id)?;
13240            }
13241            return Err(start_error);
13242        }
13243        // A session with its own m4a account runs in its own folder inside the workspace.
13244        if let Some(dir) = self.session_workdir(std::path::Path::new(&working_directory), instance_id, &session_provider) {
13245            working_directory = dir.to_string_lossy().into_owned();
13246        }
13247        let start_result = self
13248            .dispatch_bounded(
13249                ControlCommand::Start {
13250                    instance_id,
13251                    runtime_policy,
13252                    request: StartRequest {
13253                        working_directory,
13254                        terminal_size,
13255                        initial_prompt,
13256                        session_options: SPAWN_SESSION_OPTIONS.try_with(|o| o.clone()).ok().flatten(),
13257                        approval_level,
13258                    },
13259                },
13260                start_timeout,
13261            )
13262            .await;
13263        let start_command_id = match start_result {
13264            Ok(command_id) => command_id,
13265            Err(start_error) => {
13266                let start_error = spawn_dispatch_error(start_error, deadline);
13267                let recovery = self
13268                    .rollback_spawn(
13269                        &address,
13270                        Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13271                    )
13272                    .await;
13273                return match recovery {
13274                    Ok(()) => {
13275                        if let Some(record_id) = record_id.as_ref() {
13276                            self.discard_record(record_id)?;
13277                        }
13278                        Err(start_error)
13279                    }
13280                    Err(recovery_error) => {
13281                        let diagnostic = format!(
13282                            "start failed and registration recovery failed: {}",
13283                            recovery_error.message,
13284                        );
13285                        if let Some(record_id) = record_id.as_ref() {
13286                            self.mark_record_error(record_id, &diagnostic)?;
13287                        }
13288                        Err(failure(recovery_error.code, &diagnostic))
13289                    }
13290                };
13291            }
13292        };
13293
13294        let commit_deadline = deadline.unwrap_or_else(|| Instant::now() + dispatch_timeout);
13295        loop {
13296            // `Register`/`Start` only fail synchronously for a port-level
13297            // ingress problem (see `dispatch_bounded`); the kernel refusing
13298            // either command's content -- an unsupported transport, an
13299            // unknown agent -- lands one drive-loop tick later, and no
13300            // session ever appears in `self.handle.snapshot()` for it to
13301            // notice below. Check for that named reason before falling
13302            // through to the blind presence poll, so a rejected spawn
13303            // reports why instead of just timing out.
13304            if let Some(reason) = self.take_rejected_command_reason(register_command_id)
13305                .or_else(|| self.take_rejected_command_reason(start_command_id))
13306            {
13307                self.rollback_spawn(
13308                    &address,
13309                    Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13310                ).await.map_err(|error| {
13311                        failure(
13312                            error.code,
13313                            &format!(
13314                                "spawn was rejected ({}) and registration recovery failed: {}",
13315                                reason.message, error.message
13316                            ),
13317                        )
13318                    })?;
13319                if let Some(record_id) = record_id.as_ref() {
13320                    self.discard_record(record_id)?;
13321                }
13322                let code = if reason.unsupported_transport {
13323                    NodeFailureCode::UnsupportedTransport
13324                } else {
13325                    NodeFailureCode::BackendOperationFailed
13326                };
13327                return Err(failure(code, &reason.message));
13328            }
13329            if let Some(current) = self
13330                .handle
13331                .snapshot()
13332                .sessions
13333                .iter()
13334                .find(|current| current.instance_id == instance_id)
13335            {
13336                if current.generation != session.generation {
13337                    let divergence = failure(
13338                        NodeFailureCode::StaleGeneration,
13339                        "spawn generation diverged",
13340                    );
13341                    return match self
13342                        .rollback_spawn(
13343                            &address,
13344                            Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13345                        )
13346                        .await
13347                    {
13348                        Ok(()) => {
13349                            if let Some(record_id) = record_id.as_ref() {
13350                                self.discard_record(record_id)?;
13351                            }
13352                            Err(divergence)
13353                        }
13354                        Err(recovery_error) => {
13355                            if let Some(record_id) = record_id.as_ref() {
13356                                self.mark_record_error(
13357                                    record_id,
13358                                    "spawn generation diverged and recovery failed",
13359                                )?;
13360                            }
13361                            Err(recovery_error)
13362                        }
13363                    };
13364                }
13365                if !matches!(current.status, gate4agent_types::SessionStatus::Registered) {
13366                    prepared_spawn.retain_launch_guards();
13367                    return Ok((address, runtime_policy));
13368                }
13369            }
13370            if Instant::now() >= commit_deadline {
13371                self.rollback_spawn(
13372                    &address,
13373                    Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13374                ).await.map_err(|error| {
13375                        failure(
13376                            error.code,
13377                            &format!(
13378                                "start did not commit and registration recovery failed: {}",
13379                                error.message
13380                            ),
13381                        )
13382                    })?;
13383                if let Some(record_id) = record_id.as_ref() {
13384                    self.discard_record(record_id)?;
13385                }
13386                return Err(failure(
13387                    if deadline.is_some() {
13388                        NodeFailureCode::SpawnDeadlineExceeded
13389                    } else {
13390                        NodeFailureCode::BackendBusy
13391                    },
13392                    "start did not commit before the bounded deadline; registration was removed",
13393                ));
13394            }
13395            sleep(Duration::from_millis(2)).await;
13396        }
13397    }
13398
13399    fn effective_spawn_runtime_policy(
13400        &self,
13401        runtime_policy: ProviderRuntimePolicy,
13402    ) -> ProviderRuntimePolicy {
13403        #[cfg(feature = "fixture")]
13404        let runtime_policy = if self.fixture_semantic_hook_policy {
13405            ProviderRuntimePolicy::new(true, true, true, true, false, true)
13406                .expect("monitoring Hook fixture policy is internally valid")
13407        } else {
13408            runtime_policy
13409        };
13410        runtime_policy
13411    }
13412
13413    fn verify_harness_mcp_before_start(
13414        &self,
13415        prepared: Option<&PreparedHarnessMcpSpawn>,
13416    ) -> Result<(), NodeFailure> {
13417        prepared
13418            .map(PreparedHarnessMcpSpawn::verify_helper_program)
13419            .transpose()
13420            .map(|_| ())
13421            .map_err(harness_mcp_failure)
13422    }
13423
13424    async fn rollback_spawn(
13425        &self,
13426        address: &SessionAddress,
13427        commit_timeout: Duration,
13428    ) -> Result<(), NodeFailure> {
13429        self.dispatch_bounded(
13430            ControlCommand::Remove {
13431                instance_id: address.session.instance_id,
13432            },
13433            commit_timeout,
13434        )
13435        .await?;
13436        self.wait_until_removed(address, commit_timeout).await?;
13437        if let Some(binding) = self.remove_binding(address) {
13438            SessionLifecycleHook::cleanup_session_materialization(self, address, &binding)?;
13439        }
13440        Ok(())
13441    }
13442
13443    async fn remove_session(&self, address: &SessionAddress) -> Result<(), NodeFailure> {
13444        let commit_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
13445        self.dispatch_bounded(
13446            ControlCommand::Remove {
13447                instance_id: address.session.instance_id,
13448            },
13449            commit_timeout,
13450        )
13451            .await?;
13452        self.wait_until_removed(address, commit_timeout).await?;
13453        let removed = self.remove_binding(address);
13454        if let Some(binding) = removed.as_ref() {
13455            SessionLifecycleHook::cleanup_session_materialization(self, address, binding)?;
13456        }
13457        if let Some(lease_id) = removed.and_then(|binding| binding.managed_worktree_lease_id) {
13458            let snapshot = self.managed_worktrees.lock()
13459                .unwrap_or_else(|poisoned| poisoned.into_inner())
13460                .release_session(
13461                    &lease_id,
13462                    self.incarnation_id,
13463                    address.session.instance_id,
13464                    address.session.generation,
13465                    unix_time_ms(),
13466                );
13467            if self.persist_state().is_err() {
13468                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
13469            }
13470            if let Some(snapshot) = snapshot {
13471                self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
13472            }
13473            let _ = self.cleanup_managed_worktree(&lease_id, false).await;
13474        }
13475        Ok(())
13476    }
13477
13478    /// Polls until `address`'s session leaves the engine's own session map,
13479    /// or the bounded deadline expires.
13480    ///
13481    /// A `Remove` sent while the session is not yet in a terminal status
13482    /// (e.g. still `Stopping`, mid-effect right after a `Stop`) is rejected
13483    /// once by the engine (`SessionStatus::allows_remove`) and
13484    /// then discarded -- nothing re-issues it on its own, so the removal
13485    /// that would satisfy this wait never arrives and the caller was
13486    /// guaranteed to time out even though the session settles microseconds
13487    /// later. So this re-dispatches on every poll tick: a session that
13488    /// settles mid-wait -- the common case right after a `Stop` -- still
13489    /// gets removed inside the same bounded deadline.
13490    ///
13491    /// It re-dispatches only once the status will ACCEPT the command. A
13492    /// rejected re-dispatch is not the harmless no-op this loop first
13493    /// assumed: every rejection publishes a `ControlEventKind::Command
13494    /// Rejected` to every subscriber and writes a WARN line. Ungated, one
13495    /// ordinary teardown produced 241 of them in 1.6 seconds -- the whole
13496    /// `Stopping` window at one known-doomed dispatch per 2ms tick, fanned
13497    /// out to the harness and every attached client. Gated, the same
13498    /// teardown sends exactly one `Remove`, at the first tick the engine
13499    /// can act on it.
13500    ///
13501    /// The status is read from the snapshot this loop already takes, so
13502    /// the gate costs nothing extra. It races -- the status can move
13503    /// between the read and the dispatch -- and that is fine: a rare lost
13504    /// race is one rejection, and the next tick retries.
13505    ///
13506    /// Re-dispatch is separately rate-limited to
13507    /// [`REMOVE_REDISPATCH_INTERVAL_MS`], because the status gate alone
13508    /// leaves a second burst behind it. Once the session settles, every
13509    /// 2ms tick sent another `Remove` while the accepted one was still in
13510    /// flight, and each of those landed on an instance the engine had
13511    /// already dropped -- `agent instance … is not registered`, observed
13512    /// live as a tail of rejections immediately after the `session
13513    /// removed` line. The presence poll stays at 2ms so the wait still
13514    /// exits promptly; only the sending slows down.
13515    ///
13516    /// Rate-limited rather than once-only on purpose: the status gate
13517    /// covers the status precondition, but `Gate4AgentEngine::remove` has
13518    /// others (a pending operation, an in-flight capability probe or
13519    /// history request) that clear on their own. A single shot would turn
13520    /// those into a guaranteed `BackendBusy` timeout; at 10Hz they still
13521    /// get their retries, just twenty of them across the deadline instead
13522    /// of a thousand.
13523    async fn wait_until_removed(
13524        &self,
13525        address: &SessionAddress,
13526        commit_timeout: Duration,
13527    ) -> Result<(), NodeFailure> {
13528        let deadline = Instant::now() + commit_timeout;
13529        let mut next_dispatch = Instant::now();
13530        loop {
13531            let snapshot = self.handle.snapshot();
13532            let status = snapshot
13533                .sessions
13534                .iter()
13535                .find(|session| session.instance_id == address.session.instance_id)
13536                .map(|session| session.status.clone());
13537            let Some(status) = status else {
13538                return Ok(());
13539            };
13540            let now = Instant::now();
13541            if now >= deadline {
13542                return Err(failure(
13543                    NodeFailureCode::BackendBusy,
13544                    "remove did not commit before the bounded deadline; session binding was retained",
13545                ));
13546            }
13547            if status.allows_remove() && now >= next_dispatch {
13548                let _ = self.dispatch(ControlCommand::Remove {
13549                    instance_id: address.session.instance_id,
13550                });
13551                next_dispatch = now + Duration::from_millis(REMOVE_REDISPATCH_INTERVAL_MS);
13552            }
13553            sleep(Duration::from_millis(2)).await;
13554        }
13555    }
13556
13557    fn resync(&self, after_sequence: u64) -> NodeResponse {
13558        let history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
13559        let event_sequence = history.last_sequence;
13560        let oldest_available_sequence = history.replay_floor_sequence;
13561        let events = history
13562            .events
13563            .iter()
13564            .filter(|event| event.sequence > after_sequence)
13565            .cloned()
13566            .collect();
13567        drop(history);
13568        NodeResponse::Resync {
13569            event_sequence,
13570            oldest_available_sequence,
13571            snapshot: self.snapshot(),
13572            events,
13573        }
13574    }
13575}
13576
13577/// Coarse label for one `AgentStreamChunkKindV1`, used only by
13578/// `NodeShared::publish_agent_stream_chunk`'s own debug log -- mirrors
13579/// `hatchery-harness-service`'s `agent_stream_event_kind_label` (same
13580/// "name what crossed this boundary" reasoning, one layer upstream of it).
13581fn agent_stream_chunk_kind_label(kind: &AgentStreamChunkKindV1) -> &'static str {
13582    match kind {
13583        AgentStreamChunkKindV1::Text { .. } => "text",
13584        AgentStreamChunkKindV1::Thinking { .. } => "thinking",
13585        AgentStreamChunkKindV1::InteractionPrompt { .. } => "interaction-prompt",
13586        AgentStreamChunkKindV1::ModeCatalog { .. } => "mode-catalog",
13587        AgentStreamChunkKindV1::ConfigOptions { .. } => "config-options",
13588        AgentStreamChunkKindV1::ModelCatalog { .. } => "model-catalog",
13589        AgentStreamChunkKindV1::Blocked { .. } => "blocked",
13590    }
13591}
13592
13593/// Serves this wire on the local endpoint, and -- when `call_home` names a
13594/// relay -- over an outbound connection to it as well.
13595///
13596/// The two live in one future rather than a fourth arm of `run`'s own
13597/// `select!` because they are one concern: this is where the node's wire
13598/// is served, and the direction a given connection was opened in does not
13599/// change what happens on it. It also means the existing graceful drain
13600/// below covers both without being duplicated.
13601///
13602/// Either side finishing ends the pair, which is correct: the listener
13603/// only returns on shutdown or a bind failure, and `call_home_loop` only
13604/// returns on shutdown -- a failed relay connection is retried inside it,
13605/// never propagated out.
13606async fn accept_connections(
13607    endpoint: &str,
13608    call_home: Option<std::net::SocketAddr>,
13609    identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13610    shared: Arc<NodeShared>,
13611) -> Result<(), NodeServerError> {
13612    let mut connections = JoinSet::new();
13613    let result = match call_home {
13614        None => accept_connections_inner(endpoint, shared, &mut connections).await,
13615        Some(relay) => {
13616            let listener =
13617                accept_connections_inner(endpoint, Arc::clone(&shared), &mut connections);
13618            let dialler = call_home_loop(relay, identity, Arc::clone(&shared));
13619            tokio::pin!(listener);
13620            tokio::pin!(dialler);
13621            tokio::select! {
13622                result = &mut listener => result,
13623                result = &mut dialler => result,
13624            }
13625        }
13626    };
13627    let graceful_deadline = Instant::now() + Duration::from_millis(CONNECTION_SHUTDOWN_GRACE_MS);
13628    while !connections.is_empty() {
13629        let remaining = graceful_deadline.saturating_duration_since(Instant::now());
13630        if remaining.is_zero()
13631            || timeout(remaining, connections.join_next()).await.is_err()
13632        {
13633            break;
13634        }
13635    }
13636    connections.abort_all();
13637    while connections.join_next().await.is_some() {}
13638    result
13639}
13640
13641/// Backoff ladder for a relay that will not answer, capped rather than
13642/// unbounded: a node whose relay is down is not broken and should keep
13643/// trying forever, but it should not spend a core doing it.
13644const CALL_HOME_BACKOFFS_MS: [u64; 5] = [500, 1_000, 2_000, 4_000, 8_000];
13645
13646/// Dials the relay, serves this wire over the connection, and does it
13647/// again when the connection ends.
13648///
13649/// The node is the SERVER on the socket it just opened. That inversion is
13650/// the whole point: `serve_connection` is the same function the local
13651/// listener feeds, and it neither knows nor cares which end placed the
13652/// call. The one thing this direction adds is the preface -- the relay
13653/// accepted a socket and cannot know whose it is, so the node says its
13654/// name before the handshake it would otherwise begin with.
13655///
13656/// Never returns an error. A relay that is down, refusing, or speaking a
13657/// different protocol is a condition to retry, not a reason to take the
13658/// node's local endpoint down with it; the only way out is shutdown.
13659async fn call_home_loop(
13660    relay: std::net::SocketAddr,
13661    identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13662    shared: Arc<NodeShared>,
13663) -> Result<(), NodeServerError> {
13664    let mut failures = 0_usize;
13665    loop {
13666        if shared.shutdown.load(Ordering::Acquire) {
13667            return Ok(());
13668        }
13669        let permit = tokio::select! {
13670            permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13671                permit.map_err(|_| NodeServerError::PreauthClosed)?
13672            }
13673            _ = shared.shutdown_notify.notified() => return Ok(()),
13674        };
13675        match dial_relay_and_serve(relay, identity.as_deref(), Arc::clone(&shared), permit).await {
13676            Ok(()) => {
13677                // A clean end is the relay hanging up, which it does on
13678                // its own restart. Reconnect immediately rather than
13679                // serving the backoff a peer that is plainly there.
13680                failures = 0;
13681                tracing::info!(node_id = %shared.node_id, %relay, "call-home connection closed; redialling");
13682            }
13683            Err(error) => {
13684                failures = failures.saturating_add(1);
13685                tracing::warn!(
13686                    node_id = %shared.node_id,
13687                    %relay,
13688                    cause = %error,
13689                    attempt = failures,
13690                    "call-home connection failed",
13691                );
13692                let backoff = call_home_backoff(failures);
13693                tokio::select! {
13694                    _ = sleep(backoff) => {}
13695                    _ = shared.shutdown_notify.notified() => return Ok(()),
13696                }
13697            }
13698        }
13699    }
13700}
13701
13702/// Idle time before the first keepalive probe on a call-home socket.
13703const CALL_HOME_KEEPALIVE_IDLE: Duration = Duration::from_secs(15);
13704/// Interval between keepalive probes once the link has gone quiet.
13705const CALL_HOME_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(5);
13706
13707fn call_home_keepalive() -> socket2::TcpKeepalive {
13708    let keepalive = socket2::TcpKeepalive::new()
13709        .with_time(CALL_HOME_KEEPALIVE_IDLE)
13710        .with_interval(CALL_HOME_KEEPALIVE_INTERVAL);
13711    #[cfg(not(windows))]
13712    let keepalive = keepalive.with_retries(3);
13713    keepalive
13714}
13715
13716fn enable_call_home_keepalive(stream: &tokio::net::TcpStream) -> std::io::Result<()> {
13717    socket2::SockRef::from(stream).set_tcp_keepalive(&call_home_keepalive())
13718}
13719
13720/// Same keepalive profile for any node->relay socket (restream uplink).
13721pub(crate) fn enable_tcp_keepalive(stream: &std::net::TcpStream) -> std::io::Result<()> {
13722    socket2::SockRef::from(stream).set_tcp_keepalive(&call_home_keepalive())
13723}
13724
13725/// Backoff for the `attempt`-th consecutive failure (1-based), capped.
13726fn call_home_backoff(attempt: usize) -> Duration {
13727    Duration::from_millis(
13728        CALL_HOME_BACKOFFS_MS[attempt.saturating_sub(1).min(CALL_HOME_BACKOFFS_MS.len() - 1)],
13729    )
13730}
13731
13732
13733/// Dial the call-home relay. Tunnel addresses go through the in-process
13734/// WireGuard dialer when the userspace session is up.
13735async fn dial_call_home(relay: std::net::SocketAddr) -> Result<tokio::net::TcpStream, NodeServerError> {
13736    #[cfg(feature = "wireguard")]
13737    {
13738        let std_stream = tokio::task::spawn_blocking(move || {
13739            gate4agent_node_core::kit_wireguard::tcp_connect(relay)
13740        })
13741        .await
13742        .map_err(|e| NodeServerError::CallHomeTransport(std::io::Error::other(e.to_string())))?
13743        .map_err(NodeServerError::CallHomeTransport)?;
13744        std_stream
13745            .set_nonblocking(true)
13746            .map_err(NodeServerError::CallHomeTransport)?;
13747        return tokio::net::TcpStream::from_std(std_stream).map_err(NodeServerError::CallHomeTransport);
13748    }
13749    #[cfg(not(feature = "wireguard"))]
13750    {
13751        tokio::net::TcpStream::connect(relay)
13752            .await
13753            .map_err(NodeServerError::CallHomeTransport)
13754    }
13755}
13756
13757/// One call-home attempt: connect, name this node, then serve.
13758async fn dial_relay_and_serve(
13759    relay: std::net::SocketAddr,
13760    identity: Option<&hatchery_enroll::NodeIdentity>,
13761    shared: Arc<NodeShared>,
13762    preauth_permit: OwnedSemaphorePermit,
13763) -> Result<(), NodeServerError> {
13764    let mut stream = dial_call_home(relay).await?;
13765    // Nagle off for the same reason the rest of this wire wants it off:
13766    // the traffic is small request/response frames, and coalescing them
13767    // adds latency to every one.
13768    let _ = stream.set_nodelay(true);
13769    // A relay that vanishes without a FIN/RST reaching us (tunnel drop,
13770    // relay-side socket kill whose RST was lost) would otherwise leave this
13771    // socket ESTABLISHED forever: the node mostly reads, so nothing ever
13772    // fails and the reconnect loop never runs. Kernel keepalive turns that
13773    // half-open link into a read error within a bounded time.
13774    if let Err(error) = enable_call_home_keepalive(&stream) {
13775        tracing::warn!(cause = %error, "could not enable TCP keepalive on the call-home socket");
13776    }
13777    gate4agent_node_wire::write_call_home_announce(&mut stream, &shared.node_id)
13778        .await
13779        .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13780    tracing::info!(node_id = %shared.node_id, %relay, "announced this node to the relay");
13781    let mut session_token = None;
13782    if let Some(identity) = identity {
13783        // An enrolled relay answers the announce with a signed-call-home challenge; a
13784        // token-mode relay sends its wire hello instead. Peek to tell them apart
13785        // without consuming either.
13786        let mut magic = [0_u8; 4];
13787        let peeked = timeout(Duration::from_secs(10), stream.peek(&mut magic))
13788            .await
13789            .map_err(|_| NodeServerError::Handshake("relay sent nothing after the announce".to_owned()))?
13790            .map_err(NodeServerError::CallHomeTransport)?;
13791        if peeked == 4 && &magic == hatchery_enroll::CHALLENGE_MAGIC {
13792            let mut frame = [0_u8; hatchery_enroll::CHALLENGE_LEN];
13793            timeout(Duration::from_secs(5), tokio::io::AsyncReadExt::read_exact(&mut stream, &mut frame))
13794                .await
13795                .map_err(|_| NodeServerError::Handshake("relay challenge timed out".to_owned()))?
13796                .map_err(NodeServerError::CallHomeTransport)?;
13797            let (answer, token) = identity
13798                .answer_challenge(shared.node_id.as_str(), &frame)
13799                .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13800            tokio::io::AsyncWriteExt::write_all(&mut stream, &answer).await.map_err(NodeServerError::CallHomeTransport)?;
13801            tracing::info!(node_id = %shared.node_id, "answered the relay's signed call-home challenge");
13802            session_token = Some(token);
13803        }
13804    }
13805    serve_connection(stream, shared, session_token, preauth_permit).await
13806}
13807
13808async fn accept_connections_inner(
13809    endpoint: &str,
13810    shared: Arc<NodeShared>,
13811    connections: &mut JoinSet<Result<(), NodeServerError>>,
13812) -> Result<(), NodeServerError> {
13813    let mut listener = OwnerOnlyLocalListener::bind(endpoint).await?;
13814    loop {
13815        while connections.try_join_next().is_some() {}
13816        if shared.shutdown.load(Ordering::Acquire) {
13817            return Ok(());
13818        }
13819        let preauth_permit = tokio::select! {
13820            permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13821                permit.map_err(|_| NodeServerError::PreauthClosed)?
13822            }
13823            _ = shared.shutdown_notify.notified() => return Ok(()),
13824        };
13825        let server = tokio::select! {
13826            result = listener.accept() => result?,
13827            _ = shared.shutdown_notify.notified() => return Ok(()),
13828        };
13829        let connection_shared = Arc::clone(&shared);
13830        connections.spawn(async move {
13831            serve_connection(server, connection_shared, None, preauth_permit).await
13832        });
13833        if shared.shutdown.load(Ordering::Acquire) {
13834            return Ok(());
13835        }
13836    }
13837}
13838
13839/// Serves one connection, whichever end opened it.
13840///
13841/// Generic over the stream rather than typed to `LocalServerStream`
13842/// because a call-home connection arrives on a `TcpStream` the node itself
13843/// dialled. Nothing in the body ever depended on the concrete type -- it
13844/// reads and writes framed JSON and then `tokio::io::split`s -- so this is
13845/// the whole of what welding "who dials" to "who serves" was costing.
13846async fn serve_connection<S>(
13847    mut pipe: S,
13848    shared: Arc<NodeShared>,
13849    session_token: Option<String>,
13850    preauth_permit: OwnedSemaphorePermit,
13851) -> Result<(), NodeServerError>
13852where
13853    S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
13854{
13855    // A signed call-home connection authenticates with its own per-connection token.
13856    let auth_token: &[u8] = session_token.as_deref().map_or(shared.access_token.as_bytes(), str::as_bytes);
13857    let hello_frame = timeout(
13858        Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13859        read_json_frame_limited_body_timeout(
13860            &mut pipe,
13861            MAX_NODE_HELLO_FRAME_BYTES,
13862            Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13863        ),
13864    )
13865    .await
13866    .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13867    let ClientFrame::Hello(hello) = hello_frame else {
13868        return Err(NodeServerError::Handshake("first frame must be hello".to_owned()));
13869    };
13870    if hello.build_stamp != BUILD_STAMP {
13871        return Err(NodeServerError::Handshake(format!(
13872            "build stamp mismatch: local={BUILD_STAMP} remote={}",
13873            hello.build_stamp,
13874        )));
13875    }
13876    let compatibility = match hello.compatibility.as_ref() {
13877        Some(offer) => {
13878            let mut selected = node_compatibility_support(&shared)?
13879                .negotiate(offer)
13880                .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13881            project_negotiated_provider_ids(&mut selected);
13882            Some(selected)
13883        }
13884        None => None,
13885    };
13886    let server_nonce = random_nonce().map_err(NodeServerError::Authentication)?;
13887    let server_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13888        (Some(offer), Some(selected)) => negotiated_auth_proof(
13889            auth_token,
13890            AuthDirection::Server,
13891            hello.role,
13892            &hello.client_nonce,
13893            &server_nonce,
13894            offer,
13895            selected,
13896        ),
13897        (None, None) => auth_proof(
13898            auth_token,
13899            AuthDirection::Server,
13900            hello.role,
13901            &hello.client_nonce,
13902            &server_nonce,
13903        ),
13904        _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13905    }
13906    .map_err(NodeServerError::Authentication)?;
13907    write_json_frame_limited(
13908        &mut pipe,
13909        &ServerFrame::Challenge(ServerChallenge {
13910            build_stamp: BUILD_STAMP.to_owned(),
13911            server_nonce,
13912            server_proof,
13913            compatibility: compatibility.clone(),
13914        }),
13915        MAX_NODE_HELLO_FRAME_BYTES,
13916    )
13917    .await?;
13918    let authentication = timeout(
13919        Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13920        read_json_frame_limited_body_timeout(
13921            &mut pipe,
13922            MAX_NODE_HELLO_FRAME_BYTES,
13923            Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13924        ),
13925    )
13926    .await
13927    .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13928    let ClientFrame::Authenticate(authentication) = authentication else {
13929        return Err(NodeServerError::Handshake("second frame must authenticate the challenge".to_owned()));
13930    };
13931    let expected_client_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13932        (Some(offer), Some(selected)) => negotiated_auth_proof(
13933            auth_token,
13934            AuthDirection::Client,
13935            hello.role,
13936            &hello.client_nonce,
13937            &server_nonce,
13938            offer,
13939            selected,
13940        ),
13941        (None, None) => auth_proof(
13942            auth_token,
13943            AuthDirection::Client,
13944            hello.role,
13945            &hello.client_nonce,
13946            &server_nonce,
13947        ),
13948        _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13949    }
13950    .map_err(NodeServerError::Authentication)?;
13951    if !proofs_match(&authentication.client_proof, &expected_client_proof) {
13952        return Err(NodeServerError::Handshake("access denied".to_owned()));
13953    }
13954    let selected_capabilities = compatibility
13955        .as_ref()
13956        .map(|selected| selected.capabilities.clone())
13957        .unwrap_or_default();
13958    let include_provider_runtime_status = selected_capabilities.iter().any(|capability| {
13959        capability.as_str() == NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY
13960    });
13961    let include_open_provider_ids = selected_capabilities.iter().any(|capability| {
13962        capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
13963    });
13964    let include_terminal_frame_events = selected_capabilities.iter().any(|capability| {
13965        capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
13966    });
13967    let include_agent_stream_events = selected_capabilities.iter().any(|capability| {
13968        capability.as_str() == NODE_AGENT_STREAM_EVENTS_CAPABILITY
13969    });
13970    let include_spawn_profiles = selected_capabilities.iter().any(|capability| {
13971        capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
13972    });
13973    let include_managed_worktrees = selected_capabilities.iter().any(|capability| {
13974        capability.as_str() == NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY
13975    }) && selected_capabilities.iter().any(|capability| {
13976        capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
13977    });
13978    let include_child_environment_profiles = selected_capabilities.iter().any(|capability| {
13979        capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
13980    });
13981    let include_session_bundles = selected_capabilities.iter().any(|capability| {
13982        capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
13983    });
13984    let include_history_context_packs = selected_capabilities.iter().any(|capability| {
13985        capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
13986    });
13987    let include_agent_progress = selected_capabilities.iter().any(|capability| {
13988        capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
13989    });
13990    let include_session_task_correlation = selected_capabilities.iter().any(|capability| {
13991        capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
13992    });
13993    let include_harness_mcp_proxy = selected_capabilities.iter().any(|capability| {
13994        capability.as_str() == NODE_HARNESS_MCP_READ_PROXY_CAPABILITY
13995    });
13996    let authenticated_permit = Arc::clone(&shared.authenticated_slots)
13997        .try_acquire_owned()
13998        .map_err(|_| NodeServerError::AuthenticatedConnectionLimit)?;
13999    let connection_id = shared.next_connection_id.fetch_add(1, Ordering::AcqRel);
14000    let _release_guard = ControllerReleaseGuard {
14001        shared: Arc::clone(&shared),
14002        connection_id,
14003    };
14004    drop(preauth_permit);
14005    let _authenticated_permit = authenticated_permit;
14006    let mut event_rx = shared.event_tx.subscribe();
14007    let mut terminal_event_rx = terminal_event_subscription(
14008        &shared,
14009        include_terminal_frame_events,
14010    );
14011    let mut agent_stream_event_rx = agent_stream_event_subscription(
14012        &shared,
14013        include_agent_stream_events,
14014    );
14015    let mut harness_mcp_proxy_event_rx = harness_mcp_proxy_event_subscription(
14016        &shared,
14017        include_harness_mcp_proxy,
14018    );
14019    write_json_frame(
14020        &mut pipe,
14021        &ServerFrame::Hello(NodeHello {
14022            build_stamp: BUILD_STAMP.to_owned(),
14023            incarnation_id: shared.incarnation_id,
14024            connection_id,
14025            role: hello.role,
14026            event_sequence: shared.current_sequence(),
14027            controller: shared.controller_state(),
14028            snapshot: snapshot_for_wire(
14029                &shared,
14030                include_provider_runtime_status,
14031                include_open_provider_ids,
14032                include_spawn_profiles,
14033                include_managed_worktrees,
14034                include_child_environment_profiles,
14035                include_session_bundles,
14036                include_history_context_packs,
14037                include_agent_progress,
14038                include_session_task_correlation,
14039            ),
14040            compatibility,
14041        }),
14042    )
14043    .await?;
14044    let (mut reader, mut writer) = tokio::io::split(pipe);
14045    let (frame_tx, mut frame_rx) = mpsc::channel(16);
14046    let reader_task = tokio::spawn(async move {
14047        loop {
14048            let frame = read_json_frame_limited_body_timeout::<_, ClientFrame>(
14049                &mut reader,
14050                MAX_NODE_CLIENT_FRAME_BYTES,
14051                Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
14052            )
14053            .await;
14054            let terminal = frame.is_err();
14055            if frame_tx.send(frame).await.is_err() || terminal {
14056                break;
14057            }
14058        }
14059    });
14060    let _reader_abort = AbortTaskOnDrop(reader_task.abort_handle());
14061    let mut pending_events = Vec::new();
14062    let mut resync_required = false;
14063    let mut discard_events_through = 0_u64;
14064    loop {
14065        // One tick of the serve loop, whether or not it had anything to do.
14066        // Against `connection_events_sent` this separates "busy" from
14067        // "spinning": a loop turning over thousands of times a second while
14068        // sending nothing is not serving, it is burning a core.
14069        shared.connection_loop_iterations.fetch_add(1, Ordering::Relaxed);
14070        let durable_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
14071            .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
14072        for _ in 0..durable_drain_budget {
14073            match event_rx.try_recv() {
14074                Ok(event) => queue_connection_event(
14075                    &mut pending_events,
14076                    event,
14077                    discard_events_through,
14078                ),
14079                Err(broadcast::error::TryRecvError::Empty) => break,
14080                Err(broadcast::error::TryRecvError::Lagged(_)) => {
14081                    resync_required = true;
14082                    break;
14083                }
14084                Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14085            }
14086        }
14087        if let Some(receiver) = terminal_event_rx.as_mut() {
14088            match receiver.try_recv() {
14089                Ok(events) => queue_connection_event_batch(
14090                    &mut pending_events,
14091                    &events,
14092                    discard_events_through,
14093                ),
14094                Err(broadcast::error::TryRecvError::Empty) => {}
14095                Err(broadcast::error::TryRecvError::Lagged(_)) => {
14096                    resync_required = true;
14097                }
14098                Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14099            }
14100        }
14101        if let Some(receiver) = agent_stream_event_rx.as_mut() {
14102            // Loops up to the same remaining-room budget the durable
14103            // `event_rx` drain above uses, rather than one `try_recv` per
14104            // tick: each successful receive here is itself only ONE
14105            // `NodeEventEnvelope` batch (`publish_agent_stream_chunk` always
14106            // sends `vec![envelope]`, never more), so a single-shot drain
14107            // caps this connection's agent-stream throughput at one chunk
14108            // per outer-loop tick regardless of how many are already queued
14109            // -- exactly the kind of self-inflicted stall this crate's own
14110            // durable-event budget exists to avoid.
14111            let agent_stream_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
14112                .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
14113            for _ in 0..agent_stream_drain_budget {
14114                match receiver.try_recv() {
14115                    Ok(events) => queue_connection_event_batch(
14116                        &mut pending_events,
14117                        &events,
14118                        discard_events_through,
14119                    ),
14120                    Err(broadcast::error::TryRecvError::Empty) => break,
14121                    Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
14122                        tracing::warn!(
14123                            connection_id,
14124                            dropped,
14125                            "agent stream event subscription lagged; forcing a resync",
14126                        );
14127                        resync_required = true;
14128                        break;
14129                    }
14130                    Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14131                }
14132            }
14133        }
14134        if let Some(receiver) = harness_mcp_proxy_event_rx.as_mut() {
14135            // Mirrors the agent-stream drain immediately above: each
14136            // successful receive is one single-envelope batch
14137            // (`publish_transient` always sends `vec![envelope]`), so this
14138            // loops up to the same remaining-room budget rather than
14139            // stopping after one `try_recv`.
14140            let harness_mcp_proxy_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
14141                .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
14142            for _ in 0..harness_mcp_proxy_drain_budget {
14143                match receiver.try_recv() {
14144                    Ok(events) => queue_connection_event_batch(
14145                        &mut pending_events,
14146                        &events,
14147                        discard_events_through,
14148                    ),
14149                    Err(broadcast::error::TryRecvError::Empty) => break,
14150                    Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
14151                        tracing::warn!(
14152                            connection_id,
14153                            dropped,
14154                            "harness MCP proxy event subscription lagged; forcing a resync",
14155                        );
14156                        resync_required = true;
14157                        break;
14158                    }
14159                    Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14160                }
14161            }
14162        }
14163        if resync_required {
14164            let event = resync_required_event(&shared);
14165            apply_connection_resync_watermark(
14166                &mut pending_events,
14167                &mut discard_events_through,
14168                event.sequence,
14169            );
14170            write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
14171            resync_required = false;
14172            continue;
14173        }
14174        if !pending_events.is_empty() {
14175            pending_events.sort_unstable_by_key(|event| event.sequence);
14176            let burst_len = connection_event_burst_len(pending_events.len());
14177            shared
14178                .connection_events_sent
14179                .fetch_add(burst_len as u64, Ordering::Relaxed);
14180            for event in pending_events.drain(..burst_len) {
14181                let event = if include_managed_worktrees {
14182                    Some(event)
14183                } else {
14184                    project_event_without_managed_worktrees(event)
14185                };
14186                let event = event.and_then(|event| if include_open_provider_ids {
14187                    Some(event)
14188                } else {
14189                    project_event_legacy_provider_ids(&shared, event)
14190                });
14191                let event = event.map(|event| if include_child_environment_profiles {
14192                    event
14193                } else {
14194                    project_event_without_child_environment_profile(event)
14195                });
14196                let event = event.map(|event| if include_session_bundles {
14197                    event
14198                } else {
14199                    project_event_without_session_bundle(event)
14200                });
14201                let event = event.map(|event| {
14202                    project_event_history_for_wire(event, include_history_context_packs)
14203                });
14204                let event = event.map(|event| if include_history_context_packs {
14205                    event
14206                } else {
14207                    project_event_without_context_pack(event)
14208                });
14209                let event = event.map(|event| if include_session_task_correlation {
14210                    event
14211                } else {
14212                    project_event_without_session_task_binding(event)
14213                });
14214                let exact_harness_controller = hello.role == ClientRole::Operator
14215                    && shared.controller_state().is_some_and(|controller| {
14216                        controller.connection_id == connection_id
14217                    });
14218                let event = event.and_then(|event| {
14219                    if include_harness_mcp_proxy && exact_harness_controller {
14220                        Some(event)
14221                    } else {
14222                        project_event_without_harness_mcp_proxy(event)
14223                    }
14224                });
14225                if let Some(event) = event {
14226                    write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
14227                }
14228            }
14229        }
14230        tokio::select! {
14231            biased;
14232            frame = frame_rx.recv() => {
14233                let frame = match frame {
14234                    Some(frame) => frame,
14235                    None => break,
14236                };
14237                let frame = match frame {
14238                    Ok(frame) => frame,
14239                    Err(FrameError::Io(error)) if matches!(error.kind(), io::ErrorKind::BrokenPipe | io::ErrorKind::UnexpectedEof) => break,
14240                    Err(error) => return Err(error.into()),
14241                };
14242                let ClientFrame::Request(request) = frame else {
14243                    return Err(NodeServerError::Handshake("hello may only be sent once".to_owned()));
14244                };
14245                #[cfg(feature = "fixture")]
14246                let probe_spawn_managed_worktree_v2 = matches!(
14247                    &request.request,
14248                    NodeRequest::SpawnManagedWorktreeV2 { .. }
14249                );
14250                let requires_open_provider_ids =
14251                    request_requires_open_provider_ids(&shared, &request.request);
14252                let requires_child_environment_profile =
14253                    request_requires_child_environment_profile(&shared, &request.request);
14254                let requires_session_bundle =
14255                    request_requires_session_bundle(&shared, &request.request);
14256                let requires_history_context_pack =
14257                    request_requires_history_context_pack(&shared, &request.request);
14258                let mut reply = if requires_open_provider_ids && !include_open_provider_ids {
14259                    ResponseEnvelope {
14260                        request_id: request.request_id,
14261                        result: Err(failure(
14262                            NodeFailureCode::UnsupportedCapability,
14263                            "open provider IDs were not negotiated",
14264                        )),
14265                    }
14266                } else if requires_child_environment_profile
14267                    && !include_child_environment_profiles
14268                {
14269                    ResponseEnvelope {
14270                        request_id: request.request_id,
14271                        result: Err(failure(
14272                            NodeFailureCode::UnsupportedCapability,
14273                            "child environment profiles were not negotiated",
14274                        )),
14275                    }
14276                } else if requires_session_bundle && !include_session_bundles {
14277                    ResponseEnvelope {
14278                        request_id: request.request_id,
14279                        result: Err(failure(
14280                            NodeFailureCode::UnsupportedCapability,
14281                            "session bundles were not negotiated",
14282                        )),
14283                    }
14284                } else if requires_history_context_pack && !include_history_context_packs {
14285                    ResponseEnvelope {
14286                        request_id: request.request_id,
14287                        result: Err(failure(
14288                            NodeFailureCode::UnsupportedCapability,
14289                            "history context packs were not negotiated",
14290                        )),
14291                    }
14292                } else if request_uses_unnegotiated_capability(
14293                    &request.request,
14294                    &selected_capabilities,
14295                )
14296                {
14297                    ResponseEnvelope {
14298                        request_id: request.request_id,
14299                        result: Err(failure(
14300                            NodeFailureCode::UnsupportedCapability,
14301                            "request capability was not negotiated",
14302                        )),
14303                    }
14304                } else {
14305                    process_request(&shared, connection_id, hello.role, request).await
14306                };
14307                #[cfg(feature = "fixture")]
14308                if probe_spawn_managed_worktree_v2 {
14309                    shared
14310                        .fixture_spawn_managed_worktree_v2_failure_probe
14311                        .record(reply.result.as_ref().err().map(|failure| failure.code));
14312                }
14313                if !include_provider_runtime_status {
14314                    clear_response_provider_runtime_status(&mut reply);
14315                }
14316                if !include_managed_worktrees {
14317                    project_response_without_managed_worktrees(&mut reply);
14318                }
14319                if !include_open_provider_ids {
14320                    project_response_legacy_provider_ids(&shared, &mut reply);
14321                }
14322                if !include_child_environment_profiles {
14323                    project_response_without_child_environment_profile(&mut reply);
14324                }
14325                if !include_session_bundles {
14326                    project_response_without_session_bundle(&mut reply);
14327                }
14328                project_response_history_for_wire(
14329                    &mut reply,
14330                    include_history_context_packs,
14331                );
14332                if !include_history_context_packs {
14333                    project_response_without_context_pack(&mut reply);
14334                }
14335                if !include_agent_progress {
14336                    project_response_without_agent_progress(&mut reply);
14337                }
14338                if !include_session_task_correlation {
14339                    project_response_without_session_task_binding(&mut reply);
14340                }
14341                write_json_frame(&mut writer, &ServerFrame::Reply(reply)).await?;
14342            }
14343            _ = tokio::task::yield_now(), if !pending_events.is_empty() => {}
14344            event = event_rx.recv() => {
14345                match event {
14346                    Ok(event) => queue_connection_event(
14347                        &mut pending_events,
14348                        event,
14349                        discard_events_through,
14350                    ),
14351                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14352                    Err(broadcast::error::RecvError::Closed) => break,
14353                }
14354            }
14355            events = receive_terminal_event_batch(&mut terminal_event_rx) => {
14356                match events {
14357                    Ok(events) => queue_connection_event_batch(
14358                        &mut pending_events,
14359                        &events,
14360                        discard_events_through,
14361                    ),
14362                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14363                    Err(broadcast::error::RecvError::Closed) => break,
14364                }
14365            }
14366            events = receive_terminal_event_batch(&mut agent_stream_event_rx) => {
14367                match events {
14368                    Ok(events) => queue_connection_event_batch(
14369                        &mut pending_events,
14370                        &events,
14371                        discard_events_through,
14372                    ),
14373                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14374                    Err(broadcast::error::RecvError::Closed) => break,
14375                }
14376            }
14377            events = receive_terminal_event_batch(&mut harness_mcp_proxy_event_rx) => {
14378                match events {
14379                    Ok(events) => queue_connection_event_batch(
14380                        &mut pending_events,
14381                        &events,
14382                        discard_events_through,
14383                    ),
14384                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14385                    Err(broadcast::error::RecvError::Closed) => break,
14386                }
14387            }
14388            _ = shared.shutdown_notify.notified() => break,
14389        }
14390    }
14391    Ok(())
14392}
14393
14394async fn receive_terminal_event_batch(
14395    receiver: &mut Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>>,
14396) -> Result<Arc<Vec<NodeEventEnvelope>>, broadcast::error::RecvError> {
14397    match receiver {
14398        Some(receiver) => receiver.recv().await,
14399        None => std::future::pending().await,
14400    }
14401}
14402
14403fn terminal_event_subscription(
14404    shared: &NodeShared,
14405    enabled: bool,
14406) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14407    enabled.then(|| shared.terminal_event_tx.subscribe())
14408}
14409
14410/// Mirrors `terminal_event_subscription` exactly, over the agent stream's
14411/// own channel -- see `NodeShared::agent_stream_event_tx`'s own doc for why
14412/// this needs a dedicated channel rather than a flag on the terminal one.
14413fn agent_stream_event_subscription(
14414    shared: &NodeShared,
14415    enabled: bool,
14416) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14417    enabled.then(|| shared.agent_stream_event_tx.subscribe())
14418}
14419
14420/// Mirrors `agent_stream_event_subscription` exactly, over the harness MCP
14421/// proxy's own channel -- see `NodeShared::harness_mcp_proxy_event_tx`'s
14422/// own doc for why this needs a dedicated channel rather than a ride on
14423/// the durable `event_tx`.
14424fn harness_mcp_proxy_event_subscription(
14425    shared: &NodeShared,
14426    enabled: bool,
14427) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14428    enabled.then(|| shared.harness_mcp_proxy_event_tx.subscribe())
14429}
14430
14431fn queue_connection_event(
14432    pending: &mut Vec<NodeEventEnvelope>,
14433    event: NodeEventEnvelope,
14434    discard_through: u64,
14435) {
14436    if event.sequence <= discard_through
14437        && !event.event.requires_harness_mcp_proxy_capability()
14438    {
14439        return;
14440    }
14441    if let NodeEvent::TerminalFrame { address, .. } = &event.event {
14442        if let Some(index) = pending.iter().position(|current| {
14443            matches!(
14444                &current.event,
14445                NodeEvent::TerminalFrame {
14446                    address: current_address,
14447                    ..
14448                } if current_address == address
14449            )
14450        }) {
14451            pending.remove(index);
14452        }
14453    }
14454    pending.push(event);
14455}
14456
14457fn project_event_without_harness_mcp_proxy(
14458    envelope: NodeEventEnvelope,
14459) -> Option<NodeEventEnvelope> {
14460    (!envelope.event.requires_harness_mcp_proxy_capability()).then_some(envelope)
14461}
14462
14463fn queue_connection_event_batch(
14464    pending: &mut Vec<NodeEventEnvelope>,
14465    events: &[NodeEventEnvelope],
14466    discard_through: u64,
14467) {
14468    for event in events {
14469        queue_connection_event(pending, event.clone(), discard_through);
14470    }
14471}
14472
14473fn apply_connection_resync_watermark(
14474    pending: &mut Vec<NodeEventEnvelope>,
14475    discard_through: &mut u64,
14476    marker_sequence: u64,
14477) {
14478    *discard_through = (*discard_through).max(marker_sequence);
14479    pending.retain(|event| event.sequence > *discard_through);
14480}
14481
14482fn connection_event_burst_len(pending_len: usize) -> usize {
14483    pending_len.min(NODE_CONNECTION_EVENT_BURST_MAX)
14484}
14485
14486fn resync_required_event(shared: &NodeShared) -> NodeEventEnvelope {
14487    let history = shared
14488        .history
14489        .lock()
14490        .unwrap_or_else(|poisoned| poisoned.into_inner());
14491    let sequence = history.last_sequence;
14492    let oldest_available_sequence = history.replay_floor_sequence;
14493    NodeEventEnvelope {
14494        sequence,
14495        event: NodeEvent::ResyncRequired {
14496            oldest_available_sequence,
14497        },
14498    }
14499}
14500
14501fn node_compatibility_support(
14502    shared: &NodeShared,
14503) -> Result<NodeCompatibilitySupport, NodeServerError> {
14504    let mut support = node_compatibility_support_for_manifest(
14505        &shared.provider_contracts,
14506        &shared.provider_adapter_contracts,
14507    )?;
14508    if shared
14509        .delivery_store
14510        .lock()
14511        .unwrap_or_else(|poisoned| poisoned.into_inner())
14512        .is_some()
14513    {
14514        support.capabilities.push(
14515            CapabilityId::new(NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY)
14516                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14517        );
14518    }
14519    if shared.harness_mcp_registry.is_some() {
14520        support.capabilities.push(
14521            CapabilityId::new(NODE_HARNESS_MCP_READ_PROXY_CAPABILITY)
14522                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14523        );
14524    }
14525    Ok(support)
14526}
14527
14528fn node_compatibility_support_for_manifest(
14529    provider_contracts: &[ProviderContractSupport],
14530    provider_adapter_contracts: &[ProviderAdapterContractSupport],
14531) -> Result<NodeCompatibilitySupport, NodeServerError> {
14532    Ok(NodeCompatibilitySupport {
14533        build_stamp: BUILD_STAMP.to_owned(),
14534        capabilities: baseline_capabilities()?,
14535        host: platform::host_descriptor().map_err(NodeServerError::Handshake)?,
14536        path_semantics: platform::path_semantics(),
14537        local_transport: platform::local_transport(),
14538        state_schema: StateSchemaSupport {
14539            versions: ProtocolRange::new(NODE_STATE_SCHEMA_V1, NODE_STATE_SCHEMA_V10)
14540                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14541        },
14542        provider_contracts: provider_contracts.to_vec(),
14543        provider_adapter_contracts: provider_adapter_contracts.to_vec(),
14544    })
14545}
14546
14547fn baseline_capabilities() -> Result<Vec<CapabilityId>, NodeServerError> {
14548    let capabilities = [
14549        NODE_COMPATIBILITY_METADATA_CAPABILITY,
14550        CAPABILITY_HOST_DIRECTORY_BROWSE_V1,
14551        NODE_REPOSITORY_PATH_CAPABILITY,
14552        NODE_WORKSPACE_FILE_READ_CAPABILITY,
14553        NODE_WORKSPACE_FILE_WRITE_CAPABILITY,
14554        NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY,
14555        NODE_GIT_READ_CAPABILITY,
14556        NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY,
14557        NODE_PROVIDER_ID_OPEN_CAPABILITY,
14558        NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
14559        NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY,
14560        NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
14561        NODE_AGENT_STREAM_EVENTS_CAPABILITY,
14562        NODE_ACP_CONTROL_CAPABILITY,
14563        NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
14564        NODE_SPAWN_PROFILE_REVISION_CAPABILITY,
14565        NODE_WORKTREE_SELECTION_CAPABILITY,
14566        NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
14567        NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
14568        NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
14569        NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
14570        NODE_HISTORY_CONTEXT_PACK_CAPABILITY,
14571        NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
14572        NODE_NATIVE_SESSION_CATALOG_CAPABILITY,
14573        NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
14574        NODE_NATIVE_SESSION_INDEX_CAPABILITY,
14575        NODE_NATIVE_SESSION_PREVIEW_CAPABILITY,
14576        NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
14577        NODE_SESSION_TASK_CORRELATION_CAPABILITY,
14578    ]
14579        .into_iter()
14580        .map(|capability| {
14581            CapabilityId::new(capability)
14582                .map_err(|error| NodeServerError::Handshake(error.to_string()))
14583        })
14584        .collect::<Result<Vec<_>, _>>()?;
14585    #[cfg(windows)]
14586    let capabilities = {
14587        let mut capabilities = capabilities;
14588        capabilities.push(
14589            CapabilityId::new(NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY)
14590                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14591        );
14592        capabilities
14593    };
14594    Ok(capabilities)
14595}
14596
14597fn request_uses_unnegotiated_capability(
14598    request: &NodeRequest,
14599    selected_capabilities: &[CapabilityId],
14600) -> bool {
14601    let selected = |required: &str| {
14602        selected_capabilities
14603            .iter()
14604            .any(|capability| capability.as_str() == required)
14605    };
14606    request
14607        .required_capability()
14608        .is_some_and(|required| !selected(required))
14609        || (request.requires_worktree_selection_capability()
14610            && !selected(NODE_WORKTREE_SELECTION_CAPABILITY))
14611        || (request.requires_spawn_spec_defaults_overrides_capability()
14612            && !selected(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY))
14613        || (request.requires_spawn_profile_revision_capability()
14614            && !selected(NODE_SPAWN_PROFILE_REVISION_CAPABILITY))
14615        || (request.requires_child_environment_profile_capability()
14616            && !selected(NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY))
14617        || (request.requires_session_bundle_materialization_capability()
14618            && !selected(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY))
14619        || (request.requires_history_context_pack_capability()
14620            && !selected(NODE_HISTORY_CONTEXT_PACK_CAPABILITY))
14621}
14622
14623fn snapshot_for_wire(
14624    shared: &NodeShared,
14625    include_provider_runtime_status: bool,
14626    include_open_provider_ids: bool,
14627    include_spawn_profiles: bool,
14628    include_managed_worktrees: bool,
14629    include_child_environment_profiles: bool,
14630    include_session_bundles: bool,
14631    include_history_context_packs: bool,
14632    include_agent_progress: bool,
14633    include_session_task_correlation: bool,
14634) -> NodeSnapshot {
14635    let mut snapshot = shared.snapshot();
14636    if !include_provider_runtime_status {
14637        snapshot.provider_runtime_statuses.clear();
14638    }
14639    if !include_open_provider_ids {
14640        project_snapshot_legacy_provider_ids(&mut snapshot);
14641    }
14642    if !include_managed_worktrees {
14643        snapshot.managed_worktrees.clear();
14644        for workspace in &mut snapshot.workspaces {
14645            workspace.managed_worktree_profiles = None;
14646        }
14647    }
14648    if let Some(inventory) = snapshot.launch_inventory.as_mut() {
14649        if !include_spawn_profiles {
14650            inventory.spawn_profiles = None;
14651        }
14652        if !include_session_bundles {
14653            inventory.bundles = None;
14654        }
14655        if inventory.spawn_profiles.is_none()
14656            && inventory.bundles.is_none()
14657            && inventory.network_allowlists.is_none()
14658        {
14659            snapshot.launch_inventory = None;
14660        }
14661    }
14662    if !include_child_environment_profiles {
14663        clear_snapshot_child_environment_profiles(&mut snapshot);
14664    }
14665    if !include_session_bundles {
14666        clear_snapshot_session_bundles(&mut snapshot);
14667    }
14668    project_snapshot_history_for_wire(&mut snapshot, include_history_context_packs);
14669    if !include_history_context_packs {
14670        clear_snapshot_context_packs(&mut snapshot);
14671    }
14672    if !include_agent_progress {
14673        snapshot.agent_progress.clear();
14674    }
14675    if !include_session_task_correlation {
14676        clear_snapshot_session_task_bindings(&mut snapshot);
14677    }
14678    snapshot
14679}
14680
14681fn clear_snapshot_session_task_bindings(snapshot: &mut NodeSnapshot) {
14682    for record in &mut snapshot.session_records {
14683        record.task_binding = None;
14684    }
14685}
14686
14687fn project_event_without_session_task_binding(
14688    mut envelope: NodeEventEnvelope,
14689) -> NodeEventEnvelope {
14690    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14691        record.task_binding = None;
14692    }
14693    envelope
14694}
14695
14696fn project_response_without_session_task_binding(reply: &mut ResponseEnvelope) {
14697    let Ok(response) = reply.result.as_mut() else { return };
14698    match response {
14699        NodeResponse::Snapshot { snapshot, .. } => {
14700            clear_snapshot_session_task_bindings(snapshot);
14701        }
14702        NodeResponse::Resync { snapshot, events, .. } => {
14703            clear_snapshot_session_task_bindings(snapshot);
14704            for event in events {
14705                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14706                    record.task_binding = None;
14707                }
14708            }
14709        }
14710        NodeResponse::SessionRecordUpdated { record }
14711        | NodeResponse::ProviderSessionIndexed { record }
14712        | NodeResponse::NativeSessionIndexed { record, .. }
14713        | NodeResponse::SessionRecordResumed { record, .. } => {
14714            record.task_binding = None;
14715        }
14716        _ => {}
14717    }
14718}
14719
14720fn project_response_without_agent_progress(reply: &mut ResponseEnvelope) {
14721    let Ok(response) = reply.result.as_mut() else { return };
14722    match response {
14723        NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
14724            snapshot.agent_progress.clear();
14725        }
14726        _ => {}
14727    }
14728}
14729
14730fn project_event_without_managed_worktrees(
14731    mut envelope: NodeEventEnvelope,
14732) -> Option<NodeEventEnvelope> {
14733    if matches!(
14734        envelope.event,
14735        NodeEvent::ManagedWorktreeUpserted { .. }
14736            | NodeEvent::ManagedWorktreeRemoved { .. }
14737    ) {
14738        return None;
14739    }
14740    if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
14741        workspace.managed_worktree_profiles = None;
14742    }
14743    Some(envelope)
14744}
14745
14746fn project_response_without_managed_worktrees(reply: &mut ResponseEnvelope) {
14747    if matches!(
14748        reply.result,
14749        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { .. })
14750            | Ok(NodeResponse::ManagedWorktreeCleanup { .. })
14751    ) {
14752        reply.result = Err(failure(
14753            NodeFailureCode::UnsupportedCapability,
14754            "managed worktree capability was not negotiated",
14755        ));
14756        return;
14757    }
14758    let Ok(response) = reply.result.as_mut() else { return };
14759    match response {
14760        NodeResponse::Snapshot { snapshot, .. } => {
14761            snapshot.managed_worktrees.clear();
14762            for workspace in &mut snapshot.workspaces {
14763                workspace.managed_worktree_profiles = None;
14764            }
14765        }
14766        NodeResponse::Resync { snapshot, events, .. } => {
14767            snapshot.managed_worktrees.clear();
14768            for workspace in &mut snapshot.workspaces {
14769                workspace.managed_worktree_profiles = None;
14770            }
14771            *events = std::mem::take(events)
14772                .into_iter()
14773                .filter_map(project_event_without_managed_worktrees)
14774                .collect();
14775        }
14776        NodeResponse::WorkspaceRegistered { workspace }
14777        | NodeResponse::StandaloneWorkspaceCreated { workspace }
14778        | NodeResponse::WorktreeCreated { workspace, .. } => {
14779            workspace.managed_worktree_profiles = None;
14780        }
14781        NodeResponse::WorkspaceInspected { inspection } => {
14782            inspection.git.managed_worktree = None;
14783        }
14784        _ => {}
14785    }
14786}
14787
14788fn project_negotiated_provider_ids(compatibility: &mut NegotiatedNodeCompatibility) {
14789    let includes_open_provider_ids = compatibility.capabilities.iter().any(|capability| {
14790        capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
14791    });
14792    if includes_open_provider_ids {
14793        return;
14794    }
14795    compatibility
14796        .provider_contracts
14797        .retain(|contract| provider_id_is_legacy(&contract.provider));
14798    compatibility
14799        .provider_adapter_contracts
14800        .retain(|contract| provider_id_is_legacy(&contract.provider));
14801}
14802
14803fn request_requires_open_provider_ids(shared: &NodeShared, request: &NodeRequest) -> bool {
14804    if let Some(spec) = match request {
14805        NodeRequest::SpawnSpec { spec }
14806        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14807        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14808        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14809        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14810        _ => None,
14811    } {
14812        return shared
14813            .resolve_spawn_spec(spec)
14814            .is_ok_and(|resolved| !provider_id_is_legacy(&resolved.provider));
14815    }
14816    request_requires_open_provider_ids_with(
14817        request,
14818        |session| shared.validate_address(session).ok(),
14819        |record_id| shared.record(record_id).ok().map(|record| record.provider),
14820    )
14821}
14822
14823fn request_requires_child_environment_profile(
14824    shared: &NodeShared,
14825    request: &NodeRequest,
14826) -> bool {
14827    if let Some(spec) = match request {
14828        NodeRequest::SpawnSpec { spec }
14829        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14830        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14831        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14832        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14833        _ => None,
14834    } {
14835        return shared
14836            .resolve_spawn_spec(spec)
14837            .is_ok_and(|resolved| resolved.environment_profile_id.is_some());
14838    }
14839    match request {
14840        NodeRequest::Resume { session, .. }
14841        | NodeRequest::Prompt { session, .. }
14842        | NodeRequest::Paste { session, .. }
14843        | NodeRequest::Input { session, .. }
14844        | NodeRequest::TerminalBytes { session, .. }
14845        | NodeRequest::TerminalControl { session, .. }
14846        | NodeRequest::Resize { session, .. }
14847        | NodeRequest::Interrupt { session }
14848        | NodeRequest::Stop { session, .. }
14849        | NodeRequest::Remove { session }
14850        | NodeRequest::ResolveInteraction { session, .. }
14851        | NodeRequest::SetSessionMode { session, .. }
14852        | NodeRequest::SetSessionConfigOption { session, .. }
14853        | NodeRequest::SetSessionModel { session, .. }
14854        | NodeRequest::DiscoverHistory { session, .. }
14855        | NodeRequest::LoadHistory { session, .. }
14856        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14857        | NodeRequest::ExportContextPack { session } => shared
14858            .session_bindings
14859            .lock()
14860            .unwrap_or_else(|poisoned| poisoned.into_inner())
14861            .get(&session.session.instance_id)
14862            .filter(|binding| {
14863                binding.workspace_id == session.workspace_id
14864                    && binding.generation == session.session.generation
14865            })
14866            .map_or(true, |binding| binding.environment_profile.is_some()),
14867        NodeRequest::RenameSessionRecord { record_id, .. }
14868        | NodeRequest::SetSessionTask { record_id, .. }
14869        | NodeRequest::ResumeSessionRecord { record_id, .. }
14870        | NodeRequest::ForgetSessionRecord { record_id } => shared
14871            .record(record_id)
14872            .map_or(true, |record| record.environment_profile.is_some()),
14873        NodeRequest::ArmHarnessMcpReservation { .. }
14874        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14875        | NodeRequest::ActivateHarnessMcpReservation { .. }
14876        | NodeRequest::AbortHarnessMcpReservation { .. }
14877        | NodeRequest::PutHarnessMcpReplyChunk { .. }
14878        | NodeRequest::RejectHarnessMcpCall { .. }
14879        | NodeRequest::Snapshot
14880        | NodeRequest::Resync { .. }
14881        | NodeRequest::BeginDeliveryStage { .. }
14882        | NodeRequest::PutDeliveryBlobChunk { .. }
14883        | NodeRequest::CommitDeliveryStage { .. }
14884        | NodeRequest::AbortDeliveryStage { .. }
14885        | NodeRequest::BrowseHostDirectories { .. }
14886        | NodeRequest::InspectWorkspace { .. }
14887        | NodeRequest::ReadWorkspaceFile { .. }
14888        | NodeRequest::WriteWorkspaceFile { .. }
14889        | NodeRequest::CreateWorkspaceFile { .. }
14890        | NodeRequest::CreateWorkspaceDirectory { .. }
14891        | NodeRequest::ReadGitHistory { .. }
14892        | NodeRequest::ReadGitDiff { .. }
14893        | NodeRequest::AcquireController { .. }
14894        | NodeRequest::ReleaseController
14895        | NodeRequest::Spawn { .. }
14896        | NodeRequest::SpawnWithOptions { .. }
14897        | NodeRequest::SpawnSpec { .. }
14898        | NodeRequest::RegisterWorkspace { .. }
14899        | NodeRequest::CreateStandaloneWorkspace { .. }
14900        | NodeRequest::UnregisterWorkspace { .. }
14901        | NodeRequest::CreateWorktree { .. }
14902        | NodeRequest::RemoveWorktree { .. }
14903        | NodeRequest::SpawnManagedWorktree { .. }
14904        | NodeRequest::SpawnManagedWorktreeV2 { .. }
14905        | NodeRequest::CleanupManagedWorktree { .. }
14906        | NodeRequest::IndexProviderSession { .. }
14907        | NodeRequest::IndexNativeSession { .. }
14908        | NodeRequest::CatalogNativeSessions { .. }
14909        | NodeRequest::PageNativeSessions { .. }
14910        | NodeRequest::PreviewNativeSession { .. }
14911        | NodeRequest::PreviewSessionRecord { .. }
14912        | NodeRequest::ForgetContextPack { .. }
14913        | NodeRequest::ResolveDurableContextPack { .. }
14914        | NodeRequest::ReadContextPack { .. }
14915        | NodeRequest::Shutdown => false,
14916    }
14917}
14918
14919fn request_requires_session_bundle(shared: &NodeShared, request: &NodeRequest) -> bool {
14920    if let Some(spec) = match request {
14921        NodeRequest::SpawnSpec { spec }
14922        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14923        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14924        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14925        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14926        _ => None,
14927    } {
14928        return shared
14929            .resolve_spawn_spec(spec)
14930            .is_ok_and(|resolved| resolved.bundle_id.is_some());
14931    }
14932    match request {
14933        NodeRequest::Resume { session, .. }
14934        | NodeRequest::Prompt { session, .. }
14935        | NodeRequest::Paste { session, .. }
14936        | NodeRequest::Input { session, .. }
14937        | NodeRequest::TerminalBytes { session, .. }
14938        | NodeRequest::TerminalControl { session, .. }
14939        | NodeRequest::Resize { session, .. }
14940        | NodeRequest::Interrupt { session }
14941        | NodeRequest::Stop { session, .. }
14942        | NodeRequest::Remove { session }
14943        | NodeRequest::ResolveInteraction { session, .. }
14944        | NodeRequest::SetSessionMode { session, .. }
14945        | NodeRequest::SetSessionConfigOption { session, .. }
14946        | NodeRequest::SetSessionModel { session, .. }
14947        | NodeRequest::DiscoverHistory { session, .. }
14948        | NodeRequest::LoadHistory { session, .. }
14949        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14950        | NodeRequest::ExportContextPack { session } => shared
14951            .session_bindings
14952            .lock()
14953            .unwrap_or_else(|poisoned| poisoned.into_inner())
14954            .get(&session.session.instance_id)
14955            .filter(|binding| {
14956                binding.workspace_id == session.workspace_id
14957                    && binding.generation == session.session.generation
14958            })
14959            .map_or(true, |binding| binding.bundle.is_some()),
14960        NodeRequest::RenameSessionRecord { record_id, .. }
14961        | NodeRequest::SetSessionTask { record_id, .. }
14962        | NodeRequest::ResumeSessionRecord { record_id, .. }
14963        | NodeRequest::ForgetSessionRecord { record_id } => shared
14964            .record(record_id)
14965            .map_or(true, |record| record.bundle.is_some()),
14966        NodeRequest::ArmHarnessMcpReservation { .. }
14967        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14968        | NodeRequest::ActivateHarnessMcpReservation { .. }
14969        | NodeRequest::AbortHarnessMcpReservation { .. }
14970        | NodeRequest::PutHarnessMcpReplyChunk { .. }
14971        | NodeRequest::RejectHarnessMcpCall { .. }
14972        | NodeRequest::Snapshot
14973        | NodeRequest::Resync { .. }
14974        | NodeRequest::BeginDeliveryStage { .. }
14975        | NodeRequest::PutDeliveryBlobChunk { .. }
14976        | NodeRequest::CommitDeliveryStage { .. }
14977        | NodeRequest::AbortDeliveryStage { .. }
14978        | NodeRequest::BrowseHostDirectories { .. }
14979        | NodeRequest::InspectWorkspace { .. }
14980        | NodeRequest::ReadWorkspaceFile { .. }
14981        | NodeRequest::WriteWorkspaceFile { .. }
14982        | NodeRequest::CreateWorkspaceFile { .. }
14983        | NodeRequest::CreateWorkspaceDirectory { .. }
14984        | NodeRequest::ReadGitHistory { .. }
14985        | NodeRequest::ReadGitDiff { .. }
14986        | NodeRequest::AcquireController { .. }
14987        | NodeRequest::ReleaseController
14988        | NodeRequest::Spawn { .. }
14989        | NodeRequest::SpawnWithOptions { .. }
14990        | NodeRequest::SpawnSpec { .. }
14991        | NodeRequest::RegisterWorkspace { .. }
14992        | NodeRequest::CreateStandaloneWorkspace { .. }
14993        | NodeRequest::UnregisterWorkspace { .. }
14994        | NodeRequest::CreateWorktree { .. }
14995        | NodeRequest::RemoveWorktree { .. }
14996        | NodeRequest::SpawnManagedWorktree { .. }
14997        | NodeRequest::SpawnManagedWorktreeV2 { .. }
14998        | NodeRequest::CleanupManagedWorktree { .. }
14999        | NodeRequest::IndexProviderSession { .. }
15000        | NodeRequest::IndexNativeSession { .. }
15001        | NodeRequest::CatalogNativeSessions { .. }
15002        | NodeRequest::PageNativeSessions { .. }
15003        | NodeRequest::PreviewNativeSession { .. }
15004        | NodeRequest::PreviewSessionRecord { .. }
15005        | NodeRequest::ForgetContextPack { .. }
15006        | NodeRequest::ResolveDurableContextPack { .. }
15007        | NodeRequest::ReadContextPack { .. }
15008        | NodeRequest::Shutdown => false,
15009    }
15010}
15011
15012fn request_requires_history_context_pack(shared: &NodeShared, request: &NodeRequest) -> bool {
15013    if request.requires_history_context_pack_capability() {
15014        return true;
15015    }
15016    if let Some(spec) = match request {
15017        NodeRequest::SpawnSpec { spec }
15018        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
15019        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
15020        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
15021        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
15022        _ => None,
15023    } {
15024        return shared
15025            .resolve_spawn_spec(spec)
15026            .map_or(true, |resolved| resolved.context_id.is_some());
15027    }
15028    match request {
15029        NodeRequest::Resume { session, .. }
15030        | NodeRequest::Prompt { session, .. }
15031        | NodeRequest::Paste { session, .. }
15032        | NodeRequest::Input { session, .. }
15033        | NodeRequest::TerminalBytes { session, .. }
15034        | NodeRequest::TerminalControl { session, .. }
15035        | NodeRequest::Resize { session, .. }
15036        | NodeRequest::Interrupt { session }
15037        | NodeRequest::Stop { session, .. }
15038        | NodeRequest::Remove { session }
15039        | NodeRequest::ResolveInteraction { session, .. }
15040        | NodeRequest::SetSessionMode { session, .. }
15041        | NodeRequest::SetSessionConfigOption { session, .. }
15042        | NodeRequest::SetSessionModel { session, .. } => shared
15043            .session_bindings
15044            .lock()
15045            .unwrap_or_else(|poisoned| poisoned.into_inner())
15046            .get(&session.session.instance_id)
15047            .filter(|binding| {
15048                binding.workspace_id == session.workspace_id
15049                    && binding.generation == session.session.generation
15050            })
15051            .map_or(true, |binding| binding.context.is_some()),
15052        NodeRequest::RenameSessionRecord { record_id, .. }
15053        | NodeRequest::SetSessionTask { record_id, .. }
15054        | NodeRequest::ResumeSessionRecord { record_id, .. }
15055        | NodeRequest::ForgetSessionRecord { record_id } => shared
15056            .record(record_id)
15057            .map_or(true, |record| record.context.is_some()),
15058        NodeRequest::ArmHarnessMcpReservation { .. }
15059        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
15060        | NodeRequest::ActivateHarnessMcpReservation { .. }
15061        | NodeRequest::AbortHarnessMcpReservation { .. }
15062        | NodeRequest::PutHarnessMcpReplyChunk { .. }
15063        | NodeRequest::RejectHarnessMcpCall { .. }
15064        | NodeRequest::Snapshot
15065        | NodeRequest::Resync { .. }
15066        | NodeRequest::BeginDeliveryStage { .. }
15067        | NodeRequest::PutDeliveryBlobChunk { .. }
15068        | NodeRequest::CommitDeliveryStage { .. }
15069        | NodeRequest::AbortDeliveryStage { .. }
15070        | NodeRequest::BrowseHostDirectories { .. }
15071        | NodeRequest::InspectWorkspace { .. }
15072        | NodeRequest::ReadWorkspaceFile { .. }
15073        | NodeRequest::WriteWorkspaceFile { .. }
15074        | NodeRequest::CreateWorkspaceFile { .. }
15075        | NodeRequest::CreateWorkspaceDirectory { .. }
15076        | NodeRequest::ReadGitHistory { .. }
15077        | NodeRequest::ReadGitDiff { .. }
15078        | NodeRequest::AcquireController { .. }
15079        | NodeRequest::ReleaseController
15080        | NodeRequest::Spawn { .. }
15081        | NodeRequest::SpawnWithOptions { .. }
15082        | NodeRequest::SpawnSpec { .. }
15083        | NodeRequest::RegisterWorkspace { .. }
15084        | NodeRequest::CreateStandaloneWorkspace { .. }
15085        | NodeRequest::UnregisterWorkspace { .. }
15086        | NodeRequest::CreateWorktree { .. }
15087        | NodeRequest::RemoveWorktree { .. }
15088        | NodeRequest::SpawnManagedWorktree { .. }
15089        | NodeRequest::SpawnManagedWorktreeV2 { .. }
15090        | NodeRequest::CleanupManagedWorktree { .. }
15091        | NodeRequest::IndexProviderSession { .. }
15092        | NodeRequest::IndexNativeSession { .. }
15093        | NodeRequest::CatalogNativeSessions { .. }
15094        | NodeRequest::PageNativeSessions { .. }
15095        | NodeRequest::PreviewNativeSession { .. }
15096        | NodeRequest::PreviewSessionRecord { .. }
15097        | NodeRequest::DiscoverHistory { .. }
15098        | NodeRequest::LoadHistory { .. }
15099        | NodeRequest::ExportContextPackForSessionRecord { .. }
15100        | NodeRequest::ExportContextPack { .. }
15101        | NodeRequest::ForgetContextPack { .. }
15102        | NodeRequest::ResolveDurableContextPack { .. }
15103        | NodeRequest::ReadContextPack { .. }
15104        | NodeRequest::Shutdown => false,
15105    }
15106}
15107
15108fn request_requires_open_provider_ids_with(
15109    request: &NodeRequest,
15110    provider_for_session: impl Fn(&SessionAddress) -> Option<AgentId>,
15111    provider_for_record: impl Fn(&SessionRecordId) -> Option<AgentId>,
15112) -> bool {
15113    match request {
15114        NodeRequest::Spawn { provider, .. } | NodeRequest::SpawnWithOptions { provider, .. } => !provider_id_is_legacy(provider),
15115        NodeRequest::IndexProviderSession { provider, .. } => !provider_id_is_legacy(provider),
15116        NodeRequest::CatalogNativeSessions { route, .. }
15117        | NodeRequest::PageNativeSessions { route, .. } => {
15118            !provider_id_is_legacy(&route.provider)
15119        }
15120        NodeRequest::PreviewNativeSession { selection, .. }
15121        | NodeRequest::IndexNativeSession { selection, .. } => {
15122            !provider_id_is_legacy(&selection.route.provider)
15123        }
15124        NodeRequest::SpawnSpec { .. }
15125        | NodeRequest::SpawnSpecWithHarnessMcp { .. } => false,
15126        NodeRequest::SpawnManagedWorktree { .. }
15127        | NodeRequest::SpawnManagedWorktreeV2 { .. } => false,
15128        NodeRequest::Resume { session, .. }
15129        | NodeRequest::Prompt { session, .. }
15130        | NodeRequest::Paste { session, .. }
15131        | NodeRequest::Input { session, .. }
15132        | NodeRequest::TerminalBytes { session, .. }
15133        | NodeRequest::TerminalControl { session, .. }
15134        | NodeRequest::Resize { session, .. }
15135        | NodeRequest::Interrupt { session }
15136        | NodeRequest::Stop { session, .. }
15137        | NodeRequest::Remove { session }
15138        | NodeRequest::ResolveInteraction { session, .. }
15139        | NodeRequest::SetSessionMode { session, .. }
15140        | NodeRequest::SetSessionConfigOption { session, .. }
15141        | NodeRequest::SetSessionModel { session, .. }
15142        | NodeRequest::DiscoverHistory { session, .. }
15143        | NodeRequest::LoadHistory { session, .. }
15144        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
15145        | NodeRequest::ExportContextPack { session } => provider_for_session(session)
15146            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
15147        NodeRequest::RenameSessionRecord { record_id, .. }
15148        | NodeRequest::SetSessionTask { record_id, .. }
15149        | NodeRequest::ResumeSessionRecord { record_id, .. }
15150        | NodeRequest::ForgetSessionRecord { record_id } => provider_for_record(record_id)
15151            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
15152        NodeRequest::PreviewSessionRecord { record_id, .. } => provider_for_record(record_id)
15153            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
15154        NodeRequest::ArmHarnessMcpReservation { .. }
15155        | NodeRequest::ActivateHarnessMcpReservation { .. }
15156        | NodeRequest::AbortHarnessMcpReservation { .. }
15157        | NodeRequest::PutHarnessMcpReplyChunk { .. }
15158        | NodeRequest::RejectHarnessMcpCall { .. }
15159        | NodeRequest::Snapshot
15160        | NodeRequest::Resync { .. }
15161        | NodeRequest::BeginDeliveryStage { .. }
15162        | NodeRequest::PutDeliveryBlobChunk { .. }
15163        | NodeRequest::CommitDeliveryStage { .. }
15164        | NodeRequest::AbortDeliveryStage { .. }
15165        | NodeRequest::BrowseHostDirectories { .. }
15166        | NodeRequest::InspectWorkspace { .. }
15167        | NodeRequest::ReadWorkspaceFile { .. }
15168        | NodeRequest::WriteWorkspaceFile { .. }
15169        | NodeRequest::CreateWorkspaceFile { .. }
15170        | NodeRequest::CreateWorkspaceDirectory { .. }
15171        | NodeRequest::ReadGitHistory { .. }
15172        | NodeRequest::ReadGitDiff { .. }
15173        | NodeRequest::AcquireController { .. }
15174        | NodeRequest::ReleaseController
15175        | NodeRequest::RegisterWorkspace { .. }
15176        | NodeRequest::CreateStandaloneWorkspace { .. }
15177        | NodeRequest::UnregisterWorkspace { .. }
15178        | NodeRequest::CreateWorktree { .. }
15179        | NodeRequest::RemoveWorktree { .. }
15180        | NodeRequest::CleanupManagedWorktree { .. }
15181        | NodeRequest::Shutdown => false,
15182        NodeRequest::ForgetContextPack { .. } => true,
15183        // No session/record to cheaply resolve a provider from here either;
15184        // the resolved receipt can carry an open-id provider, so require the
15185        // same capability as ForgetContextPack unconditionally. `ReadContextPack`
15186        // is the same digest-keyed lookup one step further into the same
15187        // catalog, so it inherits the same conservative answer.
15188        NodeRequest::ResolveDurableContextPack { .. }
15189        | NodeRequest::ReadContextPack { .. } => true,
15190    }
15191}
15192
15193fn project_snapshot_legacy_provider_ids(snapshot: &mut NodeSnapshot) {
15194    snapshot.enabled_providers.retain(provider_id_is_legacy);
15195    snapshot.provider_runtime_statuses = ProviderRuntimeStatuses::new(
15196        snapshot
15197            .provider_runtime_statuses
15198            .as_slice()
15199            .iter()
15200            .filter(|status| provider_id_is_legacy(status.provider()))
15201            .cloned(),
15202    )
15203    .expect("a filtered provider runtime projection remains valid");
15204    snapshot
15205        .session_records
15206        .retain(|record| provider_id_is_legacy(&record.provider));
15207    for workspace in &mut snapshot.workspaces {
15208        workspace
15209            .sessions
15210            .retain(|session| provider_id_is_legacy(&session.agent_id));
15211    }
15212}
15213
15214fn clear_snapshot_child_environment_profiles(snapshot: &mut NodeSnapshot) {
15215    for record in &mut snapshot.session_records {
15216        record.environment_profile = None;
15217    }
15218    if let Some(profiles) = snapshot
15219        .launch_inventory
15220        .as_mut()
15221        .and_then(|inventory| inventory.spawn_profiles.as_mut())
15222    {
15223        for profile in profiles {
15224            profile.environment_profile = None;
15225        }
15226    }
15227}
15228
15229fn project_event_without_child_environment_profile(
15230    mut envelope: NodeEventEnvelope,
15231) -> NodeEventEnvelope {
15232    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15233        record.environment_profile = None;
15234    }
15235    envelope
15236}
15237
15238fn project_response_without_child_environment_profile(reply: &mut ResponseEnvelope) {
15239    let contains_environment_profile = match reply.result.as_ref() {
15240        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
15241            receipt.environment_profile.is_some()
15242        }
15243        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15244            receipt.spawn.environment_profile.is_some()
15245        }
15246        _ => false,
15247    };
15248    if contains_environment_profile {
15249        reply.result = Err(failure(
15250            NodeFailureCode::UnsupportedCapability,
15251            "child environment profiles were not negotiated",
15252        ));
15253        return;
15254    }
15255    let Ok(response) = reply.result.as_mut() else {
15256        return;
15257    };
15258    match response {
15259        NodeResponse::Snapshot { snapshot, .. } => {
15260            clear_snapshot_child_environment_profiles(snapshot);
15261        }
15262        NodeResponse::Resync { snapshot, events, .. } => {
15263            clear_snapshot_child_environment_profiles(snapshot);
15264            for event in events {
15265                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15266                    record.environment_profile = None;
15267                }
15268            }
15269        }
15270        NodeResponse::SessionRecordUpdated { record }
15271        | NodeResponse::ProviderSessionIndexed { record }
15272        | NodeResponse::NativeSessionIndexed { record, .. }
15273        | NodeResponse::SessionRecordResumed { record, .. } => {
15274            record.environment_profile = None;
15275        }
15276        NodeResponse::Armed { .. }
15277        | NodeResponse::Spawned { .. }
15278        | NodeResponse::Activated { .. }
15279        | NodeResponse::Aborted { .. }
15280        | NodeResponse::ReplyChunkAccepted { .. }
15281        | NodeResponse::CallRejected { .. }
15282        | NodeResponse::DeliveryStageBegun { .. }
15283        | NodeResponse::DeliveryBlobChunkAccepted { .. }
15284        | NodeResponse::DeliveryCommitted { .. }
15285        | NodeResponse::DeliveryStageAborted { .. }
15286        | NodeResponse::WorkspaceInspected { .. }
15287        | NodeResponse::HostDirectoriesBrowsed { .. }
15288        | NodeResponse::WorkspaceFileRead { .. }
15289        | NodeResponse::WorkspaceFileWritten { .. }
15290        | NodeResponse::WorkspaceFileCreated { .. }
15291        | NodeResponse::WorkspaceDirectoryCreated { .. }
15292        | NodeResponse::GitHistoryRead { .. }
15293        | NodeResponse::GitDiffRead { .. }
15294        | NodeResponse::Controller { .. }
15295        | NodeResponse::SpawnAccepted { .. }
15296        | NodeResponse::SpawnSpecAccepted { .. }
15297        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15298        | NodeResponse::ManagedWorktreeCleanup { .. }
15299        | NodeResponse::NativeSessionsCataloged { .. }
15300        | NodeResponse::NativeSessionsPaged { .. }
15301        | NodeResponse::NativeSessionPreviewed { .. }
15302        | NodeResponse::SessionRecordPreviewed { .. }
15303        | NodeResponse::HistoryDiscovered { .. }
15304        | NodeResponse::HistoryLoaded { .. }
15305        | NodeResponse::ContextPackForSessionRecordExported { .. }
15306        | NodeResponse::ContextPackExported { .. }
15307        | NodeResponse::ContextPackForgotten { .. }
15308        | NodeResponse::DurableContextPackResolved { .. }
15309        | NodeResponse::ContextPackBytesRead { .. }
15310        | NodeResponse::SessionRecordForgotten { .. }
15311        | NodeResponse::WorkspaceRegistered { .. }
15312        | NodeResponse::StandaloneWorkspaceCreated { .. }
15313        | NodeResponse::WorkspaceUnregistered { .. }
15314        | NodeResponse::WorktreeCreated { .. }
15315        | NodeResponse::WorktreeRemoved { .. }
15316        | NodeResponse::Accepted
15317        | NodeResponse::ShuttingDown => {}
15318    }
15319}
15320
15321fn clear_snapshot_session_bundles(snapshot: &mut NodeSnapshot) {
15322    for record in &mut snapshot.session_records {
15323        record.bundle = None;
15324    }
15325}
15326
15327fn project_event_without_session_bundle(
15328    mut envelope: NodeEventEnvelope,
15329) -> NodeEventEnvelope {
15330    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15331        record.bundle = None;
15332    }
15333    envelope
15334}
15335
15336fn project_response_without_session_bundle(reply: &mut ResponseEnvelope) {
15337    let contains_bundle = match reply.result.as_ref() {
15338        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.bundle.is_some(),
15339        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15340            receipt.spawn.bundle.is_some()
15341        }
15342        _ => false,
15343    };
15344    if contains_bundle {
15345        reply.result = Err(failure(
15346            NodeFailureCode::UnsupportedCapability,
15347            "session bundles were not negotiated",
15348        ));
15349        return;
15350    }
15351    let Ok(response) = reply.result.as_mut() else {
15352        return;
15353    };
15354    match response {
15355        NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_session_bundles(snapshot),
15356        NodeResponse::Resync { snapshot, events, .. } => {
15357            clear_snapshot_session_bundles(snapshot);
15358            for event in events {
15359                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15360                    record.bundle = None;
15361                }
15362            }
15363        }
15364        NodeResponse::SessionRecordUpdated { record }
15365        | NodeResponse::ProviderSessionIndexed { record }
15366        | NodeResponse::NativeSessionIndexed { record, .. }
15367        | NodeResponse::SessionRecordResumed { record, .. } => record.bundle = None,
15368        NodeResponse::Armed { .. }
15369        | NodeResponse::Spawned { .. }
15370        | NodeResponse::Activated { .. }
15371        | NodeResponse::Aborted { .. }
15372        | NodeResponse::ReplyChunkAccepted { .. }
15373        | NodeResponse::CallRejected { .. }
15374        | NodeResponse::DeliveryStageBegun { .. }
15375        | NodeResponse::DeliveryBlobChunkAccepted { .. }
15376        | NodeResponse::DeliveryCommitted { .. }
15377        | NodeResponse::DeliveryStageAborted { .. }
15378        | NodeResponse::WorkspaceInspected { .. }
15379        | NodeResponse::HostDirectoriesBrowsed { .. }
15380        | NodeResponse::WorkspaceFileRead { .. }
15381        | NodeResponse::WorkspaceFileWritten { .. }
15382        | NodeResponse::WorkspaceFileCreated { .. }
15383        | NodeResponse::WorkspaceDirectoryCreated { .. }
15384        | NodeResponse::GitHistoryRead { .. }
15385        | NodeResponse::GitDiffRead { .. }
15386        | NodeResponse::Controller { .. }
15387        | NodeResponse::SpawnAccepted { .. }
15388        | NodeResponse::SpawnSpecAccepted { .. }
15389        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15390        | NodeResponse::ManagedWorktreeCleanup { .. }
15391        | NodeResponse::NativeSessionsCataloged { .. }
15392        | NodeResponse::NativeSessionsPaged { .. }
15393        | NodeResponse::NativeSessionPreviewed { .. }
15394        | NodeResponse::SessionRecordPreviewed { .. }
15395        | NodeResponse::HistoryDiscovered { .. }
15396        | NodeResponse::HistoryLoaded { .. }
15397        | NodeResponse::ContextPackForSessionRecordExported { .. }
15398        | NodeResponse::ContextPackExported { .. }
15399        | NodeResponse::ContextPackForgotten { .. }
15400        | NodeResponse::DurableContextPackResolved { .. }
15401        | NodeResponse::ContextPackBytesRead { .. }
15402        | NodeResponse::SessionRecordForgotten { .. }
15403        | NodeResponse::WorkspaceRegistered { .. }
15404        | NodeResponse::StandaloneWorkspaceCreated { .. }
15405        | NodeResponse::WorkspaceUnregistered { .. }
15406        | NodeResponse::WorktreeCreated { .. }
15407        | NodeResponse::WorktreeRemoved { .. }
15408        | NodeResponse::Accepted
15409        | NodeResponse::ShuttingDown => {}
15410    }
15411}
15412
15413fn project_snapshot_history_for_wire(
15414    snapshot: &mut NodeSnapshot,
15415    include_history_metadata: bool,
15416) {
15417    for workspace in &mut snapshot.workspaces {
15418        for session in &mut workspace.sessions {
15419            if include_history_metadata {
15420                session.history.loaded = None;
15421            } else {
15422                session.history = Default::default();
15423            }
15424        }
15425    }
15426}
15427
15428fn project_event_history_for_wire(
15429    mut envelope: NodeEventEnvelope,
15430    include_history_metadata: bool,
15431) -> NodeEventEnvelope {
15432    if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
15433        for session in &mut workspace.sessions {
15434            if include_history_metadata {
15435                session.history.loaded = None;
15436            } else {
15437                session.history = Default::default();
15438            }
15439        }
15440    }
15441    envelope
15442}
15443
15444fn project_response_history_for_wire(
15445    reply: &mut ResponseEnvelope,
15446    include_history_metadata: bool,
15447) {
15448    let Ok(response) = reply.result.as_mut() else {
15449        return;
15450    };
15451    match response {
15452        NodeResponse::Snapshot { snapshot, .. } => {
15453            project_snapshot_history_for_wire(snapshot, include_history_metadata);
15454        }
15455        NodeResponse::Resync {
15456            snapshot, events, ..
15457        } => {
15458            project_snapshot_history_for_wire(snapshot, include_history_metadata);
15459            for event in events {
15460                *event = project_event_history_for_wire(
15461                    event.clone(),
15462                    include_history_metadata,
15463                );
15464            }
15465        }
15466        NodeResponse::WorkspaceRegistered { workspace }
15467        | NodeResponse::StandaloneWorkspaceCreated { workspace }
15468        | NodeResponse::WorktreeCreated { workspace, .. } => {
15469            for session in &mut workspace.sessions {
15470                if include_history_metadata {
15471                    session.history.loaded = None;
15472                } else {
15473                    session.history = Default::default();
15474                }
15475            }
15476        }
15477        _ => {}
15478    }
15479}
15480
15481fn clear_snapshot_context_packs(snapshot: &mut NodeSnapshot) {
15482    for record in &mut snapshot.session_records {
15483        record.context_id = None;
15484        record.context = None;
15485        record.exported_context = None;
15486    }
15487}
15488
15489fn project_event_without_context_pack(
15490    mut envelope: NodeEventEnvelope,
15491) -> NodeEventEnvelope {
15492    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15493        record.context_id = None;
15494        record.context = None;
15495        record.exported_context = None;
15496    }
15497    envelope
15498}
15499
15500fn project_response_without_context_pack(reply: &mut ResponseEnvelope) {
15501    let contains_context = match reply.result.as_ref() {
15502        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.context.is_some(),
15503        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15504            receipt.spawn.context.is_some()
15505        }
15506        Ok(NodeResponse::HistoryDiscovered { .. })
15507        | Ok(NodeResponse::HistoryLoaded { .. })
15508        | Ok(NodeResponse::ContextPackForSessionRecordExported { .. })
15509        | Ok(NodeResponse::ContextPackExported { .. })
15510        | Ok(NodeResponse::ContextPackForgotten { .. })
15511        | Ok(NodeResponse::DurableContextPackResolved { .. }) => true,
15512        _ => false,
15513    };
15514    if contains_context {
15515        reply.result = Err(failure(
15516            NodeFailureCode::UnsupportedCapability,
15517            "history context packs were not negotiated",
15518        ));
15519        return;
15520    }
15521    let Ok(response) = reply.result.as_mut() else {
15522        return;
15523    };
15524    match response {
15525        NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_context_packs(snapshot),
15526        NodeResponse::Resync {
15527            snapshot, events, ..
15528        } => {
15529            clear_snapshot_context_packs(snapshot);
15530            for event in events {
15531                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15532                    record.context_id = None;
15533                    record.context = None;
15534                    record.exported_context = None;
15535                }
15536            }
15537        }
15538        NodeResponse::SessionRecordUpdated { record }
15539        | NodeResponse::ProviderSessionIndexed { record }
15540        | NodeResponse::NativeSessionIndexed { record, .. }
15541        | NodeResponse::SessionRecordResumed { record, .. } => {
15542            record.context_id = None;
15543            record.context = None;
15544            record.exported_context = None;
15545        }
15546        _ => {}
15547    }
15548}
15549
15550fn project_response_legacy_provider_ids(shared: &NodeShared, reply: &mut ResponseEnvelope) {
15551    let contains_open_record = match reply.result.as_ref() {
15552        Ok(NodeResponse::SessionRecordUpdated { record })
15553        | Ok(NodeResponse::ProviderSessionIndexed { record })
15554        | Ok(NodeResponse::SessionRecordResumed { record, .. }) => {
15555            !provider_id_is_legacy(&record.provider)
15556        }
15557        Ok(NodeResponse::NativeSessionIndexed { selection, record }) => {
15558            !provider_id_is_legacy(&selection.route.provider)
15559                || !provider_id_is_legacy(&record.provider)
15560        }
15561        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
15562            !provider_id_is_legacy(&receipt.provider)
15563        }
15564        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15565            !provider_id_is_legacy(&receipt.spawn.provider)
15566        }
15567        Ok(NodeResponse::NativeSessionsCataloged { route, .. })
15568        | Ok(NodeResponse::NativeSessionsPaged { route, .. }) => {
15569            !provider_id_is_legacy(&route.provider)
15570        }
15571        Ok(NodeResponse::NativeSessionPreviewed { selection, .. }) => {
15572            !provider_id_is_legacy(&selection.route.provider)
15573        }
15574        _ => false,
15575    };
15576    if contains_open_record {
15577        reply.result = Err(failure(
15578            NodeFailureCode::UnsupportedCapability,
15579            "open provider IDs were not negotiated",
15580        ));
15581        return;
15582    }
15583    let Ok(response) = reply.result.as_mut() else {
15584        return;
15585    };
15586    match response {
15587        NodeResponse::Snapshot { snapshot, .. } => {
15588            project_snapshot_legacy_provider_ids(snapshot);
15589        }
15590        NodeResponse::Resync { snapshot, events, .. } => {
15591            project_snapshot_legacy_provider_ids(snapshot);
15592            *events = std::mem::take(events)
15593                .into_iter()
15594                .filter_map(|event| project_event_legacy_provider_ids(shared, event))
15595                .collect();
15596        }
15597        NodeResponse::WorkspaceRegistered { workspace }
15598        | NodeResponse::StandaloneWorkspaceCreated { workspace }
15599        | NodeResponse::WorktreeCreated { workspace, .. } => {
15600            workspace
15601                .sessions
15602                .retain(|session| provider_id_is_legacy(&session.agent_id));
15603        }
15604        NodeResponse::Armed { .. }
15605        | NodeResponse::Spawned { .. }
15606        | NodeResponse::Activated { .. }
15607        | NodeResponse::Aborted { .. }
15608        | NodeResponse::ReplyChunkAccepted { .. }
15609        | NodeResponse::CallRejected { .. }
15610        | NodeResponse::DeliveryStageBegun { .. }
15611        | NodeResponse::DeliveryBlobChunkAccepted { .. }
15612        | NodeResponse::DeliveryCommitted { .. }
15613        | NodeResponse::DeliveryStageAborted { .. }
15614        | NodeResponse::WorkspaceInspected { .. }
15615        | NodeResponse::HostDirectoriesBrowsed { .. }
15616        | NodeResponse::WorkspaceFileRead { .. }
15617        | NodeResponse::WorkspaceFileWritten { .. }
15618        | NodeResponse::WorkspaceFileCreated { .. }
15619        | NodeResponse::WorkspaceDirectoryCreated { .. }
15620        | NodeResponse::GitHistoryRead { .. }
15621        | NodeResponse::GitDiffRead { .. }
15622        | NodeResponse::Controller { .. }
15623        | NodeResponse::SpawnAccepted { .. }
15624        | NodeResponse::SpawnSpecAccepted { .. }
15625        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15626        | NodeResponse::ManagedWorktreeCleanup { .. }
15627        | NodeResponse::NativeSessionsCataloged { .. }
15628        | NodeResponse::NativeSessionsPaged { .. }
15629        | NodeResponse::NativeSessionPreviewed { .. }
15630        | NodeResponse::SessionRecordPreviewed { .. }
15631        | NodeResponse::HistoryDiscovered { .. }
15632        | NodeResponse::HistoryLoaded { .. }
15633        | NodeResponse::ContextPackForSessionRecordExported { .. }
15634        | NodeResponse::ContextPackExported { .. }
15635        | NodeResponse::ContextPackForgotten { .. }
15636        | NodeResponse::DurableContextPackResolved { .. }
15637        | NodeResponse::ContextPackBytesRead { .. }
15638        | NodeResponse::SessionRecordUpdated { .. }
15639        | NodeResponse::ProviderSessionIndexed { .. }
15640        | NodeResponse::NativeSessionIndexed { .. }
15641        | NodeResponse::SessionRecordResumed { .. }
15642        | NodeResponse::SessionRecordForgotten { .. }
15643        | NodeResponse::WorkspaceUnregistered { .. }
15644        | NodeResponse::WorktreeRemoved { .. }
15645        | NodeResponse::Accepted
15646        | NodeResponse::ShuttingDown => {}
15647    }
15648}
15649
15650fn project_event_legacy_provider_ids(
15651    shared: &NodeShared,
15652    mut envelope: NodeEventEnvelope,
15653) -> Option<NodeEventEnvelope> {
15654    let include = match &mut envelope.event {
15655        NodeEvent::HarnessMcpReadCall { .. } => true,
15656        NodeEvent::Control { address, .. }
15657        | NodeEvent::AgentStream { address, .. }
15658        | NodeEvent::TerminalFrame { address, .. } => shared
15659            .validate_address(address)
15660            .is_ok_and(|provider| provider_id_is_legacy(&provider)),
15661        NodeEvent::WorkspaceAdded { workspace } => {
15662            workspace
15663                .sessions
15664                .retain(|session| provider_id_is_legacy(&session.agent_id));
15665            true
15666        }
15667        NodeEvent::SessionRecordUpserted { record } => {
15668            provider_id_is_legacy(&record.provider)
15669        }
15670        NodeEvent::SessionRecordHistorySummarized { .. } => false,
15671        NodeEvent::SessionRecordRemoved { .. } => shared
15672            .history
15673            .lock()
15674            .unwrap_or_else(|poisoned| poisoned.into_inner())
15675            .removed_record_providers
15676            .get(&envelope.sequence)
15677            .is_some_and(provider_id_is_legacy),
15678        NodeEvent::ControllerChanged { .. }
15679        | NodeEvent::ManagedWorktreeUpserted { .. }
15680        | NodeEvent::ManagedWorktreeRemoved { .. }
15681        | NodeEvent::WorkspaceRemoved { .. }
15682        | NodeEvent::ResyncRequired { .. } => true,
15683    };
15684    include.then_some(envelope)
15685}
15686
15687fn clear_response_provider_runtime_status(reply: &mut ResponseEnvelope) {
15688    let Ok(response) = reply.result.as_mut() else {
15689        return;
15690    };
15691    match response {
15692        NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
15693            snapshot.provider_runtime_statuses.clear();
15694        }
15695        NodeResponse::Armed { .. }
15696        | NodeResponse::Spawned { .. }
15697        | NodeResponse::Activated { .. }
15698        | NodeResponse::Aborted { .. }
15699        | NodeResponse::ReplyChunkAccepted { .. }
15700        | NodeResponse::CallRejected { .. }
15701        | NodeResponse::DeliveryStageBegun { .. }
15702        | NodeResponse::DeliveryBlobChunkAccepted { .. }
15703        | NodeResponse::DeliveryCommitted { .. }
15704        | NodeResponse::DeliveryStageAborted { .. }
15705        | NodeResponse::WorkspaceInspected { .. }
15706        | NodeResponse::HostDirectoriesBrowsed { .. }
15707        | NodeResponse::WorkspaceFileRead { .. }
15708        | NodeResponse::WorkspaceFileWritten { .. }
15709        | NodeResponse::WorkspaceFileCreated { .. }
15710        | NodeResponse::WorkspaceDirectoryCreated { .. }
15711        | NodeResponse::GitHistoryRead { .. }
15712        | NodeResponse::GitDiffRead { .. }
15713        | NodeResponse::Controller { .. }
15714        | NodeResponse::SpawnAccepted { .. }
15715        | NodeResponse::SpawnSpecAccepted { .. }
15716        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15717        | NodeResponse::ManagedWorktreeCleanup { .. }
15718        | NodeResponse::NativeSessionsCataloged { .. }
15719        | NodeResponse::NativeSessionsPaged { .. }
15720        | NodeResponse::NativeSessionPreviewed { .. }
15721        | NodeResponse::SessionRecordPreviewed { .. }
15722        | NodeResponse::HistoryDiscovered { .. }
15723        | NodeResponse::HistoryLoaded { .. }
15724        | NodeResponse::ContextPackForSessionRecordExported { .. }
15725        | NodeResponse::ContextPackExported { .. }
15726        | NodeResponse::ContextPackForgotten { .. }
15727        | NodeResponse::DurableContextPackResolved { .. }
15728        | NodeResponse::ContextPackBytesRead { .. }
15729        | NodeResponse::SessionRecordUpdated { .. }
15730        | NodeResponse::ProviderSessionIndexed { .. }
15731        | NodeResponse::NativeSessionIndexed { .. }
15732        | NodeResponse::SessionRecordResumed { .. }
15733        | NodeResponse::SessionRecordForgotten { .. }
15734        | NodeResponse::WorkspaceRegistered { .. }
15735        | NodeResponse::StandaloneWorkspaceCreated { .. }
15736        | NodeResponse::WorkspaceUnregistered { .. }
15737        | NodeResponse::WorktreeCreated { .. }
15738        | NodeResponse::WorktreeRemoved { .. }
15739        | NodeResponse::Accepted
15740        | NodeResponse::ShuttingDown => {}
15741    }
15742}
15743
15744struct ControllerReleaseGuard {
15745    shared: Arc<NodeShared>,
15746    connection_id: u64,
15747}
15748
15749impl Drop for ControllerReleaseGuard {
15750    fn drop(&mut self) {
15751        self.shared.release_controller(self.connection_id);
15752    }
15753}
15754
15755struct AbortTaskOnDrop(AbortHandle);
15756
15757impl Drop for AbortTaskOnDrop {
15758    fn drop(&mut self) {
15759        self.0.abort();
15760    }
15761}
15762
15763/// Reads an unsigned env-config override, following the pattern of every
15764/// other bounded knob in this module: missing/unparsable/zero falls back to
15765/// `default`, and the resolved value is always clamped to `max` so a bad
15766/// override cannot reopen a bound this budget exists to enforce.
15767fn env_config_bounded(name: &str, default: u64, max: u64) -> u64 {
15768    std::env::var(name)
15769        .ok()
15770        .and_then(|value| value.parse::<u64>().ok())
15771        .filter(|value| *value > 0)
15772        .unwrap_or(default)
15773        .min(max)
15774}
15775
15776fn workspace_inspection_time_budget_ms() -> u64 {
15777    env_config_bounded(
15778        "GATE4AGENT_NODE_WORKSPACE_INSPECTION_BUDGET_MS",
15779        WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT,
15780        WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX,
15781    )
15782}
15783
15784fn workspace_inspection_entry_cap() -> usize {
15785    env_config_bounded(
15786        "GATE4AGENT_NODE_WORKSPACE_INSPECTION_ENTRY_CAP",
15787        WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT as u64,
15788        WORKSPACE_INSPECTION_ENTRY_CAP_MAX as u64,
15789    ) as usize
15790}
15791
15792/// Tracks the shared walk-side budget across the whole recursive walk: a
15793/// wall-clock deadline (shared with the git phase that runs after the walk)
15794/// and a cap on directory entries visited, whether or not they were pushed
15795/// to the response. Cheap to check per entry — one `Instant::now()` and one
15796/// integer compare.
15797struct WorkspaceWalkBudget {
15798    deadline: Instant,
15799    entry_cap: usize,
15800    entries_visited: u64,
15801    time_budget_exceeded: bool,
15802    entry_cap_exceeded: bool,
15803}
15804
15805impl WorkspaceWalkBudget {
15806    fn new(deadline: Instant, entry_cap: usize) -> Self {
15807        Self {
15808            deadline,
15809            entry_cap,
15810            entries_visited: 0,
15811            time_budget_exceeded: false,
15812            entry_cap_exceeded: false,
15813        }
15814    }
15815
15816    fn is_exhausted(&self) -> bool {
15817        self.time_budget_exceeded || self.entry_cap_exceeded
15818    }
15819
15820    /// Records one visited directory entry (pushed to the response or not)
15821    /// and reports whether the walk must stop now.
15822    fn record_visit(&mut self) -> bool {
15823        self.entries_visited += 1;
15824        if self.entries_visited as usize >= self.entry_cap {
15825            self.entry_cap_exceeded = true;
15826        }
15827        if Instant::now() >= self.deadline {
15828            self.time_budget_exceeded = true;
15829        }
15830        self.is_exhausted()
15831    }
15832}
15833
15834fn collect_workspace_entries(
15835    root: &Path,
15836    deadline: Instant,
15837    entry_cap: usize,
15838) -> (Vec<WorkspaceEntry>, bool, WorkspaceWalkBudget) {
15839    let mut entries = Vec::new();
15840    let mut truncated = false;
15841    let mut budget = WorkspaceWalkBudget::new(deadline, entry_cap);
15842    walk_workspace_directory(root, None, 0, &mut entries, &mut truncated, &mut budget);
15843    // DFS pre-order emits `x/child` between prefix-named siblings `x` and
15844    // `x-y` ('-' sorts below '/'), while the bounded operator projections
15845    // require strict global byte order over relative paths.
15846    entries.sort_by(|left, right| left.relative_path.as_utf8().cmp(&right.relative_path.as_utf8()));
15847    (entries, truncated, budget)
15848}
15849
15850/// Builds the additive `WorkspaceInspectionTruncationV1` for an
15851/// `inspect_workspace` response — `None` once the walk and git phase both
15852/// completed inside their shared budget.
15853fn workspace_inspection_truncation(
15854    walk_budget: &WorkspaceWalkBudget,
15855    git_time_budget_exceeded: bool,
15856    elapsed: Duration,
15857) -> Option<WorkspaceInspectionTruncationV1> {
15858    if !walk_budget.time_budget_exceeded
15859        && !walk_budget.entry_cap_exceeded
15860        && !git_time_budget_exceeded
15861    {
15862        return None;
15863    }
15864    Some(WorkspaceInspectionTruncationV1 {
15865        walk_time_budget_exceeded: walk_budget.time_budget_exceeded,
15866        walk_entry_cap_exceeded: walk_budget.entry_cap_exceeded,
15867        git_time_budget_exceeded,
15868        entries_visited: walk_budget.entries_visited,
15869        elapsed_ms: u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX),
15870    })
15871}
15872
15873fn walk_workspace_directory(
15874    directory: &Path,
15875    relative_directory: Option<&RepositoryPath>,
15876    depth: usize,
15877    entries: &mut Vec<WorkspaceEntry>,
15878    truncated: &mut bool,
15879    budget: &mut WorkspaceWalkBudget,
15880) {
15881    if budget.is_exhausted() {
15882        *truncated = true;
15883        return;
15884    }
15885    let read_dir = match std::fs::read_dir(directory) {
15886        Ok(read_dir) => read_dir,
15887        Err(_) => {
15888            *truncated = true;
15889            return;
15890        }
15891    };
15892    let mut children = Vec::new();
15893    for child in read_dir {
15894        match child {
15895            Ok(child) => children.push(child),
15896            Err(_) => *truncated = true,
15897        }
15898    }
15899    children.sort_by_key(|child| child.file_name());
15900    for child in children {
15901        if budget.record_visit() {
15902            *truncated = true;
15903            return;
15904        }
15905        if entries.len() >= WORKSPACE_TREE_MAX_ENTRIES {
15906            *truncated = true;
15907            return;
15908        }
15909        let file_type = match child.file_type() {
15910            Ok(file_type) => file_type,
15911            Err(_) => {
15912                *truncated = true;
15913                continue;
15914            }
15915        };
15916        if file_type.is_symlink() {
15917            continue;
15918        }
15919        let name = match child.file_name().into_string() {
15920            Ok(name) => name,
15921            Err(_) => {
15922                *truncated = true;
15923                continue;
15924            }
15925        };
15926        if file_type.is_dir() && is_skipped_workspace_directory(&name, &child.path()) {
15927            continue;
15928        }
15929        let relative_path = match relative_directory {
15930            None => windows_repository_path(name),
15931            Some(parent) => parent
15932                .as_utf8()
15933                .and_then(|parent| windows_repository_path(format!("{parent}/{name}"))),
15934        };
15935        let Some(relative_path) = relative_path else {
15936            *truncated = true;
15937            continue;
15938        };
15939        let kind = if file_type.is_dir() {
15940            WorkspaceEntryKind::Directory
15941        } else if file_type.is_file() {
15942            WorkspaceEntryKind::File
15943        } else {
15944            continue;
15945        };
15946        entries.push(WorkspaceEntry {
15947            relative_path: relative_path.clone(),
15948            kind,
15949        });
15950        if file_type.is_dir() {
15951            if depth + 1 < WORKSPACE_TREE_MAX_DEPTH {
15952                walk_workspace_directory(
15953                    &child.path(),
15954                    Some(&relative_path),
15955                    depth + 1,
15956                    entries,
15957                    truncated,
15958                    budget,
15959                );
15960            } else {
15961                *truncated = true;
15962            }
15963        }
15964    }
15965}
15966
15967fn windows_repository_path(value: String) -> Option<RepositoryPath> {
15968    if value.contains('\\') {
15969        return None;
15970    }
15971    RepositoryPath::utf8(value).ok()
15972}
15973
15974fn windows_repository_path_from_bytes(value: &[u8]) -> Option<RepositoryPath> {
15975    windows_repository_path(std::str::from_utf8(value).ok()?.to_owned())
15976}
15977
15978/// Excludes a directory from the workspace tree walk. The three literal
15979/// names are the common convention-based cases; the `CACHEDIR.TAG` check
15980/// beneath them catches every build-output directory regardless of name
15981/// (cargo writes `CACHEDIR.TAG` into every `--target-dir` it creates, so
15982/// this covers `target-a2`, `target-a2-tui`, and any future renamed build
15983/// directory without maintaining a name heuristic list). The name check
15984/// runs first and returns early so a directory rejected by name never pays
15985/// for the extra `CACHEDIR.TAG` stat.
15986fn is_skipped_workspace_directory(name: &str, path: &Path) -> bool {
15987    if name.eq_ignore_ascii_case(".git")
15988        || name.eq_ignore_ascii_case("target")
15989        || name.eq_ignore_ascii_case("node_modules")
15990    {
15991        return true;
15992    }
15993    path.join("CACHEDIR.TAG").is_file()
15994}
15995
15996fn context_pack_source_status_is_usable(status: &SessionStatus) -> bool {
15997    matches!(
15998        status,
15999        SessionStatus::Running | SessionStatus::Exited { exit_code: Some(0) }
16000    )
16001}
16002
16003#[derive(Clone, Debug, Eq, PartialEq)]
16004enum ContextPackRepositoryHead {
16005    NotRepository,
16006    Unborn,
16007    Commit(String),
16008}
16009
16010#[derive(Clone, Debug, Eq, PartialEq)]
16011struct ContextPackRepositoryObservation {
16012    head: ContextPackRepositoryHead,
16013    git: GitSnapshot,
16014    files: Vec<ContextPackRepositoryFileSource>,
16015}
16016
16017fn stable_context_pack_repository(
16018    first: ContextPackRepositoryObservation,
16019    second: ContextPackRepositoryObservation,
16020) -> Result<ContextPackRepository, NodeFailure> {
16021    if first != second {
16022        return Err(failure(
16023            NodeFailureCode::ContextPackMaterializationFailed,
16024            "context repository changed during bounded capture",
16025        ));
16026    }
16027    Ok(ContextPackRepository::from_sources(
16028        &second.git,
16029        second.files,
16030    ))
16031}
16032
16033/// Appends a "skipped: budget exhausted" diagnostic and returns `true` once
16034/// (and only once) the shared walk+git `deadline` has already passed. Every
16035/// remaining phase in `inspect_git_workspace` checks this immediately
16036/// before issuing its own git call, so a budget spent by the walk (or by an
16037/// earlier git call) skips the rest of the chain instead of paying for it
16038/// anyway.
16039fn git_budget_exhausted(snapshot: &mut GitSnapshot, deadline: Instant, stage: &str) -> bool {
16040    if Instant::now() < deadline {
16041        return false;
16042    }
16043    append_git_diagnostic(
16044        snapshot,
16045        &format!("{stage} skipped: workspace inspection time budget exhausted"),
16046    );
16047    true
16048}
16049
16050/// Deadline-aware sibling of `run_git_bounded`, used only by
16051/// `inspect_git_workspace`'s six-call chain: caps the per-call timeout to
16052/// whatever remains of the shared `deadline` (never more than
16053/// `GIT_COMMAND_TIMEOUT_MS`), so a slow walk phase that already spent most
16054/// of the budget cannot still hand a full per-call timeout to every git
16055/// probe behind it. `inspect_git_workspace` already guards each call site
16056/// with `git_budget_exhausted`, so `deadline` is guaranteed not to have
16057/// passed yet here.
16058async fn run_git_bounded_with_deadline(
16059    root: &str,
16060    arguments: &[&str],
16061    output_limit: usize,
16062    deadline: Instant,
16063) -> io::Result<GitCommandOutput> {
16064    let remaining = deadline.checked_duration_since(Instant::now());
16065    let timeout_ms = remaining
16066        .and_then(|remaining| u64::try_from(remaining.as_millis()).ok())
16067        .unwrap_or(0)
16068        .clamp(1, GIT_COMMAND_TIMEOUT_MS);
16069    run_git_read_bounded(root, arguments, output_limit, timeout_ms).await
16070}
16071
16072/// Runs the bounded six-call git probe chain used by `inspect_workspace`.
16073/// `deadline` is the same absolute instant the preceding directory walk was
16074/// bounded by, so time already spent walking counts against what remains
16075/// for git enrichment. Returns the resulting `GitSnapshot` plus whether the
16076/// shared budget (not an individual per-call timeout) is what cut the git
16077/// phase short.
16078async fn inspect_git_workspace(root: &str, deadline: Instant) -> (GitSnapshot, bool) {
16079    let mut snapshot = GitSnapshot {
16080        is_repository: false,
16081        branch: None,
16082        status: Vec::new(),
16083        recent_commits: Vec::new(),
16084        worktrees: Vec::new(),
16085        managed_worktree: None,
16086        truncated: false,
16087        diagnostic: None,
16088    };
16089    if git_budget_exhausted(&mut snapshot, deadline, "git repository probe") {
16090        return (snapshot, true);
16091    }
16092    let repository = match run_git_bounded_with_deadline(
16093        root,
16094        &["rev-parse", "--is-inside-work-tree"],
16095        4 * 1_024,
16096        deadline,
16097    )
16098    .await
16099    {
16100        Ok(output) => output,
16101        Err(error) => {
16102            append_git_diagnostic(
16103                &mut snapshot,
16104                &format!("git inspection unavailable: {error}"),
16105            );
16106            return (snapshot, false);
16107        }
16108    };
16109    snapshot.truncated |= repository.truncated;
16110    if repository.timed_out {
16111        append_git_diagnostic(&mut snapshot, "git repository probe timed out");
16112        return (snapshot, false);
16113    }
16114    if !repository.success
16115        || String::from_utf8_lossy(&repository.stdout).trim() != "true"
16116    {
16117        let stderr = String::from_utf8_lossy(&repository.stderr);
16118        if !stderr.to_ascii_lowercase().contains("not a git repository") {
16119            append_git_diagnostic(&mut snapshot, stderr.trim());
16120        }
16121        return (snapshot, false);
16122    }
16123    snapshot.is_repository = true;
16124
16125    if git_budget_exhausted(&mut snapshot, deadline, "git branch query") {
16126        return (snapshot, true);
16127    }
16128    match run_git_bounded_with_deadline(root, &["branch", "--show-current"], 4 * 1_024, deadline).await {
16129        Ok(output) => {
16130            snapshot.truncated |= output.truncated;
16131            if output.timed_out {
16132                append_git_diagnostic(&mut snapshot, "git branch query timed out");
16133            } else if output.success {
16134                let branch = String::from_utf8_lossy(&output.stdout).trim().to_owned();
16135                if !branch.is_empty() {
16136                    snapshot.branch = Some(truncate_git_field(
16137                        &branch,
16138                        MAX_REPOSITORY_PATH_BYTES,
16139                        &mut snapshot.truncated,
16140                    ));
16141                }
16142            }
16143        }
16144        Err(error) => append_git_diagnostic(
16145            &mut snapshot,
16146            &format!("git branch query failed: {error}"),
16147        ),
16148    }
16149    if snapshot.branch.is_none() && !git_budget_exhausted(&mut snapshot, deadline, "git detached-HEAD probe") {
16150        if let Ok(output) = run_git_bounded_with_deadline(root, &["rev-parse", "--short", "HEAD"], 4 * 1_024, deadline).await {
16151            snapshot.truncated |= output.truncated;
16152            if output.success && !output.timed_out {
16153                let head = String::from_utf8_lossy(&output.stdout).trim().to_owned();
16154                if !head.is_empty() {
16155                    snapshot.branch = Some(format!("detached:{head}"));
16156                }
16157            }
16158        }
16159    }
16160
16161    if git_budget_exhausted(&mut snapshot, deadline, "git status") {
16162        return (snapshot, true);
16163    }
16164    match run_git_bounded_with_deadline(
16165        root,
16166        &[
16167            "status",
16168            "--porcelain=v1",
16169            "-z",
16170            "--renames",
16171            "--untracked-files=normal",
16172            "--",
16173            ".",
16174        ],
16175        GIT_OUTPUT_MAX_BYTES,
16176        deadline,
16177    )
16178    .await
16179    {
16180        Ok(output) => {
16181            snapshot.truncated |= output.truncated;
16182            if output.timed_out {
16183                append_git_diagnostic(&mut snapshot, "git status timed out");
16184            } else if output.success {
16185                parse_git_status(&output.stdout, &mut snapshot);
16186            } else {
16187                append_git_diagnostic(
16188                    &mut snapshot,
16189                    String::from_utf8_lossy(&output.stderr).trim(),
16190                );
16191            }
16192        }
16193        Err(error) => append_git_diagnostic(
16194            &mut snapshot,
16195            &format!("git status failed: {error}"),
16196        ),
16197    }
16198
16199    if git_budget_exhausted(&mut snapshot, deadline, "git log") {
16200        return (snapshot, true);
16201    }
16202    let log_limit = (GIT_COMMIT_MAX_ENTRIES + 1).to_string();
16203    match run_git_bounded_with_deadline(
16204        root,
16205        &["log", "-n", &log_limit, "--pretty=format:%H%x1f%s", "--", "."],
16206        16 * 1_024,
16207        deadline,
16208    )
16209    .await
16210    {
16211        Ok(output) => {
16212            snapshot.truncated |= output.truncated;
16213            if output.timed_out {
16214                append_git_diagnostic(&mut snapshot, "git log timed out");
16215            } else if output.success {
16216                parse_git_commits(&output.stdout, &mut snapshot);
16217            } else {
16218                let stderr = String::from_utf8_lossy(&output.stderr);
16219                if !stderr.to_ascii_lowercase().contains("does not have any commits") {
16220                    append_git_diagnostic(&mut snapshot, stderr.trim());
16221                }
16222            }
16223        }
16224        Err(error) => append_git_diagnostic(
16225            &mut snapshot,
16226            &format!("git log failed: {error}"),
16227        ),
16228    }
16229
16230    if git_budget_exhausted(&mut snapshot, deadline, "git worktree list") {
16231        return (snapshot, true);
16232    }
16233    match list_git_worktrees_with_deadline(root, deadline).await {
16234        Ok(worktrees) => {
16235            snapshot.worktrees = worktrees.into_iter().map(protocol_worktree).collect()
16236        }
16237        Err(error) => append_git_diagnostic(
16238            &mut snapshot,
16239            &format!("git worktree list failed: {}", error.message),
16240        ),
16241    }
16242    (snapshot, false)
16243}
16244
16245fn parse_git_status(output: &[u8], snapshot: &mut GitSnapshot) {
16246    let mut remaining = output;
16247    while !remaining.is_empty() {
16248        if snapshot.status.len() >= GIT_STATUS_MAX_ENTRIES {
16249            snapshot.truncated = true;
16250            break;
16251        }
16252        let Some(record_end) = remaining.iter().position(|byte| *byte == 0) else {
16253            snapshot.truncated = true;
16254            break;
16255        };
16256        let record = &remaining[..record_end];
16257        remaining = &remaining[record_end + 1..];
16258        let has_previous_path = record
16259            .get(..2)
16260            .is_some_and(|status| status.iter().any(|byte| matches!(*byte, b'R' | b'C')));
16261        let previous_path_bytes = if has_previous_path {
16262            let Some(previous_end) = remaining.iter().position(|byte| *byte == 0) else {
16263                snapshot.truncated = true;
16264                break;
16265            };
16266            let previous = &remaining[..previous_end];
16267            remaining = &remaining[previous_end + 1..];
16268            Some(previous)
16269        } else {
16270            None
16271        };
16272        if record.len() < 4
16273            || record[2] != b' '
16274            || !is_git_status_code(record[0])
16275            || !is_git_status_code(record[1])
16276        {
16277            snapshot.truncated = true;
16278            continue;
16279        }
16280        let Some(path) = windows_repository_path_from_bytes(&record[3..]) else {
16281            snapshot.truncated = true;
16282            continue;
16283        };
16284        let previous_path = match previous_path_bytes {
16285            Some(previous) => {
16286                let Some(previous) = windows_repository_path_from_bytes(previous) else {
16287                    snapshot.truncated = true;
16288                    continue;
16289                };
16290                Some(previous)
16291            }
16292            None => None,
16293        };
16294        snapshot.status.push(GitStatusEntry {
16295            index_status: (record[0] as char).to_string(),
16296            worktree_status: (record[1] as char).to_string(),
16297            path,
16298            previous_path,
16299        });
16300    }
16301}
16302
16303fn is_git_status_code(byte: u8) -> bool {
16304    matches!(byte, b' ' | b'M' | b'T' | b'A' | b'D' | b'R' | b'C' | b'U' | b'?' | b'!')
16305}
16306
16307fn parse_git_commits(output: &[u8], snapshot: &mut GitSnapshot) {
16308    for line in String::from_utf8_lossy(output).lines() {
16309        let Some((id, summary)) = line.split_once('\u{1f}') else {
16310            continue;
16311        };
16312        if snapshot.recent_commits.len() >= GIT_COMMIT_MAX_ENTRIES {
16313            snapshot.truncated = true;
16314            break;
16315        }
16316        let id = truncate_git_field(id, 64, &mut snapshot.truncated);
16317        let summary = truncate_git_field(summary, 512, &mut snapshot.truncated);
16318        snapshot.recent_commits.push(GitCommitSummary { id, summary });
16319    }
16320}
16321
16322fn truncate_git_field(value: &str, max_bytes: usize, truncated: &mut bool) -> String {
16323    if value.len() <= max_bytes {
16324        return value.to_owned();
16325    }
16326    *truncated = true;
16327    let mut boundary = max_bytes;
16328    while boundary > 0 && !value.is_char_boundary(boundary) {
16329        boundary -= 1;
16330    }
16331    value[..boundary].to_owned()
16332}
16333
16334fn append_git_diagnostic(snapshot: &mut GitSnapshot, message: &str) {
16335    if message.is_empty() {
16336        return;
16337    }
16338    let normalized = message
16339        .chars()
16340        .map(|character| if character.is_control() { ' ' } else { character })
16341        .collect::<String>();
16342    let mut diagnostic = snapshot.diagnostic.take().unwrap_or_default();
16343    if !diagnostic.is_empty() {
16344        diagnostic.push_str("; ");
16345    }
16346    diagnostic.push_str(normalized.trim());
16347    snapshot.diagnostic = Some(truncate_git_field(
16348        &diagnostic,
16349        GIT_DIAGNOSTIC_MAX_BYTES,
16350        &mut snapshot.truncated,
16351    ));
16352}
16353
16354async fn run_git_bounded(
16355    root: &str,
16356    arguments: &[&str],
16357    output_limit: usize,
16358) -> io::Result<GitCommandOutput> {
16359    run_git_read_bounded(root, arguments, output_limit, GIT_COMMAND_TIMEOUT_MS).await
16360}
16361
16362async fn read_git_history_bounded(
16363    root: &str,
16364    path: Option<&RepositoryPath>,
16365    before: Option<&GitObjectId>,
16366    limit: u16,
16367) -> Result<GitHistoryPage, NodeFailure> {
16368    let path = path
16369        .map(|path| {
16370            path.as_utf8().ok_or_else(|| {
16371                failure(
16372                    NodeFailureCode::InvalidRepositoryPath,
16373                    "git history path must use UTF-8",
16374                )
16375            })
16376        })
16377        .transpose()?
16378        .unwrap_or(".");
16379    let count = (usize::from(limit) + 1).to_string();
16380    let start = before
16381        .map(|revision| format!("{}^", revision.as_str()))
16382        .unwrap_or_else(|| "HEAD".to_owned());
16383    let arguments = [
16384        "--no-pager",
16385        "log",
16386        "--no-decorate",
16387        "--date=iso-strict",
16388        "--pretty=format:%H%x1f%P%x1f%s%x1f%an%x1f%ae%x1f%aI%x1f%cn%x1f%ce%x1f%cI%x1e",
16389        "-n",
16390        count.as_str(),
16391        start.as_str(),
16392        "--",
16393        path,
16394    ];
16395    let output = run_git_bounded(root, &arguments, 256 * 1_024)
16396        .await
16397        .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git history command failed"))?;
16398    if output.timed_out {
16399        return Err(failure(NodeFailureCode::GitReadTimedOut, "git history exceeded its bounded deadline"));
16400    }
16401    if !output.success {
16402        return Err(failure(NodeFailureCode::GitReadFailed, "git history could not be read"));
16403    }
16404    let mut commits = parse_git_history(&output.stdout);
16405    let has_more = commits.len() > usize::from(limit) || output.truncated;
16406    commits.truncate(usize::from(limit));
16407    let next_before = has_more.then(|| commits.last().map(|commit| commit.id.clone())).flatten();
16408    Ok(GitHistoryPage { commits, next_before, truncated: output.truncated })
16409}
16410
16411fn parse_git_history(output: &[u8]) -> Vec<GitCommitDetails> {
16412    String::from_utf8_lossy(output)
16413        .split('\u{1e}')
16414        .filter_map(|record| {
16415            let fields = record.trim_matches(['\r', '\n']).split('\u{1f}').collect::<Vec<_>>();
16416            if fields.len() != 9 {
16417                return None;
16418            }
16419            let id = GitObjectId::new(fields[0].to_owned()).ok()?;
16420            let parents = if fields[1].is_empty() {
16421                Vec::new()
16422            } else {
16423                fields[1]
16424                    .split(' ')
16425                    .map(|value| GitObjectId::new(value.to_owned()))
16426                    .collect::<Result<Vec<_>, _>>()
16427                    .ok()?
16428            };
16429            Some(GitCommitDetails {
16430                id,
16431                parents,
16432                subject: bounded_git_text(fields[2], 512),
16433                author_name: bounded_git_text(fields[3], 256),
16434                author_email: bounded_git_text(fields[4], 320),
16435                authored_at: bounded_git_text(fields[5], 64),
16436                committer_name: bounded_git_text(fields[6], 256),
16437                committer_email: bounded_git_text(fields[7], 320),
16438                committed_at: bounded_git_text(fields[8], 64),
16439                signature_status: GitSignatureStatus::NoSignature,
16440                signer: None,
16441            })
16442        })
16443        .take(usize::from(MAX_GIT_HISTORY_COMMITS) + 1)
16444        .collect()
16445}
16446
16447fn bounded_git_text(value: &str, max_bytes: usize) -> String {
16448    let normalized = value
16449        .chars()
16450        .map(|character| if character.is_control() { ' ' } else { character })
16451        .collect::<String>();
16452    if normalized.len() <= max_bytes {
16453        return normalized;
16454    }
16455    let mut boundary = max_bytes;
16456    while boundary > 0 && !normalized.is_char_boundary(boundary) {
16457        boundary -= 1;
16458    }
16459    normalized[..boundary].to_owned()
16460}
16461
16462async fn read_git_diff_bounded(root: &str, request: GitDiffRequest) -> Result<GitDiff, NodeFailure> {
16463    let path = request.path.as_ref().map(|path| {
16464        path.as_utf8()
16465            .ok_or_else(|| failure(NodeFailureCode::InvalidRepositoryPath, "git diff path must use UTF-8"))
16466    }).transpose()?;
16467    let mut arguments = match &request.mode {
16468        GitDiffMode::Working => vec!["--no-pager", "-c", "diff.external=", "diff", "--no-ext-diff", "--no-textconv"],
16469        GitDiffMode::Staged => vec!["--no-pager", "-c", "diff.external=", "diff", "--cached", "--no-ext-diff", "--no-textconv"],
16470        GitDiffMode::Commit { .. } => vec!["--no-pager", "-c", "diff.external=", "show", "--format=fuller", "--no-ext-diff", "--no-textconv"],
16471    };
16472    if let GitDiffMode::Commit { revision } = &request.mode {
16473        arguments.push(revision.as_str());
16474    }
16475    arguments.push("--");
16476    if let Some(path) = path {
16477        arguments.push(path);
16478    }
16479    let output = run_git_bounded(root, &arguments, MAX_GIT_DIFF_BYTES)
16480        .await
16481        .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git diff command failed"))?;
16482    if output.timed_out {
16483        return Err(failure(NodeFailureCode::GitReadTimedOut, "git diff exceeded its bounded deadline"));
16484    }
16485    if !output.success {
16486        return Err(failure(NodeFailureCode::GitReadFailed, "git diff could not be read"));
16487    }
16488    Ok(GitDiff {
16489        mode: request.mode,
16490        path: request.path,
16491        text: String::from_utf8_lossy(&output.stdout).into_owned(),
16492        truncated: output.truncated,
16493    })
16494}
16495
16496async fn process_request(shared: &NodeShared, connection_id: u64, role: ClientRole, envelope: RequestEnvelope) -> ResponseEnvelope {
16497    let result = process_request_inner(shared, connection_id, role, envelope.request).await;
16498    ResponseEnvelope { request_id: envelope.request_id, result }
16499}
16500
16501async fn process_request_inner(shared: &NodeShared, connection_id: u64, role: ClientRole, request: NodeRequest) -> Result<NodeResponse, NodeFailure> {
16502    if !request.harness_mcp_contract_is_valid_at(unix_time_ms()) {
16503        return Err(failure(NodeFailureCode::InvalidRequest, "harness MCP request is invalid or stale"));
16504    }
16505    let read_only = matches!(
16506        &request,
16507        NodeRequest::Snapshot
16508            | NodeRequest::Resync { .. }
16509            | NodeRequest::BrowseHostDirectories { .. }
16510            | NodeRequest::InspectWorkspace { .. }
16511            | NodeRequest::ReadWorkspaceFile { .. }
16512            | NodeRequest::ReadGitHistory { .. }
16513            | NodeRequest::ReadGitDiff { .. }
16514            | NodeRequest::CatalogNativeSessions { .. }
16515            | NodeRequest::PageNativeSessions { .. }
16516            | NodeRequest::PreviewNativeSession { .. }
16517            | NodeRequest::PreviewSessionRecord { .. }
16518    );
16519    let _mutation_guard = if read_only {
16520        None
16521    } else {
16522        let guard = shared.mutation_gate.lock().await;
16523        if shared.shutdown.load(Ordering::Acquire) {
16524            return Err(failure(NodeFailureCode::ShuttingDown, "node shutdown has begun"));
16525        }
16526        Some(guard)
16527    };
16528    match request {
16529        NodeRequest::Snapshot => Ok(NodeResponse::Snapshot {
16530            event_sequence: shared.current_sequence(),
16531            controller: shared.controller_state(),
16532            snapshot: shared.snapshot(),
16533        }),
16534        NodeRequest::Resync { after_sequence } => Ok(shared.resync(after_sequence)),
16535        NodeRequest::ArmHarnessMcpReservation {
16536            reservation_id,
16537            activation_digest,
16538            spawn_spec,
16539            launch,
16540            expires_at_unix_ms,
16541        } => {
16542            shared.require_controller(connection_id, role)?;
16543            let registry = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16544                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16545            })?;
16546            let expires_at_unix_ms = registry
16547                .arm(reservation_id.clone(), activation_digest.clone(), spawn_spec, launch, expires_at_unix_ms)
16548                .await
16549                .map_err(harness_mcp_failure)?;
16550            Ok(NodeResponse::Armed { reservation_id, activation_digest, expires_at_unix_ms })
16551        }
16552        NodeRequest::SpawnSpecWithHarnessMcp {
16553            reservation_id,
16554            activation_digest,
16555            spec,
16556            deadline_unix_ms,
16557        } => {
16558            shared.require_controller(connection_id, role)?;
16559            let receipt = shared
16560                .spawn_from_spec_with_harness_mcp(
16561                    reservation_id.clone(), activation_digest.clone(), spec, deadline_unix_ms,
16562                )
16563                .await?;
16564            Ok(NodeResponse::Spawned { reservation_id, activation_digest, receipt })
16565        }
16566        NodeRequest::ActivateHarnessMcpReservation {
16567            reservation_id,
16568            activation_digest,
16569            record_id,
16570            session,
16571        } => {
16572            shared.require_controller(connection_id, role)?;
16573            let provider_root_pid = shared.handle.snapshot().sessions.iter().find(|current| {
16574                current.instance_id == session.session.instance_id
16575                    && current.generation == session.session.generation
16576            }).and_then(|current| current.process_id).ok_or_else(|| {
16577                failure(NodeFailureCode::BindingMismatch, "provider root process is unavailable")
16578            })?;
16579            shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16580                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16581            })?.activate(
16582                &reservation_id, &activation_digest, &record_id, &session, provider_root_pid,
16583            ).map_err(harness_mcp_failure)?;
16584            Ok(NodeResponse::Activated { reservation_id, activation_digest, record_id, session })
16585        }
16586        NodeRequest::AbortHarnessMcpReservation { reservation_id, activation_digest } => {
16587            shared.require_controller(connection_id, role)?;
16588            let instance_id = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16589                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16590            })?.abort(&reservation_id, &activation_digest).map_err(harness_mcp_failure)?;
16591            if let Some(instance_id) = instance_id {
16592                SessionLifecycleHook::on_harness_reservation_aborted(&*shared, instance_id);
16593            }
16594            Ok(NodeResponse::Aborted { reservation_id, activation_digest })
16595        }
16596        NodeRequest::PutHarnessMcpReplyChunk {
16597            reservation_id, activation_digest, record_id, session, call_id,
16598            offset, final_chunk, chunk_hex,
16599        } => {
16600            shared.require_controller(connection_id, role)?;
16601            let (next_offset, completed) = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16602                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16603            })?.put_reply_chunk(
16604                &reservation_id, &activation_digest, &record_id, &session, &call_id,
16605                offset, final_chunk, &chunk_hex,
16606            ).map_err(harness_mcp_failure)?;
16607            Ok(NodeResponse::ReplyChunkAccepted {
16608                reservation_id, activation_digest, record_id, session, call_id,
16609                next_offset, completed,
16610            })
16611        }
16612        NodeRequest::RejectHarnessMcpCall {
16613            reservation_id, activation_digest, record_id, session, call_id, reason,
16614        } => {
16615            shared.require_controller(connection_id, role)?;
16616            shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16617                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16618            })?.reject_call(
16619                &reservation_id, &activation_digest, &record_id, &session, &call_id, reason,
16620            ).map_err(harness_mcp_failure)?;
16621            Ok(NodeResponse::CallRejected {
16622                reservation_id, activation_digest, record_id, session, call_id,
16623            })
16624        }
16625        NodeRequest::BeginDeliveryStage { manifest } => {
16626            shared.require_controller(connection_id, role)?;
16627            shared.begin_delivery_stage(manifest)
16628        }
16629        NodeRequest::PutDeliveryBlobChunk {
16630            stage_id,
16631            blob_digest,
16632            offset,
16633            chunk_hex,
16634        } => {
16635            shared.require_controller(connection_id, role)?;
16636            shared.put_delivery_blob_chunk(stage_id, blob_digest, offset, chunk_hex)
16637        }
16638        NodeRequest::CommitDeliveryStage { stage_id } => {
16639            shared.require_controller(connection_id, role)?;
16640            let receipt = shared.commit_delivery_stage(stage_id)?;
16641            Ok(NodeResponse::DeliveryCommitted { receipt })
16642        }
16643        NodeRequest::AbortDeliveryStage { stage_id } => {
16644            shared.require_controller(connection_id, role)?;
16645            shared.abort_delivery_stage(&stage_id)?;
16646            Ok(NodeResponse::DeliveryStageAborted { stage_id })
16647        }
16648        NodeRequest::BrowseHostDirectories { directory, after } => {
16649            let listing = shared.browse_host_directories(directory, after).await?;
16650            Ok(NodeResponse::HostDirectoriesBrowsed { listing })
16651        }
16652        NodeRequest::InspectWorkspace { workspace_id } => {
16653            let inspection = shared.inspect_workspace(workspace_id).await?;
16654            Ok(NodeResponse::WorkspaceInspected { inspection })
16655        }
16656        NodeRequest::ReadWorkspaceFile { workspace_id, path } => {
16657            let file = shared.read_workspace_file(workspace_id, path).await?;
16658            Ok(NodeResponse::WorkspaceFileRead { file })
16659        }
16660        NodeRequest::WriteWorkspaceFile { workspace_id, path, expected_revision, text } => {
16661            shared.require_controller(connection_id, role)?;
16662            let file = shared
16663                .write_workspace_file(workspace_id, path, expected_revision, text)
16664                .await?;
16665            Ok(NodeResponse::WorkspaceFileWritten { file })
16666        }
16667        NodeRequest::CreateWorkspaceFile { workspace_id, path } => {
16668            shared.require_controller(connection_id, role)?;
16669            let file = shared.create_workspace_file(workspace_id, path).await?;
16670            Ok(NodeResponse::WorkspaceFileCreated { file })
16671        }
16672        NodeRequest::CreateWorkspaceDirectory { workspace_id, path } => {
16673            shared.require_controller(connection_id, role)?;
16674            let entry = shared
16675                .create_workspace_directory(workspace_id.clone(), path)
16676                .await?;
16677            Ok(NodeResponse::WorkspaceDirectoryCreated { workspace_id, entry })
16678        }
16679        NodeRequest::ReadGitHistory { workspace_id, path, before, limit } => {
16680            if role != ClientRole::Operator {
16681                return Err(failure(NodeFailureCode::ObserverReadOnly, "git history requires an operator connection"));
16682            }
16683            if !(1..=MAX_GIT_HISTORY_COMMITS).contains(&limit) {
16684                return Err(failure(NodeFailureCode::InvalidRequest, "git history limit is invalid"));
16685            }
16686            let page = shared
16687                .read_git_history(workspace_id.clone(), path, before, limit)
16688                .await?;
16689            Ok(NodeResponse::GitHistoryRead { workspace_id, page })
16690        }
16691        NodeRequest::ReadGitDiff { workspace_id, request } => {
16692            if role != ClientRole::Operator {
16693                return Err(failure(NodeFailureCode::ObserverReadOnly, "git diff requires an operator connection"));
16694            }
16695            let diff = shared.read_git_diff(workspace_id.clone(), request).await?;
16696            Ok(NodeResponse::GitDiffRead { workspace_id, diff })
16697        }
16698        NodeRequest::CatalogNativeSessions { route, limit } => {
16699            if role != ClientRole::Operator {
16700                return Err(failure(
16701                    NodeFailureCode::ObserverReadOnly,
16702                    "native session catalog requires an operator connection",
16703                ));
16704            }
16705            if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit) {
16706                return Err(failure(
16707                    NodeFailureCode::InvalidRequest,
16708                    "native session catalog limit is invalid",
16709                ));
16710            }
16711            let (entries, summary) = shared
16712                .catalog_native_sessions(route.clone(), limit)
16713                .await?;
16714            Ok(NodeResponse::NativeSessionsCataloged {
16715                route,
16716                entries,
16717                summary: Some(summary),
16718            })
16719        }
16720        NodeRequest::PageNativeSessions {
16721            route,
16722            window,
16723            catalog_revision,
16724            recent_cutoff_unix_ms,
16725            after_selection_id,
16726            limit,
16727        } => {
16728            if role != ClientRole::Operator {
16729                return Err(failure(
16730                    NodeFailureCode::ObserverReadOnly,
16731                    "native session catalog paging requires an operator connection",
16732                ));
16733            }
16734            if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit)
16735                || after_selection_id
16736                    .as_deref()
16737                    .is_some_and(|cursor| validate_candidate_id(cursor).is_err())
16738            {
16739                return Err(failure(
16740                    NodeFailureCode::InvalidRequest,
16741                    "native session catalog page request is invalid",
16742                ));
16743            }
16744            let page = shared
16745                .page_native_sessions(
16746                    route.clone(),
16747                    window,
16748                    catalog_revision,
16749                    recent_cutoff_unix_ms,
16750                    after_selection_id,
16751                    limit,
16752                )
16753                .await?;
16754            Ok(NodeResponse::NativeSessionsPaged {
16755                route,
16756                page,
16757            })
16758        }
16759        NodeRequest::PreviewNativeSession {
16760            selection,
16761            message_limit,
16762        } => {
16763            if role != ClientRole::Operator {
16764                return Err(failure(
16765                    NodeFailureCode::ObserverReadOnly,
16766                    "native session preview requires an operator connection",
16767                ));
16768            }
16769            if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16770                .contains(&message_limit)
16771                || selection.validate().is_err()
16772            {
16773                return Err(failure(
16774                    NodeFailureCode::InvalidRequest,
16775                    "native session preview request is invalid",
16776                ));
16777            }
16778            let preview = shared
16779                .preview_native_session(selection.clone(), message_limit)
16780                .await?;
16781            Ok(NodeResponse::NativeSessionPreviewed {
16782                selection,
16783                preview,
16784            })
16785        }
16786        NodeRequest::IndexNativeSession {
16787            selection,
16788            display_name,
16789        } => {
16790            shared.require_controller(connection_id, role)?;
16791            let record = shared
16792                .index_native_session(selection.clone(), display_name)
16793                .await?;
16794            Ok(NodeResponse::NativeSessionIndexed { selection, record })
16795        }
16796        NodeRequest::PreviewSessionRecord {
16797            record_id,
16798            message_limit,
16799        } => {
16800            if role != ClientRole::Operator {
16801                return Err(failure(
16802                    NodeFailureCode::ObserverReadOnly,
16803                    "native session preview requires an operator connection",
16804                ));
16805            }
16806            if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16807                .contains(&message_limit)
16808            {
16809                return Err(failure(
16810                    NodeFailureCode::InvalidRequest,
16811                    "native session preview request is invalid",
16812                ));
16813            }
16814            let record = shared.record(&record_id)?;
16815            let working_directory = shared.workspace_root(&record.workspace_id)?;
16816            if !platform::roots_equal(
16817                &working_directory,
16818                windows_path_text(&record.canonical_root),
16819            ) {
16820                return Err(failure(
16821                    NodeFailureCode::SessionRecordConflict,
16822                    "managed session workspace root changed; refusing to preview another directory",
16823                ));
16824            }
16825            let identity = record.provider_session.clone().ok_or_else(|| {
16826                failure(
16827                    NodeFailureCode::BackendOperationFailed,
16828                    "native session preview is unavailable for this session record",
16829                )
16830            })?;
16831            if identity.key != ProviderSessionKey::SessionId {
16832                return Err(failure(
16833                    NodeFailureCode::BackendOperationFailed,
16834                    "native session preview is unavailable for this session identity",
16835                ));
16836            }
16837            let preview = shared
16838                .preview_native_session_by_session_id(
16839                    record.workspace_id.clone(), record.provider.clone(), identity.id.clone(), message_limit,
16840                )
16841                .await?;
16842            if preview.session_id != identity.id {
16843                return Err(failure(
16844                    NodeFailureCode::SessionRecordConflict,
16845                    "native session preview identity changed while loading",
16846                ));
16847            }
16848            shared.revalidate_session_record_preview(&record, &identity)?;
16849            let preview: gate4agent_types::SessionRecordPreview = preview.into();
16850            shared.publish(NodeEvent::SessionRecordHistorySummarized {
16851                record_id: record_id.clone(),
16852                summary: SessionHistorySummaryV1::from(&preview),
16853            });
16854            Ok(NodeResponse::SessionRecordPreviewed {
16855                record_id,
16856                preview,
16857            })
16858        }
16859        NodeRequest::AcquireController { lease_ms } => {
16860            let controller = shared.acquire_controller(connection_id, role, lease_ms)?;
16861            Ok(NodeResponse::Controller { controller: Some(controller) })
16862        }
16863        NodeRequest::ReleaseController => {
16864            shared.release_controller(connection_id);
16865            Ok(NodeResponse::Controller { controller: shared.controller_state() })
16866        }
16867        NodeRequest::RegisterWorkspace { workspace_id, root } => {
16868            shared.require_controller(connection_id, role)?;
16869            let root = require_windows_path(root)?;
16870            shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16871            let workspace = shared.register_workspace(workspace_id, root).await?;
16872            Ok(NodeResponse::WorkspaceRegistered { workspace })
16873        }
16874        NodeRequest::CreateStandaloneWorkspace {
16875            workspace_id,
16876            root,
16877            initial_branch,
16878        } => {
16879            shared.require_controller(connection_id, role)?;
16880            let root = require_windows_path(root)?;
16881            shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16882            let workspace = shared
16883                .create_standalone_workspace(workspace_id, root, initial_branch)
16884                .await?;
16885            Ok(NodeResponse::StandaloneWorkspaceCreated { workspace })
16886        }
16887        NodeRequest::UnregisterWorkspace { workspace_id } => {
16888            shared.require_controller(connection_id, role)?;
16889            shared.reject_managed_reservation(Some(&workspace_id), None)?;
16890            shared.unregister_workspace(&workspace_id)?;
16891            Ok(NodeResponse::WorkspaceUnregistered { workspace_id })
16892        }
16893        NodeRequest::CreateWorktree {
16894            source_workspace_id,
16895            workspace_id,
16896            target_root,
16897            branch,
16898            base,
16899        } => {
16900            shared.require_controller(connection_id, role)?;
16901            let target_root = require_windows_path(target_root)?;
16902            shared.reject_managed_reservation(Some(&workspace_id), Some(&target_root))?;
16903            let (worktree, workspace) = shared
16904                .create_worktree(
16905                    source_workspace_id,
16906                    workspace_id,
16907                    target_root,
16908                    branch,
16909                    base,
16910                )
16911                .await?;
16912            Ok(NodeResponse::WorktreeCreated { worktree, workspace })
16913        }
16914        NodeRequest::RemoveWorktree {
16915            source_workspace_id,
16916            target_root,
16917        } => {
16918            shared.require_controller(connection_id, role)?;
16919            let native_target_root = require_windows_path(target_root.clone())?;
16920            shared.reject_managed_reservation(None, Some(&native_target_root))?;
16921            let workspace_id = shared
16922                .remove_worktree(source_workspace_id, native_target_root)
16923                .await?;
16924            Ok(NodeResponse::WorktreeRemoved {
16925                target_root,
16926                workspace_id,
16927            })
16928        }
16929        NodeRequest::Spawn { workspace_id, provider, mode, terminal_size, initial_prompt } => {
16930            shared.require_controller(connection_id, role)?;
16931            if !terminal_size.is_valid() {
16932                return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
16933            }
16934            if let Some(prompt) = initial_prompt.as_deref() {
16935                validate_node_text("spawn initial prompt", prompt)?;
16936            }
16937            match shared
16938                .spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt)
16939                .await
16940            {
16941                Ok(session) => {
16942                    shared.mark_spawn_provisional(session.clone());
16943                    Ok(NodeResponse::SpawnAccepted { session })
16944                }
16945                Err(err) => {
16946                    tracing::warn!(
16947                        %provider,
16948                        %workspace_id,
16949                        ?mode,
16950                        code = ?err.code,
16951                        message = %err.message,
16952                        "session spawn refused"
16953                    );
16954                    Err(err)
16955                }
16956            }
16957        }
16958        NodeRequest::SpawnWithOptions { workspace_id, provider, mode, terminal_size, initial_prompt, options } => {
16959            shared.require_controller(connection_id, role)?;
16960            if !terminal_size.is_valid() {
16961                return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
16962            }
16963            if let Some(prompt) = initial_prompt.as_deref() {
16964                validate_node_text("spawn initial prompt", prompt)?;
16965            }
16966            let plan = backend_paths::plan_launch_options(
16967                provider.as_str(),
16968                mode,
16969                options.model.is_some(),
16970                options.effort.is_some(),
16971            )
16972            .map_err(|r| {
16973                tracing::warn!(%provider, reason = %r.message, "launch option path refused");
16974                failure(NodeFailureCode::InvalidRequest, "launch option path refused")
16975            })?;
16976            let selection = spawn_selection(&options)?;
16977            let pty_selection = match plan {
16978                backend_paths::LaunchOptionsPlan::PtyFlags => selection,
16979                _ => None,
16980            };
16981            let spawned = SPAWN_ORIGIN.scope(options.origin.clone(), async {
16982                SPAWN_PARENT.scope(options.parent_session_id.clone(), async {
16983                    SPAWN_SESSION_OPTIONS
16984                        .scope(pty_selection, shared.spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt))
16985                        .await
16986                }).await
16987            }).await;
16988            match spawned {
16989                Ok(session) => {
16990                    if plan == backend_paths::LaunchOptionsPlan::AcpAfterSpawn {
16991                        shared
16992                            .pending_acp_options
16993                            .lock()
16994                            .unwrap_or_else(|p| p.into_inner())
16995                            .insert(session.session.instance_id, (options.model.clone(), options.effort.clone()));
16996                    }
16997                    shared.mark_spawn_provisional(session.clone());
16998                    tracing::info!(%provider, model = ?options.model, effort = ?options.effort, "session spawned with launch options");
16999                    Ok(NodeResponse::SpawnAccepted { session })
17000                }
17001                Err(err) => {
17002                    tracing::warn!(%provider, %workspace_id, code = ?err.code, message = %err.message, "session spawn with options refused");
17003                    Err(err)
17004                }
17005            }
17006        }
17007        NodeRequest::SpawnSpec { spec } => {
17008            shared.require_controller(connection_id, role)?;
17009            let receipt = shared.spawn_from_spec(spec).await?;
17010            Ok(NodeResponse::SpawnSpecAccepted { receipt })
17011        }
17012        NodeRequest::SpawnManagedWorktree { request } => {
17013            shared.require_controller(connection_id, role)?;
17014            let receipt = shared.spawn_managed_worktree(request).await?;
17015            Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
17016        }
17017        NodeRequest::SpawnManagedWorktreeV2 { request } => {
17018            shared.require_controller(connection_id, role)?;
17019            let receipt = shared.spawn_managed_worktree_v2(request).await?;
17020            Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
17021        }
17022        NodeRequest::CleanupManagedWorktree { lease_id } => {
17023            shared.require_controller(connection_id, role)?;
17024            let lease = shared.cleanup_managed_worktree(&lease_id, true).await?;
17025            Ok(NodeResponse::ManagedWorktreeCleanup { lease })
17026        }
17027        NodeRequest::DiscoverHistory { session, limit } => {
17028            controlled_session(shared, connection_id, role, &session)?;
17029            let candidates = shared.discover_history(&session, limit).await?;
17030            Ok(NodeResponse::HistoryDiscovered {
17031                session,
17032                candidates,
17033            })
17034        }
17035        NodeRequest::LoadHistory {
17036            session,
17037            candidate_id,
17038        } => {
17039            controlled_session(shared, connection_id, role, &session)?;
17040            let loaded = shared
17041                .load_history(&session, candidate_id)
17042                .await?;
17043            Ok(NodeResponse::HistoryLoaded {
17044                session,
17045                session_id: loaded.session_id,
17046                message_count: loaded.message_count,
17047                completed_turn_count: loaded.completed_turn_count,
17048            })
17049        }
17050        NodeRequest::ExportContextPackForSessionRecord { record_id, session } => {
17051            shared.require_controller(connection_id, role)?;
17052            let context = shared
17053                .export_context_pack_for_session_record(&record_id, &session, false)
17054                .await?;
17055            Ok(NodeResponse::ContextPackForSessionRecordExported {
17056                record_id,
17057                session,
17058                context,
17059            })
17060        }
17061        NodeRequest::ExportContextPack { session } => {
17062            controlled_session(shared, connection_id, role, &session)?;
17063            let context = shared.export_context_pack(&session).await?;
17064            Ok(NodeResponse::ContextPackExported { context })
17065        }
17066        NodeRequest::ForgetContextPack { context_id } => {
17067            shared.require_controller(connection_id, role)?;
17068            shared.forget_context_pack(&context_id)?;
17069            Ok(NodeResponse::ContextPackForgotten { context_id })
17070        }
17071        NodeRequest::ResolveDurableContextPack { context_id } => {
17072            // Read-only, idempotent, no controller lease required: this lets
17073            // a caller preflight "is this durably exported pack still here"
17074            // without holding the Node's mutation controller.
17075            let context = shared
17076                .context_catalog
17077                .read()
17078                .unwrap_or_else(|poisoned| poisoned.into_inner())
17079                .get(&context_id)
17080                .map(|pack| pack.receipt().clone())
17081                .ok_or_else(|| {
17082                    failure(
17083                        NodeFailureCode::UnknownContextPack,
17084                        "context pack is not present",
17085                    )
17086                })?;
17087            Ok(NodeResponse::DurableContextPackResolved { context })
17088        }
17089        NodeRequest::ReadContextPack { digest } => {
17090            // Read-only fetch of the pack's own bytes, keyed by content digest
17091            // rather than the caller's `SpawnContextId`: same no-controller-lease
17092            // rationale as `ResolveDurableContextPack` above, one step further
17093            // down the same catalog.
17094            let (id, byte_len, bytes) = shared
17095                .context_catalog
17096                .read()
17097                .unwrap_or_else(|poisoned| poisoned.into_inner())
17098                .get_by_digest(&digest)
17099                .map(|pack| {
17100                    (pack.receipt().id.clone(), pack.receipt().byte_len, pack.bytes().to_vec())
17101                })
17102                .ok_or_else(|| {
17103                    failure(
17104                        NodeFailureCode::UnknownContextPack,
17105                        &format!("context pack {digest} not on this node"),
17106                    )
17107                })?;
17108            tracing::debug!(
17109                digest = digest.as_str(),
17110                byte_len,
17111                "read context pack bytes",
17112            );
17113            Ok(NodeResponse::ContextPackBytesRead {
17114                pack: ContextPackBytesRead {
17115                    digest,
17116                    id,
17117                    byte_len,
17118                    bytes,
17119                },
17120            })
17121        }
17122        NodeRequest::Resume { session, terminal_size, initial_prompt } => {
17123            let provider = controlled_session(shared, connection_id, role, &session)?;
17124            if let Some(prompt) = initial_prompt.as_deref() {
17125                validate_node_text("resume initial prompt", prompt)?;
17126            }
17127            let runtime_requirement = if initial_prompt.is_some() {
17128                ProviderRuntimeRequirement::ResumeWithPrompt
17129            } else {
17130                ProviderRuntimeRequirement::Resume
17131            };
17132            shared.require_session_runtime_policy(&session, runtime_requirement)?;
17133            let runtime_policy = shared
17134                .admit_provider_runtime(&provider, runtime_requirement)
17135                .await?;
17136            let working_directory = shared.bound_workspace_root(&session)?;
17137            let request = ResumeLaunchRequest {
17138                working_directory,
17139                terminal_size,
17140                initial_prompt,
17141            };
17142            request
17143                .validate()
17144                .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
17145            let command = shared.prepare_command(ControlCommand::Resume {
17146                instance_id: session.session.instance_id,
17147                target: ResumeTarget::CurrentProvider,
17148                runtime_policy,
17149                request,
17150            });
17151            shared.arm_resume(&session, command.id, runtime_policy)?;
17152            let dispatch = shared.dispatch_envelope(command);
17153            if let Err(error) = dispatch {
17154                shared.clear_armed_resume(&session);
17155                return Err(error);
17156            }
17157            Ok(NodeResponse::Accepted)
17158        }
17159        NodeRequest::RenameSessionRecord {
17160            record_id,
17161            display_name,
17162        } => {
17163            shared.require_controller(connection_id, role)?;
17164            let record = shared.rename_session_record(&record_id, display_name)?;
17165            Ok(NodeResponse::SessionRecordUpdated { record })
17166        }
17167        NodeRequest::SetSessionTask {
17168            record_id,
17169            expected_revision,
17170            target,
17171        } => {
17172            shared.require_controller(connection_id, role)?;
17173            let record = shared.set_session_task(&record_id, expected_revision, target)?;
17174            Ok(NodeResponse::SessionRecordUpdated { record })
17175        }
17176        NodeRequest::IndexProviderSession {
17177            workspace_id,
17178            provider,
17179            identity,
17180            display_name,
17181        } => {
17182            shared.require_controller(connection_id, role)?;
17183            let record = shared.index_provider_session(
17184                workspace_id,
17185                provider,
17186                identity,
17187                display_name,
17188            )?;
17189            Ok(NodeResponse::ProviderSessionIndexed { record })
17190        }
17191        NodeRequest::ResumeSessionRecord {
17192            record_id,
17193            terminal_size,
17194            initial_prompt,
17195        } => {
17196            shared.require_controller(connection_id, role)?;
17197            if let Some(prompt) = initial_prompt.as_deref() {
17198                validate_node_text("managed resume initial prompt", prompt)?;
17199            }
17200            let (record, session) = shared
17201                .resume_session_record(
17202                    &record_id,
17203                    terminal_size,
17204                    initial_prompt,
17205                )
17206                .await?;
17207            Ok(NodeResponse::SessionRecordResumed { record, session })
17208        }
17209        NodeRequest::ForgetSessionRecord { record_id } => {
17210            shared.require_controller(connection_id, role)?;
17211            shared.forget_session_record(&record_id).await?;
17212            Ok(NodeResponse::SessionRecordForgotten { record_id })
17213        }
17214        NodeRequest::Prompt { session, text } => {
17215            let agent_id = controlled_session(shared, connection_id, role, &session)?;
17216            validate_node_text("prompt", &text)?;
17217            shared.require_session_runtime_policy(
17218                &session,
17219                ProviderRuntimeRequirement::SemanticPrompt,
17220            )?;
17221            let framing = prompt_framing(&agent_id);
17222            shared
17223                .dispatch_input_bounded(
17224                    &session,
17225                    InputAction::SubmitPrompt(PromptPayload { text, framing }),
17226                )
17227                .await?;
17228            Ok(NodeResponse::Accepted)
17229        }
17230        NodeRequest::Paste { session, text } => {
17231            controlled_session(shared, connection_id, role, &session)?;
17232            validate_node_text("paste", &text)?;
17233            shared.require_session_runtime_policy(
17234                &session,
17235                ProviderRuntimeRequirement::SemanticPrompt,
17236            )?;
17237            shared
17238                .dispatch_input_bounded(
17239                    &session,
17240                    InputAction::InsertDraft(PromptPayload {
17241                        text,
17242                        framing: PromptFraming::BracketedPaste,
17243                    }),
17244                )
17245                .await?;
17246            Ok(NodeResponse::Accepted)
17247        }
17248        NodeRequest::Input { session, text } => {
17249            controlled_session(shared, connection_id, role, &session)?;
17250            validate_node_text("terminal input", &text)?;
17251            shared
17252                .dispatch_input_bounded(
17253                    &session,
17254                    InputAction::TerminalText(TerminalText { text }),
17255                )
17256                .await?;
17257            Ok(NodeResponse::Accepted)
17258        }
17259        NodeRequest::TerminalBytes { session, bytes } => {
17260            controlled_session(shared, connection_id, role, &session)?;
17261            shared.sessionize_spawn(session.session.instance_id);
17262            let action = terminal_bytes_action(bytes)?;
17263            shared
17264                .dispatch_input_bounded(&session, action)
17265                .await?;
17266            Ok(NodeResponse::Accepted)
17267        }
17268        NodeRequest::TerminalControl { session, control } => {
17269            controlled_session(shared, connection_id, role, &session)?;
17270            shared
17271                .dispatch_input_bounded(&session, InputAction::TerminalControl(control))
17272                .await?;
17273            Ok(NodeResponse::Accepted)
17274        }
17275        NodeRequest::Resize { session, size } => {
17276            controlled_session(shared, connection_id, role, &session)?;
17277            shared.sessionize_spawn(session.session.instance_id);
17278            if !size.is_valid() {
17279                return Err(failure(NodeFailureCode::InvalidRequest, "terminal size is invalid"));
17280            }
17281            shared.dispatch_resize_bounded(&session, size).await?;
17282            Ok(NodeResponse::Accepted)
17283        }
17284        NodeRequest::Interrupt { session } => {
17285            controlled_session(shared, connection_id, role, &session)?;
17286            shared
17287                .dispatch_input_bounded(
17288                    &session,
17289                    InputAction::TerminalControl(TerminalControl::Interrupt),
17290                )
17291                .await?;
17292            Ok(NodeResponse::Accepted)
17293        }
17294        NodeRequest::Stop { session, force } => {
17295            controlled_session(shared, connection_id, role, &session)?;
17296            shared.sessionize_spawn(session.session.instance_id);
17297            shared.dispatch(ControlCommand::Stop { instance_id: session.session.instance_id, force })?;
17298            shared.retire_session_grok_leader(session.session.instance_id);
17299            Ok(NodeResponse::Accepted)
17300        }
17301        NodeRequest::Remove { session } => {
17302            controlled_session(shared, connection_id, role, &session)?;
17303            shared.remove_session(&session).await?;
17304            Ok(NodeResponse::Accepted)
17305        }
17306        NodeRequest::ResolveInteraction { session, correlation_id, response } => {
17307            shared.require_controller(connection_id, role)?;
17308            let snapshot = shared.require_acp_session(&session)?;
17309            let interaction = snapshot
17310                .provider
17311                .interactions
17312                .iter()
17313                .find(|interaction| {
17314                    interaction_correlation(
17315                        session.session.instance_id,
17316                        session.session.generation,
17317                        interaction.id.0,
17318                    ) == correlation_id
17319                })
17320                .ok_or_else(|| failure(
17321                    NodeFailureCode::InvalidRequest,
17322                    "unknown interaction correlation",
17323                ))?;
17324            if interaction.status != ProviderInteractionStatus::Pending {
17325                return Err(failure(
17326                    NodeFailureCode::BackendOperationFailed,
17327                    "interaction has already been resolved",
17328                ));
17329            }
17330            response
17331                .validate_for(interaction.interaction_kind)
17332                .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
17333            let interaction_id = interaction.id;
17334            let response_kind = response.kind();
17335            let response_outcome = response.outcome();
17336            shared.dispatch(ControlCommand::ResolveInteraction {
17337                instance_id: session.session.instance_id,
17338                generation: session.session.generation,
17339                interaction_id,
17340                response,
17341            })?;
17342            // The one line that lets an operator compare what THIS node
17343            // recorded against what the provider itself reports doing --
17344            // measured live on a kimi run where our side resolved `true`
17345            // while the agent reported "rejected" and neither layer had a
17346            // line to line up against the other.
17347            tracing::info!(
17348                node_id = %shared.node_id,
17349                session = ?session.session,
17350                correlation_id = %correlation_id,
17351                interaction_id = interaction_id.0,
17352                response_kind = ?response_kind,
17353                outcome = ?response_outcome,
17354                "interaction resolved",
17355            );
17356            Ok(NodeResponse::Accepted)
17357        }
17358        NodeRequest::SetSessionMode { session, mode_id } => {
17359            shared.require_controller(connection_id, role)?;
17360            shared.require_acp_session(&session)?;
17361            shared.dispatch(ControlCommand::SetSessionMode {
17362                instance_id: session.session.instance_id,
17363                mode_id,
17364            })?;
17365            Ok(NodeResponse::Accepted)
17366        }
17367        NodeRequest::SetSessionConfigOption {
17368            session,
17369            option_id,
17370            value_json,
17371        } => {
17372            shared.require_controller(connection_id, role)?;
17373            shared.require_acp_session(&session)?;
17374            shared.dispatch(ControlCommand::SetSessionConfigOption {
17375                instance_id: session.session.instance_id,
17376                option_id,
17377                value_json,
17378            })?;
17379            Ok(NodeResponse::Accepted)
17380        }
17381        NodeRequest::SetSessionModel { session, model_id } => {
17382            shared.require_controller(connection_id, role)?;
17383            shared.require_acp_session(&session)?;
17384            shared.dispatch(ControlCommand::SetSessionModel {
17385                instance_id: session.session.instance_id,
17386                model_id,
17387            })?;
17388            Ok(NodeResponse::Accepted)
17389        }
17390        NodeRequest::Shutdown => {
17391            shared.require_controller(connection_id, role)?;
17392            shared.begin_shutdown_locked().await?;
17393            Ok(NodeResponse::ShuttingDown)
17394        }
17395    }
17396}
17397
17398fn controlled_session(shared: &NodeShared, connection_id: u64, role: ClientRole, session: &SessionAddress) -> Result<AgentId, NodeFailure> {
17399    shared.require_controller(connection_id, role)?;
17400    shared.validate_address(session)
17401}
17402
17403/// Selects the sole history candidate matching `provider_session_id` when
17404/// the managed record carries a verified identity, or the sole candidate in
17405/// the discovered set at all when it does not (see
17406/// `export_context_pack_for_session_record_inner`'s identity match). Either
17407/// way, more than one match refuses instead of guessing which one is exact.
17408fn select_history_candidate(
17409    candidates: &[HistoryCandidateSummary],
17410    provider_session_id: Option<&str>,
17411) -> Result<String, NodeFailure> {
17412    let mut matches = candidates.iter().filter(|candidate| match provider_session_id {
17413        Some(id) => candidate.session_id_hint == id,
17414        None => true,
17415    });
17416    let candidate = matches.next().ok_or_else(|| {
17417        failure(
17418            NodeFailureCode::SessionRecordConflict,
17419            if provider_session_id.is_some() {
17420                "no history candidate matches the managed provider session"
17421            } else {
17422                "no history candidate exists for the managed session"
17423            },
17424        )
17425    })?;
17426    if matches.next().is_some() {
17427        return Err(failure(
17428            NodeFailureCode::SessionRecordConflict,
17429            if provider_session_id.is_some() {
17430                "history identity is ambiguous for the managed provider session"
17431            } else {
17432                "history is ambiguous without a verified provider session identity"
17433            },
17434        ));
17435    }
17436    Ok(candidate.id.clone())
17437}
17438
17439fn resolved_spawn_profile_summary(
17440    profile: &SpawnProfileDefaults,
17441    environment_profiles: &BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>,
17442) -> Option<SpawnProfileSummary> {
17443    let environment_profile = match profile.environment_profile_id.as_ref() {
17444        None => None,
17445        Some(profile_id) => {
17446            let binding = environment_profiles.get(profile_id)?;
17447            if binding.provider != profile.provider
17448                || binding.native_profile_id(profile.mode).is_none()
17449            {
17450                return None;
17451            }
17452            Some(ResolvedEnvironmentProfileReceipt {
17453                profile_id: binding.id.clone(),
17454                profile_revision: binding.revision.clone(),
17455                network_allowlist: None,
17456                browser_profile_id: None,
17457            })
17458        }
17459    };
17460    Some(SpawnProfileSummary {
17461        id: profile.profile_id.clone(),
17462        revision: profile.revision.clone(),
17463        environment_profile,
17464    })
17465}
17466
17467/// True if `current` is an acceptable version of `expected` at export
17468/// revalidation/commit time.
17469///
17470/// When `allow_clean_detachment` is `false` (the on-demand,
17471/// `NodeRequest::ExportContextPackForSessionRecord` API path), `current` must
17472/// be byte-identical to `expected` and still actively bound to `session` —
17473/// the original, unchanged invariant.
17474///
17475/// When `true` (the reactive auto-export-at-exit path, `§2.1`), `current` may
17476/// ALSO be the exact Live -> Dormant transition that the same clean exit this
17477/// export reacts to produces on its own: `state` Live -> Dormant,
17478/// `active_session` `Some(session)` -> `None`, `updated_at_unix_ms`
17479/// advancing. Every other field — including `provider_session`, `context`,
17480/// `bundle`, `task_binding` — must still match exactly. A record that changed
17481/// in any other way (rebind, a different active session, an identity
17482/// conflict, capacity eviction) is rejected either way; this widens nothing
17483/// about which sessions are eligible, only how the record's own expected
17484/// detachment is tolerated mid-export.
17485fn session_record_export_target_matches(
17486    expected: &ManagedSessionRecord,
17487    current: &ManagedSessionRecord,
17488    session: &SessionAddress,
17489    allow_clean_detachment: bool,
17490) -> bool {
17491    if current == expected && current.active_session.as_ref() == Some(session) {
17492        return current.state == ManagedSessionState::Live;
17493    }
17494    allow_clean_detachment
17495        && current.state == ManagedSessionState::Dormant
17496        && current.active_session.is_none()
17497        && current.record_id == expected.record_id
17498        && current.display_name == expected.display_name
17499        && current.provider == expected.provider
17500        && current.mode == expected.mode
17501        && current.workspace_id == expected.workspace_id
17502        && current.canonical_root == expected.canonical_root
17503        && current.provider_session == expected.provider_session
17504        && current.environment_profile == expected.environment_profile
17505        && current.bundle == expected.bundle
17506        && current.context_id == expected.context_id
17507        && current.context == expected.context
17508        && current.exported_context == expected.exported_context
17509        && current.task_binding == expected.task_binding
17510        && current.created_at_unix_ms == expected.created_at_unix_ms
17511        && current.last_error == expected.last_error
17512}
17513
17514fn session_record_context_export_binding_is_exact(
17515    expected: &ManagedSessionRecord,
17516    current: &ManagedSessionRecord,
17517    identity: Option<&ProviderSessionIdentity>,
17518    session: &SessionAddress,
17519    binding: &SessionBinding,
17520    provider: &AgentId,
17521    current_root: &str,
17522    allow_clean_detachment: bool,
17523) -> bool {
17524    session_record_export_target_matches(expected, current, session, allow_clean_detachment)
17525        && current.provider_session.as_ref() == identity
17526        && current.workspace_id == session.workspace_id
17527        && provider == &current.provider
17528        && binding.workspace_id == session.workspace_id
17529        && binding.generation == session.session.generation
17530        && binding.record_id.as_ref() == Some(&current.record_id)
17531        && platform::roots_equal(
17532            current_root,
17533            windows_path_text(&current.canonical_root),
17534        )
17535}
17536
17537fn session_record_context_export_source_is_usable(
17538    record: &ManagedSessionRecord,
17539    status: &SessionStatus,
17540    allow_clean_detachment: bool,
17541) -> bool {
17542    (record.state == ManagedSessionState::Live
17543        || (allow_clean_detachment && record.state == ManagedSessionState::Dormant))
17544        && context_pack_source_status_is_usable(status)
17545}
17546
17547fn prompt_framing(_agent_id: &AgentId) -> PromptFraming {
17548    PromptFraming::BracketedPaste
17549}
17550
17551fn validate_node_text(field: &str, text: &str) -> Result<(), NodeFailure> {
17552    if text.len() > MAX_NODE_TEXT_BYTES {
17553        return Err(failure(
17554            NodeFailureCode::InvalidRequest,
17555            &format!(
17556                "{field} length {} exceeds the {MAX_NODE_TEXT_BYTES}-byte node limit",
17557                text.len(),
17558            ),
17559        ));
17560    }
17561    Ok(())
17562}
17563
17564fn validate_terminal_bytes(bytes: &[u8]) -> Result<(), NodeFailure> {
17565    if bytes.is_empty() || bytes.len() > MAX_NODE_TERMINAL_BYTES {
17566        return Err(failure(
17567            NodeFailureCode::InvalidRequest,
17568            &format!(
17569                "terminal byte sequence must contain 1..={MAX_NODE_TERMINAL_BYTES} bytes; received {}",
17570                bytes.len(),
17571            ),
17572        ));
17573    }
17574    Ok(())
17575}
17576
17577fn terminal_bytes_action(bytes: Vec<u8>) -> Result<InputAction, NodeFailure> {
17578    validate_terminal_bytes(&bytes)?;
17579    Ok(InputAction::TerminalBytes(bytes))
17580}
17581
17582fn validate_workspace_request_root(root: &str) -> Result<(), NodeFailure> {
17583    if root.is_empty()
17584        || root.len() > MAX_WORKSPACE_ROOT_BYTES
17585        || root.chars().any(char::is_control)
17586    {
17587        return Err(failure(
17588            NodeFailureCode::InvalidWorkspaceRoot,
17589            &format!(
17590                "workspace root must contain 1..={MAX_WORKSPACE_ROOT_BYTES} bytes and no control characters",
17591            ),
17592        ));
17593    }
17594    Ok(())
17595}
17596
17597fn canonical_standalone_workspace_candidate(
17598    workspace_id: &WorkspaceId,
17599    root: &str,
17600) -> Result<String, NodeServerError> {
17601    let path = Path::new(root);
17602    match std::fs::symlink_metadata(path) {
17603        Ok(_) => WorkspaceConfig::new(workspace_id.clone(), path)
17604            .map(|workspace| workspace.canonical_root),
17605        Err(error) if error.kind() == io::ErrorKind::NotFound => {
17606            if !path.is_absolute() {
17607                return Err(NodeServerError::InvalidWorkspaceRoot {
17608                    workspace_id: workspace_id.clone(),
17609                    path: root.to_owned(),
17610                    message: "path is not absolute".to_owned(),
17611                });
17612            }
17613            let name = path.file_name().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
17614                workspace_id: workspace_id.clone(),
17615                path: root.to_owned(),
17616                message: "path has no standalone directory name".to_owned(),
17617            })?;
17618            let parent = path.parent().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
17619                workspace_id: workspace_id.clone(),
17620                path: root.to_owned(),
17621                message: "path has no parent directory".to_owned(),
17622            })?;
17623            let canonical = std::fs::canonicalize(parent)
17624                .map(|parent| parent.join(name))
17625                .map_err(|error| NodeServerError::InvalidWorkspaceRoot {
17626                    workspace_id: workspace_id.clone(),
17627                    path: root.to_owned(),
17628                    message: error.to_string(),
17629                })?;
17630            let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
17631                NodeServerError::InvalidWorkspaceRoot {
17632                    workspace_id: workspace_id.clone(),
17633                    path: path.to_string_lossy().into_owned(),
17634                    message: "canonical path is not valid Unicode".to_owned(),
17635                }
17636            })?;
17637            let canonical_root = platform::normalize_canonical_root(canonical_root);
17638            validate_workspace_root(workspace_id, &canonical_root)?;
17639            Ok(canonical_root)
17640        }
17641        Err(error) => Err(NodeServerError::InvalidWorkspaceRoot {
17642            workspace_id: workspace_id.clone(),
17643            path: root.to_owned(),
17644            message: error.to_string(),
17645        }),
17646    }
17647}
17648
17649fn validate_git_revision(field: &str, revision: Option<&str>) -> Result<(), NodeFailure> {
17650    let Some(revision) = revision else {
17651        return Ok(());
17652    };
17653    if revision.is_empty()
17654        || revision.len() > MAX_REPOSITORY_PATH_BYTES
17655        || revision.chars().any(char::is_control)
17656    {
17657        return Err(failure(
17658            NodeFailureCode::InvalidRequest,
17659            &format!(
17660                "{field} must contain 1..={MAX_REPOSITORY_PATH_BYTES} bytes and no control characters",
17661            ),
17662        ));
17663    }
17664    Ok(())
17665}
17666
17667fn git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
17668    let code = match error.kind {
17669        GitWorktreeErrorKind::Invalid => NodeFailureCode::InvalidRequest,
17670        GitWorktreeErrorKind::NotRepository => NodeFailureCode::NotGitRepository,
17671        GitWorktreeErrorKind::Conflict => NodeFailureCode::WorktreeConflict,
17672        GitWorktreeErrorKind::Protected => NodeFailureCode::WorktreeProtected,
17673        GitWorktreeErrorKind::Dirty => NodeFailureCode::WorktreeDirty,
17674        GitWorktreeErrorKind::Locked => NodeFailureCode::WorktreeLocked,
17675        GitWorktreeErrorKind::Failed => NodeFailureCode::BackendOperationFailed,
17676    };
17677    failure(code, &error.message)
17678}
17679
17680fn standalone_workspace_failure(error: StandaloneWorkspaceError) -> NodeFailure {
17681    let code = match error.kind {
17682        StandaloneWorkspaceErrorKind::InvalidRoot => NodeFailureCode::InvalidWorkspaceRoot,
17683        StandaloneWorkspaceErrorKind::InvalidInitialBranch => NodeFailureCode::InvalidRequest,
17684        StandaloneWorkspaceErrorKind::GitInitializationFailed => {
17685            NodeFailureCode::BackendOperationFailed
17686        }
17687        StandaloneWorkspaceErrorKind::RecoveryRequired => {
17688            NodeFailureCode::StandaloneWorkspaceRecoveryRequired
17689        }
17690    };
17691    failure(code, error.message)
17692}
17693
17694fn managed_git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
17695    let (code, message) = match error.kind {
17696        GitWorktreeErrorKind::Invalid => (
17697            NodeFailureCode::InvalidRequest,
17698            "managed Git input is invalid",
17699        ),
17700        GitWorktreeErrorKind::NotRepository => (
17701            NodeFailureCode::NotGitRepository,
17702            "managed source is not a Git repository",
17703        ),
17704        GitWorktreeErrorKind::Conflict => (
17705            NodeFailureCode::WorktreeConflict,
17706            "managed Git identity conflicts with existing repository state",
17707        ),
17708        GitWorktreeErrorKind::Protected => (
17709            NodeFailureCode::ManagedWorktreeOwnershipConflict,
17710            "managed worktree ownership validation failed",
17711        ),
17712        GitWorktreeErrorKind::Dirty => (
17713            NodeFailureCode::WorktreeDirty,
17714            "managed worktree contains uncommitted changes",
17715        ),
17716        GitWorktreeErrorKind::Locked => (
17717            NodeFailureCode::WorktreeLocked,
17718            "managed worktree is locked",
17719        ),
17720        GitWorktreeErrorKind::Failed => (
17721            NodeFailureCode::BackendOperationFailed,
17722            "managed Git operation failed",
17723        ),
17724    };
17725    NodeFailure { code, message: message.to_owned() }
17726}
17727
17728fn managed_spawn_receipt(
17729    resolved: &ResolvedSpawnSpec,
17730    incarnation_id: NodeIncarnationId,
17731    session: SessionAddress,
17732    lease: ManagedWorktreeLeaseSnapshot,
17733    environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
17734    bundle: Option<ResolvedBundleReceipt>,
17735    context: Option<ResolvedContextPackReceipt>,
17736) -> ManagedWorktreeSpawnReceipt {
17737    let mut managed = resolved.clone();
17738    managed.target.worktree_id = Some(lease.workspace_id.clone());
17739    ManagedWorktreeSpawnReceipt {
17740        spawn: managed.receipt_with_materialization(
17741            incarnation_id,
17742            session,
17743            environment_profile,
17744            bundle,
17745            context,
17746        ),
17747        lease,
17748    }
17749}
17750
17751fn validate_selected_worktree(
17752    source_root: &str,
17753    selected_root: &str,
17754    worktrees: &[NativeGitWorktreeSnapshot],
17755) -> Result<(), NodeFailure> {
17756    if !worktrees
17757        .iter()
17758        .any(|worktree| worktree_paths_equal(&worktree.path, source_root) && !worktree.is_bare)
17759    {
17760        return Err(failure(
17761            NodeFailureCode::WorktreeProtected,
17762            "source workspace is not an authoritative non-bare Git worktree root",
17763        ));
17764    }
17765    if !worktrees.iter().any(|worktree| {
17766        worktree_paths_equal(&worktree.path, selected_root)
17767            && !worktree.is_main
17768            && !worktree.is_bare
17769    }) {
17770        return Err(failure(
17771            NodeFailureCode::WorktreeProtected,
17772            "selected workspace is not an eligible Git worktree in the source repository",
17773        ));
17774    }
17775    Ok(())
17776}
17777
17778fn workspace_file_failure(error: WorkspaceFileReadError) -> NodeFailure {
17779    let code = match error.kind() {
17780        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17781        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17782            NodeFailureCode::RepositoryPathUnsafe
17783        }
17784        WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17785        WorkspaceFileReadErrorKind::NotRegularFile => {
17786            NodeFailureCode::RepositoryFileNotRegular
17787        }
17788        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17789            NodeFailureCode::RepositoryFileReadFailed
17790        }
17791        WorkspaceFileReadErrorKind::RevisionConflict => {
17792            NodeFailureCode::RepositoryFileRevisionConflict
17793        }
17794        WorkspaceFileReadErrorKind::AlreadyExists
17795        | WorkspaceFileReadErrorKind::ParentNotFound
17796        | WorkspaceFileReadErrorKind::ParentNotDirectory
17797        | WorkspaceFileReadErrorKind::Canceled => {
17798            NodeFailureCode::RepositoryFileReadFailed
17799        }
17800    };
17801    failure(code, "repository file read failed")
17802}
17803
17804fn workspace_file_write_failure(error: WorkspaceFileReadError) -> NodeFailure {
17805    let code = match error.kind() {
17806        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17807        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17808            NodeFailureCode::RepositoryPathUnsafe
17809        }
17810        WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17811        WorkspaceFileReadErrorKind::NotRegularFile => {
17812            NodeFailureCode::RepositoryFileNotRegular
17813        }
17814        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17815            NodeFailureCode::RepositoryFileWriteFailed
17816        }
17817        WorkspaceFileReadErrorKind::RevisionConflict => {
17818            NodeFailureCode::RepositoryFileRevisionConflict
17819        }
17820        WorkspaceFileReadErrorKind::AlreadyExists
17821        | WorkspaceFileReadErrorKind::ParentNotFound
17822        | WorkspaceFileReadErrorKind::ParentNotDirectory
17823        | WorkspaceFileReadErrorKind::Canceled => {
17824            NodeFailureCode::RepositoryFileWriteFailed
17825        }
17826    };
17827    failure(code, "repository file write failed")
17828}
17829
17830async fn settle_workspace_entry_create<T>(
17831    mut task: tokio::task::JoinHandle<Result<T, WorkspaceFileReadError>>,
17832    commit_state: Arc<AtomicU8>,
17833    deadline: Duration,
17834    timeout_message: &'static str,
17835    task_failure_message: &'static str,
17836) -> Result<T, NodeFailure> {
17837    let joined = match timeout(deadline, &mut task).await {
17838        Ok(joined) => joined,
17839        Err(_) => {
17840            if commit_state
17841                .compare_exchange(
17842                    WORKSPACE_ENTRY_CREATE_PENDING,
17843                    WORKSPACE_ENTRY_CREATE_CANCELED,
17844                    Ordering::AcqRel,
17845                    Ordering::Acquire,
17846                )
17847                .is_ok()
17848            {
17849                return Err(failure(
17850                    NodeFailureCode::RepositoryEntryCreateTimedOut,
17851                    timeout_message,
17852                ));
17853            }
17854            debug_assert_eq!(
17855                commit_state.load(Ordering::Acquire),
17856                WORKSPACE_ENTRY_CREATE_COMMITTING,
17857            );
17858            task.await
17859        }
17860    };
17861    joined
17862        .map_err(|_| {
17863            failure(
17864                NodeFailureCode::RepositoryEntryCreateFailed,
17865                task_failure_message,
17866            )
17867        })?
17868        .map_err(workspace_entry_create_failure)
17869}
17870
17871fn workspace_entry_create_failure(error: WorkspaceFileReadError) -> NodeFailure {
17872    let code = match error.kind() {
17873        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17874        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17875            NodeFailureCode::RepositoryPathUnsafe
17876        }
17877        WorkspaceFileReadErrorKind::AlreadyExists => {
17878            NodeFailureCode::RepositoryEntryAlreadyExists
17879        }
17880        WorkspaceFileReadErrorKind::ParentNotFound | WorkspaceFileReadErrorKind::NotFound => {
17881            NodeFailureCode::RepositoryParentNotFound
17882        }
17883        WorkspaceFileReadErrorKind::ParentNotDirectory
17884        | WorkspaceFileReadErrorKind::NotRegularFile => {
17885            NodeFailureCode::RepositoryParentNotDirectory
17886        }
17887        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17888            NodeFailureCode::RepositoryEntryCreateFailed
17889        }
17890        WorkspaceFileReadErrorKind::RevisionConflict => {
17891            NodeFailureCode::RepositoryEntryCreateFailed
17892        }
17893        WorkspaceFileReadErrorKind::Canceled => {
17894            NodeFailureCode::RepositoryEntryCreateTimedOut
17895        }
17896    };
17897    failure(code, "repository entry create failed")
17898}
17899
17900fn workspace_file_revision(bytes: &[u8]) -> WorkspaceFileRevision {
17901    let value = digest(&SHA256, bytes)
17902        .as_ref()
17903        .iter()
17904        .map(|byte| format!("{byte:02x}"))
17905        .collect();
17906    WorkspaceFileRevision::new(value).expect("SHA-256 produces a valid workspace revision")
17907}
17908
17909fn managed_spawn_request_digest_v2(
17910    request: &ManagedWorktreeSpawnRequestV2,
17911) -> Result<String, NodeFailure> {
17912    let encoded = serde_json::to_vec(request).map_err(|_| {
17913        failure(
17914            NodeFailureCode::InvalidRequest,
17915            "managed worktree V2 request could not be canonicalized",
17916        )
17917    })?;
17918    let mut value = String::with_capacity(71);
17919    value.push_str("sha256:");
17920    for byte in digest(&SHA256, &encoded).as_ref() {
17921        use std::fmt::Write as _;
17922        write!(&mut value, "{byte:02x}").expect("writing to a String cannot fail");
17923    }
17924    Ok(value)
17925}
17926
17927fn native_session_preview_failure(error: NativeSessionPreviewError) -> NodeFailure {
17928    let code = match error {
17929        NativeSessionPreviewError::InvalidLimit => NodeFailureCode::InvalidRequest,
17930        NativeSessionPreviewError::WorkspaceUnavailable => NodeFailureCode::UnknownWorkspace,
17931        NativeSessionPreviewError::StaleCatalog => NodeFailureCode::StaleNativeSessionCatalog,
17932        NativeSessionPreviewError::UnsupportedProvider
17933        | NativeSessionPreviewError::PreviewUnavailable
17934        | NativeSessionPreviewError::SessionNotFound
17935        | NativeSessionPreviewError::AmbiguousSession => {
17936            NodeFailureCode::BackendOperationFailed
17937        }
17938    };
17939    failure(code, "native session preview failed")
17940}
17941
17942fn host_directory_failure(error: HostDirectoryBrowseError) -> NodeFailure {
17943    let code = match error.kind() {
17944        HostDirectoryBrowseErrorKind::Invalid => NodeFailureCode::HostDirectoryInvalid,
17945        HostDirectoryBrowseErrorKind::ReadFailed => NodeFailureCode::HostDirectoryReadFailed,
17946    };
17947    failure(code, "host directory browse failed")
17948}
17949
17950fn delivery_failure(error: DeliveryStoreError) -> NodeFailure {
17951    let code = match error {
17952        DeliveryStoreError::InvalidManifest => NodeFailureCode::DeliveryManifestInvalid,
17953        DeliveryStoreError::UnknownStage => NodeFailureCode::UnknownDeliveryStage,
17954        DeliveryStoreError::Capacity | DeliveryStoreError::StageConflict => {
17955            NodeFailureCode::DeliveryStageConflict
17956        }
17957        DeliveryStoreError::UnexpectedBlob => NodeFailureCode::DeliveryBlobUnexpected,
17958        DeliveryStoreError::ChunkOutOfOrder | DeliveryStoreError::ChunkOverflow => {
17959            NodeFailureCode::DeliveryChunkOutOfOrder
17960        }
17961        DeliveryStoreError::BlobDigestMismatch => {
17962            NodeFailureCode::DeliveryBlobDigestMismatch
17963        }
17964        DeliveryStoreError::BundleDigestMismatch => {
17965            NodeFailureCode::DeliveryBundleDigestMismatch
17966        }
17967        DeliveryStoreError::StageIncomplete => NodeFailureCode::DeliveryStageIncomplete,
17968        DeliveryStoreError::Unavailable
17969        | DeliveryStoreError::Corrupt
17970        | DeliveryStoreError::Storage(_) => NodeFailureCode::DeliveryStageStorageFailed,
17971    };
17972    failure(code, "delivery operation failed")
17973}
17974
17975fn validate_workspace_root(
17976    workspace_id: &WorkspaceId,
17977    canonical_root: &str,
17978) -> Result<(), NodeServerError> {
17979    #[cfg(windows)]
17980    if canonical_root.starts_with(r"\\") {
17981        return Err(NodeServerError::InvalidWorkspaceRoot {
17982            workspace_id: workspace_id.clone(),
17983            path: canonical_root.to_owned(),
17984            message: "UNC workspace roots are unsupported by Windows PTY providers".to_owned(),
17985        });
17986    }
17987    if !platform::workspace_root_supported(canonical_root)
17988        || canonical_root.len() > WORKING_DIRECTORY_MAX_BYTES
17989    {
17990        return Err(NodeServerError::InvalidWorkspaceRoot {
17991            workspace_id: workspace_id.clone(),
17992            path: canonical_root.to_owned(),
17993            message: "canonical path must be absolute, bounded, and free of control characters"
17994                .to_owned(),
17995        });
17996    }
17997    Ok(())
17998}
17999
18000fn failure(code: NodeFailureCode, _message: &str) -> NodeFailure {
18001    NodeFailure {
18002        code,
18003        message: node_failure_category(code).to_owned(),
18004    }
18005}
18006
18007fn harness_mcp_failure(error: HarnessMcpProxyError) -> NodeFailure {
18008    let code = match error {
18009        HarnessMcpProxyError::Unavailable => NodeFailureCode::HarnessMcpUnavailable,
18010        HarnessMcpProxyError::NotFound => NodeFailureCode::ReservationNotFound,
18011        HarnessMcpProxyError::Conflict => NodeFailureCode::ReservationConflict,
18012        HarnessMcpProxyError::Expired => NodeFailureCode::ReservationExpired,
18013        HarnessMcpProxyError::BindingMismatch => NodeFailureCode::BindingMismatch,
18014        HarnessMcpProxyError::NotActivated => NodeFailureCode::NotActivated,
18015        HarnessMcpProxyError::CallNotFound => NodeFailureCode::CallNotFound,
18016        HarnessMcpProxyError::ChunkOutOfOrder => NodeFailureCode::ChunkOutOfOrder,
18017        HarnessMcpProxyError::ResponseTooLarge => NodeFailureCode::ResponseTooLarge,
18018    };
18019    failure(code, "harness MCP proxy operation failed")
18020}
18021
18022/// Numbers each harness-MCP overlay this process constructs, in
18023/// construction order, so two overlays built back to back in one node process
18024/// still get distinct trace files.
18025static NEXT_HARNESS_MCP_TRACE_SEQUENCE: AtomicU64 = AtomicU64::new(1);
18026
18027/// Builds the optional trace environment entry of one harness-MCP overlay.
18028/// The caller's launch description names both the entry and the node-process
18029/// variable that opts in: when that variable holds a non-empty directory, the
18030/// entry names a trace file under it. Returns `None` when the launch has no
18031/// trace, or the directory is unset, empty, or not valid Unicode. Never creates
18032/// the directory; that is the operator's job.
18033fn harness_mcp_trace_env_entry(launch: &HarnessMcpLaunchV1) -> Option<(OsString, OsString)> {
18034    let trace = launch.trace.as_ref()?;
18035    let trace_dir = std::env::var(&trace.dir_env).ok()?;
18036    if trace_dir.is_empty() {
18037        return None;
18038    }
18039    let sequence = NEXT_HARNESS_MCP_TRACE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
18040    let path = std::path::Path::new(&trace_dir).join(format!("harness-mcp-{sequence}.trace"));
18041    Some((OsString::from(trace.env.as_str()), path.into_os_string()))
18042}
18043
18044fn spawn_deadline_remaining(deadline: Instant) -> Result<Duration, NodeFailure> {
18045    let remaining = deadline.saturating_duration_since(Instant::now());
18046    if remaining.is_zero() {
18047        Err(failure(
18048            NodeFailureCode::SpawnDeadlineExceeded,
18049            "spawn deadline elapsed",
18050        ))
18051    } else {
18052        Ok(remaining)
18053    }
18054}
18055
18056fn spawn_dispatch_timeout(deadline: Option<Instant>) -> Result<Duration, NodeFailure> {
18057    let bounded = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
18058    match deadline {
18059        Some(deadline) => Ok(spawn_deadline_remaining(deadline)?.min(bounded)),
18060        None => Ok(bounded),
18061    }
18062}
18063
18064fn spawn_dispatch_error(error: NodeFailure, deadline: Option<Instant>) -> NodeFailure {
18065    if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
18066        failure(
18067            NodeFailureCode::SpawnDeadlineExceeded,
18068            "spawn dispatch exceeded the spawn deadline",
18069        )
18070    } else {
18071        error
18072    }
18073}
18074
18075fn spawn_runtime_capabilities(
18076    capabilities: &SpawnRequiredCapabilities,
18077) -> Result<Vec<ProviderRuntimeCapability>, NodeFailure> {
18078    capabilities
18079        .iter()
18080        .map(|capability| match capability.as_str() {
18081            SPAWN_RUNTIME_RAW_PTY_LIFECYCLE => Ok(ProviderRuntimeCapability::RawPtyLifecycle),
18082            SPAWN_RUNTIME_SEMANTIC_READINESS => Ok(ProviderRuntimeCapability::SemanticReadiness),
18083            SPAWN_RUNTIME_STRUCTURED_PROMPT => Ok(ProviderRuntimeCapability::StructuredPrompt),
18084            SPAWN_RUNTIME_PROVIDER_SESSION_IDENTITY => {
18085                Ok(ProviderRuntimeCapability::ProviderSessionIdentity)
18086            }
18087            SPAWN_RUNTIME_SEMANTIC_RESUME => Ok(ProviderRuntimeCapability::SemanticResume),
18088            _ => Err(failure(
18089                NodeFailureCode::UnsupportedSpawnCapability,
18090                "spawn required capability is unknown",
18091            )),
18092        })
18093        .collect()
18094}
18095
18096fn persistence_failure(_error: io::Error) -> NodeFailure {
18097    NodeFailure {
18098        code: NodeFailureCode::BackendOperationFailed,
18099        message: DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned(),
18100    }
18101}
18102
18103fn node_failure_category(code: NodeFailureCode) -> &'static str {
18104    match code {
18105        NodeFailureCode::InvalidRequest => "invalid-request",
18106        NodeFailureCode::UnsupportedCapability => "unsupported-capability",
18107        NodeFailureCode::HarnessMcpUnavailable => "harness-mcp-unavailable",
18108        NodeFailureCode::ReservationNotFound => "reservation-not-found",
18109        NodeFailureCode::ReservationConflict => "reservation-conflict",
18110        NodeFailureCode::ReservationExpired => "reservation-expired",
18111        NodeFailureCode::BindingMismatch => "binding-mismatch",
18112        NodeFailureCode::NotActivated => "not-activated",
18113        NodeFailureCode::CallNotFound => "call-not-found",
18114        NodeFailureCode::ChunkOutOfOrder => "chunk-out-of-order",
18115        NodeFailureCode::ResponseTooLarge => "response-too-large",
18116        NodeFailureCode::DeliveryManifestInvalid => "delivery-manifest-invalid",
18117        NodeFailureCode::UnknownDeliveryStage => "unknown-delivery-stage",
18118        NodeFailureCode::DeliveryStageConflict => "delivery-stage-conflict",
18119        NodeFailureCode::DeliveryBlobUnexpected => "delivery-blob-unexpected",
18120        NodeFailureCode::DeliveryChunkOutOfOrder => "delivery-chunk-out-of-order",
18121        NodeFailureCode::DeliveryBlobDigestMismatch => "delivery-blob-digest-mismatch",
18122        NodeFailureCode::DeliveryBundleDigestMismatch => "delivery-bundle-digest-mismatch",
18123        NodeFailureCode::DeliveryStageIncomplete => "delivery-stage-incomplete",
18124        NodeFailureCode::DeliveryStageStorageFailed => "delivery-stage-storage-failed",
18125        NodeFailureCode::Unauthorized => "unauthorized",
18126        NodeFailureCode::ObserverReadOnly => "observer-read-only",
18127        NodeFailureCode::ControllerBusy => "controller-busy",
18128        NodeFailureCode::ControllerRequired => "controller-required",
18129        NodeFailureCode::UnknownWorkspace => "unknown-workspace",
18130        NodeFailureCode::HostDirectoryInvalid => "host-directory-invalid",
18131        NodeFailureCode::HostDirectoryReadFailed => "host-directory-read-failed",
18132        NodeFailureCode::HostDirectoryReadTimedOut => "host-directory-read-timed-out",
18133        NodeFailureCode::InvalidRepositoryPath => "invalid-repository-path",
18134        NodeFailureCode::RepositoryFileNotFound => "repository-file-not-found",
18135        NodeFailureCode::RepositoryFileNotRegular => "repository-file-not-regular",
18136        NodeFailureCode::RepositoryPathUnsafe => "repository-path-unsafe",
18137        NodeFailureCode::RepositoryFileReadTimedOut => "repository-file-read-timed-out",
18138        NodeFailureCode::RepositoryFileReadFailed => "repository-file-read-failed",
18139        NodeFailureCode::RepositoryFileWriteTimedOut => "repository-file-write-timed-out",
18140        NodeFailureCode::RepositoryFileWriteFailed => "repository-file-write-failed",
18141        NodeFailureCode::RepositoryFileRevisionConflict => "repository-file-revision-conflict",
18142        NodeFailureCode::RepositoryEntryAlreadyExists => "repository-entry-already-exists",
18143        NodeFailureCode::RepositoryParentNotFound => "repository-parent-not-found",
18144        NodeFailureCode::RepositoryParentNotDirectory => "repository-parent-not-directory",
18145        NodeFailureCode::RepositoryEntryCreateTimedOut => "repository-entry-create-timed-out",
18146        NodeFailureCode::RepositoryEntryCreateFailed => "repository-entry-create-failed",
18147        NodeFailureCode::GitReadTimedOut => "git-read-timed-out",
18148        NodeFailureCode::GitReadFailed => "git-read-failed",
18149        NodeFailureCode::UnknownSpawnProfile => "unknown-spawn-profile",
18150        NodeFailureCode::SpawnProfileRevisionMismatch => "spawn-profile-revision-mismatch",
18151        NodeFailureCode::SpawnTargetMismatch => "spawn-target-mismatch",
18152        NodeFailureCode::SpawnIdempotencyConflict => "spawn-idempotency-conflict",
18153        NodeFailureCode::SpawnIdempotencyCapacity => "spawn-idempotency-capacity",
18154        NodeFailureCode::SpawnDeadlineExceeded => "spawn-deadline-exceeded",
18155        NodeFailureCode::UnsupportedSpawnCapability => "unsupported-spawn-capability",
18156        NodeFailureCode::UnsupportedTransport => "unsupported-transport",
18157        NodeFailureCode::TurnInFlight => "turn-in-flight",
18158        NodeFailureCode::UnknownEnvironmentProfile => "unknown-environment-profile",
18159        NodeFailureCode::UnknownNetworkAllowlist => "unknown-network-allowlist",
18160        NodeFailureCode::UnsupportedNetworkAllowlistMapping => {
18161            "unsupported-network-allowlist-mapping"
18162        }
18163        NodeFailureCode::BrowserStationProbeUnavailable => {
18164            "browser-station-probe-unavailable"
18165        }
18166        NodeFailureCode::BrowserStationUnreachable => "browser-station-unreachable",
18167        NodeFailureCode::BrowserStationProfileBusy => "browser-station-profile-busy",
18168        NodeFailureCode::EnvironmentProfileBindingMismatch => {
18169            "environment-profile-binding-mismatch"
18170        }
18171        NodeFailureCode::UnknownBundle => "unknown-bundle",
18172        NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
18173        NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
18174        NodeFailureCode::UnknownContextPack => "unknown-context-pack",
18175        NodeFailureCode::ContextPackBusy => "context-pack-busy",
18176        NodeFailureCode::ContextPackMaterializationFailed => {
18177            "context-pack-materialization-failed"
18178        }
18179        NodeFailureCode::InvalidWorkspaceRoot => "invalid-workspace-root",
18180        NodeFailureCode::DuplicateWorkspaceId => "duplicate-workspace-id",
18181        NodeFailureCode::DuplicateWorkspaceRoot => "duplicate-workspace-root",
18182        NodeFailureCode::WorkspaceBusy => "workspace-busy",
18183        NodeFailureCode::LastWorkspace => "last-workspace",
18184        NodeFailureCode::NotGitRepository => "not-git-repository",
18185        NodeFailureCode::WorktreeConflict => "worktree-conflict",
18186        NodeFailureCode::WorktreeProtected => "worktree-protected",
18187        NodeFailureCode::WorktreeDirty => "worktree-dirty",
18188        NodeFailureCode::WorktreeLocked => "worktree-locked",
18189        NodeFailureCode::UnknownManagedWorktreeLease => "unknown-managed-worktree-lease",
18190        NodeFailureCode::ManagedWorktreeBusy => "managed-worktree-busy",
18191        NodeFailureCode::ManagedWorktreeOwnershipConflict => "managed-worktree-ownership-conflict",
18192        NodeFailureCode::ManagedWorktreeProfileRevisionMismatch => {
18193            "managed-worktree-profile-revision-mismatch"
18194        }
18195        NodeFailureCode::ManagedWorktreeRecoveryRequired => "managed-worktree-recovery-required",
18196        NodeFailureCode::StandaloneWorkspaceRecoveryRequired => {
18197            "standalone-workspace-recovery-required"
18198        }
18199        NodeFailureCode::UnknownSession => "unknown-session",
18200        NodeFailureCode::UnknownSessionRecord => "unknown-session-record",
18201        NodeFailureCode::SessionRecordNotResumable => "session-record-not-resumable",
18202        NodeFailureCode::SessionRecordBusy => "session-record-busy",
18203        NodeFailureCode::SessionRecordConflict => "session-record-conflict",
18204        NodeFailureCode::SessionWorkspaceMismatch => "session-workspace-mismatch",
18205        NodeFailureCode::WorkspaceRegistrationRequired => {
18206            "workspace-registration-required"
18207        }
18208        NodeFailureCode::StaleNativeSessionCatalog => "stale-native-session-catalog",
18209        NodeFailureCode::StaleGeneration => "stale-generation",
18210        NodeFailureCode::BackendBusy => "backend-busy",
18211        NodeFailureCode::BackendDisconnected => "backend-disconnected",
18212        NodeFailureCode::BackendOperationFailed => "backend-operation-failed",
18213        NodeFailureCode::ShuttingDown => "shutting-down",
18214    }
18215}
18216
18217fn durable_state_server_error(error: io::Error, category: &'static str) -> NodeServerError {
18218    NodeServerError::DurableState(io::Error::new(error.kind(), category))
18219}
18220
18221fn durable_state_load_error(error: io::Error) -> NodeServerError {
18222    let category = match session_registry::state_load_refusal(&error) {
18223        Some(session_registry::StateLoadRefusal::UnsupportedSchema) => {
18224            DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR
18225        }
18226        Some(session_registry::StateLoadRefusal::PathSemanticsUnsupported) => {
18227            DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR
18228        }
18229        Some(session_registry::StateLoadRefusal::NodeIdentityMismatch) => {
18230            DURABLE_STATE_CONFLICT_ERROR
18231        }
18232        _ => DURABLE_STATE_LOAD_FAILED_ERROR,
18233    };
18234    durable_state_server_error(error, category)
18235}
18236
18237fn unix_time_ms() -> u64 {
18238    std::time::SystemTime::now()
18239        .duration_since(std::time::UNIX_EPOCH)
18240        .unwrap_or_default()
18241        .as_millis()
18242        .min(u64::MAX as u128) as u64
18243}
18244
18245fn detached_record_state(record: &ManagedSessionRecord) -> ManagedSessionState {
18246    if record.provider_session.is_some() {
18247        ManagedSessionState::Dormant
18248    } else {
18249        ManagedSessionState::Unavailable
18250    }
18251}
18252
18253fn active_record_state(
18254    identity_admitted: bool,
18255    provider_session_is_known: bool,
18256) -> ManagedSessionState {
18257    if identity_admitted && !provider_session_is_known {
18258        ManagedSessionState::IdentityPending
18259    } else {
18260        ManagedSessionState::Live
18261    }
18262}
18263
18264fn managed_worktree_record_holders(
18265    records: &[ManagedSessionRecord],
18266) -> Vec<(SessionRecordId, WorkspaceId)> {
18267    records
18268        .iter()
18269        .filter(|record| record.provider_session.is_some())
18270        .map(|record| (record.record_id.clone(), record.workspace_id.clone()))
18271        .collect()
18272}
18273
18274fn managed_spawn_record_ownership(
18275    binding: &SessionBinding,
18276    effective_runtime_policy: ProviderRuntimePolicy,
18277) -> (Option<SessionRecordId>, Option<SessionRecordId>) {
18278    debug_assert_eq!(binding.runtime_policy, effective_runtime_policy);
18279    if effective_runtime_policy.provider_session_identity {
18280        (binding.record_id.clone(), None)
18281    } else {
18282        (None, binding.record_id.clone())
18283    }
18284}
18285
18286#[derive(Debug, Error)]
18287pub enum NodeServerError {
18288    #[error("node endpoint must be a bounded absolute local transport path")]
18289    InvalidEndpoint,
18290    #[error("node access token must contain 1..=4096 bytes")]
18291    InvalidAccessToken,
18292    #[error("node HTTP observer must listen on a loopback address: {0}")]
18293    InvalidApiListen(std::net::SocketAddr),
18294    #[error("node browser bridge must listen on a loopback address: {0}")]
18295    InvalidBridgeListen(std::net::SocketAddr),
18296    #[error("node browser bridge token must contain 1..=4096 bytes")]
18297    InvalidBridgeToken,
18298    #[error("bridge underlay requires --bridge-listen (local HTTP+WS door)")]
18299    BridgeUnderlayRequiresBridgeListen,
18300    #[error("bridge underlay requires GATE4AGENT_BRIDGE_TOKEN (application barrier)")]
18301    BridgeUnderlayRequiresBridgeToken,
18302    #[error("mesh underlay auth token must contain 1..=4096 bytes")]
18303    InvalidBridgeUnderlayToken,
18304    #[error("mesh underlay transport key must be 32 bytes (64 hex digits)")]
18305    InvalidBridgeUnderlayKey,
18306    #[error("node call-home relay must be loopback or a concrete C2 tunnel address with a nonzero port: {0}")]
18307    InvalidCallHome(std::net::SocketAddr),
18308    #[error("node could not reach its call-home relay: {0}")]
18309    CallHomeTransport(#[source] std::io::Error),
18310    #[error("node durable state path must be an absolute file path: {0}")]
18311    InvalidStatePath(String),
18312    #[error("harness MCP helper must be an exact reviewed absolute regular file")]
18313    InvalidHarnessMcpHelper,
18314    #[error("network allowlist catalog path must be an absolute regular file: {0}")]
18315    InvalidNetworkAllowlistCatalogPath(String),
18316    #[error("network allowlist catalog could not be read: {0}")]
18317    NetworkAllowlistCatalogIo(#[source] std::io::Error),
18318    #[error("network allowlist catalog line {line}: {message}")]
18319    InvalidNetworkAllowlistCatalogEntry { line: usize, message: String },
18320    #[error("network allowlist catalog: {0}")]
18321    InvalidNetworkAllowlistCatalog(String),
18322    #[error("network allowlist catalog exceeds the {max}-entry limit")]
18323    NetworkAllowlistCatalogCapacity { max: usize },
18324    #[error("the local state directory is unavailable; supply an explicit state path")]
18325    LocalStateDirectoryUnavailable,
18326    #[error("the local runtime directory is unavailable; supply an explicit endpoint")]
18327    LocalRuntimeDirectoryUnavailable,
18328    #[error("node durable state failed: {0}")]
18329    DurableState(io::Error),
18330    #[error("node requires at least one configured workspace")]
18331    NoWorkspaces,
18332    #[error("workspace '{workspace_id}' root '{path}' is invalid: {message}")]
18333    InvalidWorkspaceRoot {
18334        workspace_id: WorkspaceId,
18335        path: String,
18336        message: String,
18337    },
18338    #[error("duplicate workspace ID: {0}")]
18339    DuplicateWorkspaceId(WorkspaceId),
18340    #[error("workspaces '{first}' and '{second}' resolve to the same root '{root}'")]
18341    DuplicateWorkspaceRoot {
18342        first: WorkspaceId,
18343        second: WorkspaceId,
18344        root: String,
18345    },
18346    #[error("workspace '{0}' uses managed worktrees but has no local profile")]
18347    ManagedWorktreeProfileRequired(WorkspaceId),
18348    #[error("workspace '{0}' has managed worktree profiles but is not in managed mode")]
18349    ManagedWorktreeProfileModeMismatch(WorkspaceId),
18350    #[error("workspace '{workspace_id}' has duplicate managed worktree profile '{profile_id}'")]
18351    DuplicateManagedWorktreeProfile {
18352        workspace_id: WorkspaceId,
18353        profile_id: WorktreeProfileId,
18354    },
18355    #[error("workspace '{workspace_id}' exceeds the {max}-profile managed worktree limit")]
18356    ManagedWorktreeProfileCapacity {
18357        workspace_id: WorkspaceId,
18358        max: usize,
18359    },
18360    #[error("workspace '{workspace_id}' managed worktree profile '{profile_id}' is invalid: {message}")]
18361    InvalidManagedWorktreeProfile {
18362        workspace_id: WorkspaceId,
18363        profile_id: WorktreeProfileId,
18364        message: String,
18365    },
18366    #[error("duplicate node environment profile: {0}")]
18367    DuplicateEnvironmentProfile(SpawnEnvironmentProfileId),
18368    #[error("node environment profile provider is unavailable: {0}")]
18369    EnvironmentProfileProviderUnavailable(AgentId),
18370    #[error("node environment profile capacity is {max}")]
18371    EnvironmentProfileCapacity { max: usize },
18372    #[error("node environment profiles reuse native profile ID '{0}'")]
18373    DuplicateNativeEnvironmentProfile(String),
18374    #[error("node native environment profile is invalid: {0}")]
18375    NativeEnvironmentProfile(String),
18376    #[error("node session-environment materialization root must be absolute")]
18377    InvalidSessionEnvironmentRoot,
18378    #[error("node session-environment materializer is unavailable")]
18379    SessionEnvironmentMaterializerRequired,
18380    #[error("node session-environment materializer failed to initialize")]
18381    SessionEnvironmentMaterializer,
18382    #[error("node bundle catalog is invalid: {0}")]
18383    BundleCatalog(String),
18384    #[error("node delivery store failed to initialize: {0}")]
18385    DeliveryStore(String),
18386    #[error("node context pack store failed to initialize")]
18387    ContextPackStore,
18388    #[error("active agent registry failed: {0}")]
18389    Registry(String),
18390    #[error("node provider contract manifest is invalid: {0}")]
18391    ProviderContractManifest(String),
18392    #[error("named pipe I/O failed: {0}")]
18393    Io(#[from] io::Error),
18394    #[error("node HTTP observer failed: {0}")]
18395    HttpApi(io::Error),
18396    #[error("node browser bridge failed: {0}")]
18397    BridgeApi(io::Error),
18398    #[error(transparent)]
18399    Frame(#[from] FrameError),
18400    #[error("node handshake failed: {0}")]
18401    Handshake(String),
18402    #[error("node authentication frame was not received before the bounded deadline")]
18403    AuthenticationTimedOut,
18404    #[error("node authentication primitive failed: {0}")]
18405    Authentication(String),
18406    #[error("node incarnation identity generation failed: {0}")]
18407    IncarnationIdentity(String),
18408    #[error("node pre-authentication limiter is closed")]
18409    PreauthClosed,
18410    #[error("node authenticated connection limit was reached")]
18411    AuthenticatedConnectionLimit,
18412    #[error("node Ctrl+C/CtrlBreak signal stream closed")]
18413    SignalStreamClosed,
18414    #[error("node stop-all dispatch failed: {0:?}")]
18415    ShutdownDispatch(NodeFailure),
18416    #[error("node shutdown timed out with {active_native_sessions} physical sessions retained")]
18417    ShutdownTimedOut { active_native_sessions: usize },
18418}
18419
18420impl From<crate::network_allowlist_catalog::NetworkAllowlistCatalogError> for NodeServerError {
18421    fn from(error: crate::network_allowlist_catalog::NetworkAllowlistCatalogError) -> Self {
18422        use crate::network_allowlist_catalog::NetworkAllowlistCatalogError as E;
18423        match error {
18424            E::InvalidPath(path) => Self::InvalidNetworkAllowlistCatalogPath(path),
18425            E::Io(error) => Self::NetworkAllowlistCatalogIo(error),
18426            E::InvalidEntry { line, message } => {
18427                Self::InvalidNetworkAllowlistCatalogEntry { line, message }
18428            }
18429            E::InvalidCatalog(message) => Self::InvalidNetworkAllowlistCatalog(message),
18430            E::Capacity { max } => Self::NetworkAllowlistCatalogCapacity { max },
18431        }
18432    }
18433}
18434
18435#[cfg(test)]
18436mod agent_stream_blocked_tests {
18437    use super::*;
18438    use gate4agent_types::{
18439        AdapterId, AdapterVerification, HostRequestOutcome as ProviderHostRequestOutcome,
18440        ProviderSource,
18441    };
18442
18443    fn provider_source(family: AdapterFamily, adapter: &str) -> ProviderSource {
18444        ProviderSource {
18445            family,
18446            binding: AdapterBinding::new(
18447                AdapterId::new(adapter).unwrap(),
18448                "fixture/v1",
18449                AdapterVerification::SyntheticFixture,
18450            )
18451            .unwrap(),
18452        }
18453    }
18454
18455    fn provider_control_event_at(
18456        family: AdapterFamily,
18457        adapter: &str,
18458        source_sequence: u64,
18459        provider_event: ProviderEvent,
18460    ) -> ControlEvent {
18461        ControlEvent {
18462            sequence: 41,
18463            command_id: None,
18464            instance_id: AgentInstanceId(7),
18465            generation: SessionGeneration(3),
18466            event: ControlEventKind::ProviderEvent {
18467                sequence: 8,
18468                source: provider_source(family, adapter),
18469                source_sequence,
18470                event: provider_event,
18471            },
18472        }
18473    }
18474
18475    fn provider_control_event(family: AdapterFamily, provider_event: ProviderEvent) -> ControlEvent {
18476        provider_control_event_at(family, "fixture", 9, provider_event)
18477    }
18478
18479    fn denied_host_request(
18480        by: ProviderHostDecisionAuthority,
18481        reason: Option<String>,
18482    ) -> ControlEvent {
18483        provider_control_event_at(
18484            AdapterFamily::Acp,
18485            "claude-code",
18486            2,
18487            ProviderEvent::HostRequestObserved {
18488                method: "terminal/create".to_owned(),
18489                params_json: String::new(),
18490                decision: ProviderHostRequestDecision::Denied { by },
18491                outcome: ProviderHostRequestOutcome::Executed,
18492                reason,
18493            },
18494        )
18495    }
18496
18497    fn blocked(
18498        event: &ControlEvent,
18499    ) -> (
18500        Option<String>,
18501        String,
18502        BlockAuthorityV1,
18503        Option<String>,
18504        String,
18505        Option<String>,
18506    ) {
18507        let chunk = agent_stream_chunk(event).expect("expected a Blocked chunk");
18508        chunk.validate().expect("minted chunk must validate");
18509        let AgentStreamChunkKindV1::Blocked {
18510            correlation_id,
18511            tool_class,
18512            authority,
18513            reason_kind,
18514            reason,
18515            help,
18516        } = chunk.kind
18517        else {
18518            panic!("expected AgentStreamChunkKindV1::Blocked");
18519        };
18520        (correlation_id, tool_class, authority, reason_kind, reason, help)
18521    }
18522
18523    /// Pins the fleet and every adapter each member declares: the fleet is
18524    /// exactly what `active_registry` says, and no member declares a hook
18525    /// adapter (lifecycle hooks are retired).
18526    #[test]
18527    fn the_enabled_provider_matrix_is_exact() {
18528        let registry = active_registry().unwrap();
18529        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
18530        assert_eq!(
18531            providers
18532                .iter()
18533                .map(|contract| contract.provider.as_str())
18534                .collect::<Vec<_>>(),
18535            // `shell` is the plain PTY provider (operator terminals and smoke tests).
18536            vec!["shell", "claude", "codex", "grok", "kimi"],
18537        );
18538        assert!(adapters.iter().all(|contract| {
18539            !matches!(contract.family, AdapterFamily::Hook | AdapterFamily::ManagedHook)
18540        }));
18541    }
18542
18543    /// A gate-denied host request, WITH its reason text carried all the way
18544    /// from the ACP host, mints a `Blocked` chunk naming the gate.
18545    #[test]
18546    fn gate_denied_host_request_mints_a_blocked_chunk() {
18547        let gate_text =
18548            "blocked by dangerous-command gate: rule=filesystem-wipe, argument=rm -rf /";
18549        let event = denied_host_request(
18550            ProviderHostDecisionAuthority::Gate,
18551            Some(gate_text.to_owned()),
18552        );
18553        let chunk = agent_stream_chunk(&event).expect("expected a Blocked chunk");
18554        assert_eq!(chunk.source_sequence, 2);
18555        let (correlation_id, tool_class, authority, reason_kind, reason, help) = blocked(&event);
18556        assert_eq!(authority, BlockAuthorityV1::HostGate);
18557        assert_eq!(reason, gate_text);
18558        assert_eq!(correlation_id, None);
18559        assert_eq!(reason_kind, None);
18560        assert_eq!(help, None);
18561        assert_eq!(tool_class, "Write");
18562    }
18563
18564    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.";
18565
18566    /// The owner's classifier sample, verbatim, arriving as a
18567    /// `tool_call_update`-derived `ProviderEvent::ToolCompleted` with
18568    /// `non_execution_kind: "permission-rule"` -- mints a `Blocked` chunk with
18569    /// `authority: ProviderPermissionRule`, the `Reason:` sentence as `reason`
18570    /// and the guidance tail as `help`.
18571    #[test]
18572    fn classifier_sample_permission_rule_mints_a_blocked_chunk() {
18573        let event = provider_control_event(
18574            AdapterFamily::Acp,
18575            ProviderEvent::ToolCompleted {
18576                id: "toolu_9".to_owned(),
18577                output: CLASSIFIER_SAMPLE.to_owned(),
18578                is_error: true,
18579                duration_ms: None,
18580                agent_id: None,
18581                non_execution_kind: Some("permission-rule".to_owned()),
18582            },
18583        );
18584        let (correlation_id, _, authority, reason_kind, reason, help) = blocked(&event);
18585        assert_eq!(authority, BlockAuthorityV1::ProviderPermissionRule);
18586        assert_eq!(reason, "Blocked by classifier");
18587        assert_eq!(reason_kind, Some("permission-rule".to_owned()));
18588        assert!(help.as_deref().is_some_and(|help| help.starts_with("If you have other tasks")));
18589        assert!(correlation_id.is_some(), "a tool-correlated block must carry a correlation id");
18590    }
18591
18592    /// A `session/prompt` RPC error with `data.codexErrorInfo:
18593    /// "usageLimitExceeded"` (measured live 2026-09-05) mints a `Blocked` chunk
18594    /// with `authority: ProviderQuota`, the message verbatim as `reason` and the
18595    /// vendor code as `reason_kind`.
18596    #[test]
18597    fn codex_quota_rpc_error_mints_a_blocked_chunk() {
18598        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.";
18599        let event = provider_control_event(
18600            AdapterFamily::Acp,
18601            ProviderEvent::SessionEnded {
18602                result: "JSON-RPC error -32603: Internal error".to_owned(),
18603                cost_usd: None,
18604                is_error: true,
18605                stop_reason: Some(ProviderStopReason::ProviderError {
18606                    code: -32603,
18607                    message: message.to_owned(),
18608                    vendor_code: Some("usageLimitExceeded".to_owned()),
18609                }),
18610            },
18611        );
18612        let (_, tool_class, authority, reason_kind, reason, _) = blocked(&event);
18613        assert_eq!(authority, BlockAuthorityV1::ProviderQuota);
18614        assert_eq!(reason, message);
18615        assert_eq!(reason_kind, Some("usageLimitExceeded".to_owned()));
18616        assert_eq!(tool_class, "Session");
18617    }
18618
18619    /// ACP's `stopReason: "refusal"` mints a `Blocked` chunk with
18620    /// `authority: ProviderRefusal`.
18621    #[test]
18622    fn session_end_stop_reason_refusal_mints_a_blocked_chunk() {
18623        let event = provider_control_event(
18624            AdapterFamily::Acp,
18625            ProviderEvent::SessionEnded {
18626                result: "refusal".to_owned(),
18627                cost_usd: None,
18628                is_error: true,
18629                stop_reason: Some(ProviderStopReason::Refusal),
18630            },
18631        );
18632        let (_, _, authority, _, _, _) = blocked(&event);
18633        assert_eq!(authority, BlockAuthorityV1::ProviderRefusal);
18634    }
18635
18636    /// A failed tool completion with no typed `non_execution_kind` -- or one
18637    /// that only says the turn stopped -- is a plain execution failure, never a
18638    /// block; nor is an agent's own narrative claiming one.
18639    #[test]
18640    fn failures_and_narrative_without_a_typed_block_mint_no_blocked_chunk() {
18641        for non_execution_kind in [None, Some("interrupted".to_owned()), Some("cancelled".to_owned())] {
18642            let event = provider_control_event(
18643                AdapterFamily::Acp,
18644                ProviderEvent::ToolCompleted {
18645                    id: "toolu_1".to_owned(),
18646                    output: "command exited with status 1".to_owned(),
18647                    is_error: true,
18648                    duration_ms: Some(12),
18649                    agent_id: None,
18650                    non_execution_kind,
18651                },
18652            );
18653            assert!(agent_stream_chunk(&event).is_none());
18654        }
18655        let narrative = provider_control_event(
18656            AdapterFamily::Acp,
18657            ProviderEvent::ToolCompleted {
18658                id: "toolu_2".to_owned(),
18659                output: "the harness blocked the write, approval needed".to_owned(),
18660                is_error: false,
18661                duration_ms: Some(3),
18662                agent_id: None,
18663                non_execution_kind: None,
18664            },
18665        );
18666        assert!(agent_stream_chunk(&narrative).is_none());
18667    }
18668
18669    /// `Granted`/`Deferred` decisions never mint a `Blocked` chunk, whatever the
18670    /// execution outcome: a granted request that failed while running was
18671    /// still authorized.
18672    #[test]
18673    fn granted_and_deferred_host_requests_never_mint_a_blocked_chunk() {
18674        for (decision, outcome) in [
18675            (
18676                ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
18677                ProviderHostRequestOutcome::Executed,
18678            ),
18679            (ProviderHostRequestDecision::Deferred, ProviderHostRequestOutcome::Executed),
18680            (
18681                ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
18682                ProviderHostRequestOutcome::Failed {
18683                    error: "terminal/create spawn failed: os error 3".to_owned(),
18684                },
18685            ),
18686        ] {
18687            let event = provider_control_event_at(
18688                AdapterFamily::Acp,
18689                "claude-code",
18690                3,
18691                ProviderEvent::HostRequestObserved {
18692                    method: "fs/read_text_file".to_owned(),
18693                    params_json: String::new(),
18694                    decision: decision.clone(),
18695                    outcome,
18696                    reason: None,
18697                },
18698            );
18699            assert!(agent_stream_chunk(&event).is_none(), "{decision:?}");
18700        }
18701    }
18702
18703    /// A deferred `session/request_permission` resolved by an operator or an
18704    /// expired deadline carries no reason text -- the chunk falls back to a
18705    /// categorical sentence naming the authority rather than inventing detail.
18706    #[test]
18707    fn denied_host_request_with_no_reason_falls_back_to_a_categorical_sentence() {
18708        for (by, expected_authority, expected_reason) in [
18709            (
18710                ProviderHostDecisionAuthority::Operator,
18711                BlockAuthorityV1::Operator,
18712                "declined by the operator",
18713            ),
18714            (
18715                ProviderHostDecisionAuthority::DeadlinePolicy,
18716                BlockAuthorityV1::HostDeadline,
18717                "denied by host policy after the operator deadline expired",
18718            ),
18719        ] {
18720            let (_, _, authority, _, reason, _) = blocked(&denied_host_request(by, None));
18721            assert_eq!(authority, expected_authority);
18722            assert_eq!(reason, expected_reason);
18723        }
18724    }
18725
18726    /// An over-long reason is cut to the wire bound BEFORE the chunk is built,
18727    /// at a safe UTF-8 boundary -- `validate` rejects an over-long value
18728    /// outright rather than truncating.
18729    #[test]
18730    fn overlong_blocked_reason_is_truncated_to_the_wire_bound() {
18731        let oversized = "x".repeat(MAX_ACP_BLOCKED_REASON_BYTES + 500);
18732        let (_, _, _, _, reason, _) = blocked(&denied_host_request(
18733            ProviderHostDecisionAuthority::Policy,
18734            Some(oversized),
18735        ));
18736        assert!(reason.len() <= MAX_ACP_BLOCKED_REASON_BYTES);
18737    }
18738}
18739
18740#[cfg(test)]
18741mod standalone_capability_platform_tests {
18742    use super::*;
18743
18744    #[test]
18745    fn standalone_workspace_capability_requires_windows_identity_guards() {
18746        let capabilities = baseline_capabilities().unwrap();
18747        let advertised = capabilities.iter().any(|capability| {
18748            capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
18749        });
18750        assert_eq!(advertised, cfg!(windows));
18751
18752        let request = NodeRequest::CreateStandaloneWorkspace {
18753            workspace_id: WorkspaceId::new("standalone-platform").unwrap(),
18754            root: OpaqueHostPath::utf8("standalone-platform-root".to_owned()).unwrap(),
18755            initial_branch: None,
18756        };
18757        assert_eq!(
18758            request_uses_unnegotiated_capability(&request, &capabilities),
18759            !cfg!(windows),
18760        );
18761    }
18762}
18763
18764
18765#[cfg(test)]
18766mod station_knobs_resolve_tests {
18767    use super::*;
18768    use gate4agent_catalog::EnvMutation;
18769    use gate4agent_runtime_native::{
18770        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
18771        NativeLaunchProfileId,
18772    };
18773    use std::ffi::OsString;
18774    use std::sync::Arc;
18775
18776    struct EmptyEnvironmentResolver;
18777
18778    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
18779        fn resolve_child_environment(
18780            &self,
18781        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
18782            Ok(vec![EnvMutation {
18783                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
18784                value: None,
18785            }])
18786        }
18787    }
18788
18789    fn agent(value: &str) -> AgentId {
18790        AgentId::new(value).unwrap()
18791    }
18792
18793    fn station_knobs_fixture() -> (NodeShared, crate::protocol::SpawnProfileDefaults) {
18794        let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
18795        let profile_revision =
18796            crate::protocol::SpawnEnvironmentProfileRevision::new("local-claude-r1").unwrap();
18797        let spawn_defaults = crate::protocol::SpawnProfileDefaults {
18798            profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
18799            revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
18800            provider: agent("claude"),
18801            mode: SessionMode::Pty,
18802            terminal_size: gate4agent_types::TerminalSize {
18803                rows: 24,
18804                columns: 80,
18805            },
18806            prompt: None,
18807            bundle_id: None,
18808            context_id: None,
18809            environment_profile_id: Some(profile_id.clone()),
18810        };
18811        let spawn_profiles = SpawnProfileRegistry::new([spawn_defaults.clone()]).unwrap();
18812        let catalog = active_registry().unwrap();
18813        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18814        let control = runtime.native_launch_profile_control();
18815        let node_profile = NodeEnvironmentProfile::new(
18816            profile_id.clone(),
18817            profile_revision,
18818            agent("claude"),
18819            [NativeLaunchProfile::new(
18820                NativeLaunchProfileId::new("local-claude-pty").unwrap(),
18821                agent("claude"),
18822                TransportKind::Pty,
18823                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
18824                Arc::new(EmptyEnvironmentResolver),
18825            )
18826            .unwrap()],
18827        )
18828        .unwrap();
18829        let (binding, native_profiles, _materialization) = node_profile.into_parts();
18830        for native_profile in native_profiles {
18831            runtime.upsert_native_launch_profile(native_profile).unwrap();
18832        }
18833        let workspace = WorkspaceConfig::new(
18834            WorkspaceId::new("primary").unwrap(),
18835            std::env::current_dir().unwrap(),
18836        )
18837        .unwrap();
18838        let shared = NodeShared::new_with_incarnation(
18839            handle,
18840            "fixture-token".to_owned(),
18841            NodeId::new("node-station-knobs-test").unwrap(),
18842            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
18843            vec![workspace],
18844            vec![agent("claude")],
18845            ProviderRuntimeStatuses::default(),
18846            None,
18847            Vec::new(),
18848            Vec::new(),
18849            spawn_profiles,
18850            Some(control),
18851            None,
18852            None,
18853            None,
18854            Vec::new(),
18855            Vec::new(),
18856            Vec::new(),
18857            Vec::new(),
18858            None,
18859            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
18860        );
18861        shared
18862            .environment_profiles
18863            .write()
18864            .unwrap_or_else(|poisoned| poisoned.into_inner())
18865            .insert(binding.id.clone(), binding);
18866        (shared, spawn_defaults)
18867    }
18868
18869    /// Empty-default catalog refuses unknown network_allowlist ids.
18870    /// Feature-off dig2-station-probe keeps browser_profile stub echo.
18871    #[test]
18872    fn resolve_environment_profile_refuses_unknown_network_allowlist() {
18873        let (shared, defaults) = station_knobs_fixture();
18874        let unknown = crate::protocol::SpawnNetworkAllowlistId::new("not-registered").unwrap();
18875        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18876        let spec = crate::protocol::SpawnSpec {
18877            target: crate::protocol::SpawnTarget {
18878                node_id: shared.node_id.clone(),
18879                workspace_id: WorkspaceId::new("primary").unwrap(),
18880                worktree_id: None,
18881            },
18882            profile_id: defaults.profile_id.clone(),
18883            expected_profile_revision: defaults.revision.clone(),
18884            overrides: crate::protocol::SpawnOverrides {
18885                network_allowlist: Some(unknown),
18886                browser_profile_id: Some(browser),
18887                ..crate::protocol::SpawnOverrides::default()
18888            },
18889            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18890            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18891                "station-knobs-unknown-allowlist",
18892            )
18893            .unwrap(),
18894            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18895        };
18896        let resolved = spec.resolve(&defaults).unwrap();
18897        let failure = shared
18898            .resolve_environment_profile(&resolved)
18899            .expect_err("unknown allowlist must refuse");
18900        assert_eq!(failure.code, NodeFailureCode::UnknownNetworkAllowlist);
18901    }
18902
18903    /// Registered network_allowlist (+ browser_profile_id when feature off)
18904    /// echo onto the env-profile receipt. Empty catalog still allows omit
18905    /// (None). Empty/whitespace refuse is type-level. Feature-off
18906    /// dig2-station-probe keeps stub browser echo; feature-on refuse is
18907    /// covered by `…_refuses_browser_profile_when_station_probe_fails`.
18908    #[test]
18909    fn resolve_environment_profile_echoes_registered_station_network_and_browser_knobs() {
18910        let (shared, defaults) = station_knobs_fixture();
18911        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18912        #[cfg(not(feature = "dig2-station-probe"))]
18913        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18914        shared
18915            .network_allowlist_catalog
18916            .write()
18917            .unwrap_or_else(|poisoned| poisoned.into_inner())
18918            .insert_id(allowlist.clone())
18919            .unwrap();
18920        let spec = crate::protocol::SpawnSpec {
18921            target: crate::protocol::SpawnTarget {
18922                node_id: shared.node_id.clone(),
18923                workspace_id: WorkspaceId::new("primary").unwrap(),
18924                worktree_id: None,
18925            },
18926            profile_id: defaults.profile_id.clone(),
18927            expected_profile_revision: defaults.revision.clone(),
18928            overrides: crate::protocol::SpawnOverrides {
18929                network_allowlist: Some(allowlist.clone()),
18930                #[cfg(not(feature = "dig2-station-probe"))]
18931                browser_profile_id: Some(browser.clone()),
18932                ..crate::protocol::SpawnOverrides::default()
18933            },
18934            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18935            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-echo")
18936                .unwrap(),
18937            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18938        };
18939        let resolved = spec.resolve(&defaults).unwrap();
18940        assert_eq!(resolved.network_allowlist.as_ref(), Some(&allowlist));
18941        #[cfg(not(feature = "dig2-station-probe"))]
18942        assert_eq!(resolved.browser_profile_id.as_ref(), Some(&browser));
18943        let receipt = shared
18944            .resolve_environment_profile(&resolved)
18945            .expect("resolve ok")
18946            .expect("env profile present");
18947        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18948        #[cfg(not(feature = "dig2-station-probe"))]
18949        assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
18950        #[cfg(feature = "dig2-station-probe")]
18951        assert_eq!(receipt.browser_profile_id, None);
18952        assert_eq!(receipt.profile_id.as_str(), "local-claude");
18953
18954        let omitted = crate::protocol::SpawnSpec {
18955            overrides: crate::protocol::SpawnOverrides::default(),
18956            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-omit")
18957                .unwrap(),
18958            ..spec
18959        };
18960        let resolved_omit = omitted.resolve(&defaults).unwrap();
18961        let bare = shared
18962            .resolve_environment_profile(&resolved_omit)
18963            .expect("resolve ok")
18964            .expect("env profile present");
18965        assert_eq!(bare.network_allowlist, None);
18966        assert_eq!(bare.browser_profile_id, None);
18967    }
18968
18969    /// Feature `dig2-station-probe`: browser_profile_id set refuses when the
18970    /// cheap station probe cannot succeed. On non-Windows (this CI host) that
18971    /// is `BrowserStationProbeUnavailable`. Never cookies on C2. Lease map is
18972    /// unit-tested separately (probe refuses before lease on Linux).
18973    #[cfg(feature = "dig2-station-probe")]
18974    #[test]
18975    fn resolve_environment_profile_refuses_browser_profile_when_station_probe_fails() {
18976        let (shared, defaults) = station_knobs_fixture();
18977        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18978        let spec = crate::protocol::SpawnSpec {
18979            target: crate::protocol::SpawnTarget {
18980                node_id: shared.node_id.clone(),
18981                workspace_id: WorkspaceId::new("primary").unwrap(),
18982                worktree_id: None,
18983            },
18984            profile_id: defaults.profile_id.clone(),
18985            expected_profile_revision: defaults.revision.clone(),
18986            overrides: crate::protocol::SpawnOverrides {
18987                browser_profile_id: Some(browser),
18988                ..crate::protocol::SpawnOverrides::default()
18989            },
18990            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18991            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18992                "station-knobs-probe-refuse",
18993            )
18994            .unwrap(),
18995            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18996        };
18997        let resolved = spec.resolve(&defaults).unwrap();
18998        let failure = shared
18999            .resolve_environment_profile(&resolved)
19000            .expect_err("browser_profile with failing probe must refuse");
19001        #[cfg(windows)]
19002        {
19003            assert!(
19004                matches!(
19005                    failure.code,
19006                    NodeFailureCode::BrowserStationUnreachable
19007                        | NodeFailureCode::BrowserStationProbeUnavailable
19008                ),
19009                "unexpected code {:?}",
19010                failure.code
19011            );
19012        }
19013        #[cfg(not(windows))]
19014        {
19015            assert_eq!(
19016                failure.code,
19017                NodeFailureCode::BrowserStationProbeUnavailable
19018            );
19019        }
19020    }
19021
19022    /// Feature on: omitting browser_profile_id still resolves (no probe).
19023    #[cfg(feature = "dig2-station-probe")]
19024    #[test]
19025    fn resolve_environment_profile_skips_station_probe_when_browser_profile_omitted() {
19026        let (shared, defaults) = station_knobs_fixture();
19027        let spec = crate::protocol::SpawnSpec {
19028            target: crate::protocol::SpawnTarget {
19029                node_id: shared.node_id.clone(),
19030                workspace_id: WorkspaceId::new("primary").unwrap(),
19031                worktree_id: None,
19032            },
19033            profile_id: defaults.profile_id.clone(),
19034            expected_profile_revision: defaults.revision.clone(),
19035            overrides: crate::protocol::SpawnOverrides::default(),
19036            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19037            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19038                "station-knobs-probe-omit",
19039            )
19040            .unwrap(),
19041            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19042        };
19043        let resolved = spec.resolve(&defaults).unwrap();
19044        let receipt = shared
19045            .resolve_environment_profile(&resolved)
19046            .expect("omit browser_profile must not probe")
19047            .expect("env profile present");
19048        assert_eq!(receipt.browser_profile_id, None);
19049    }
19050
19051    /// Feature dig2-station-probe: exclusive BrowserStationLease on NodeShared
19052    /// refuses a second holder for the same opaque profile id; release on
19053    /// remove_binding allows reacquire. Never ImportSession/cookies on C2.
19054    #[cfg(feature = "dig2-station-probe")]
19055    #[test]
19056    fn browser_station_lease_exclusive_and_clears_on_remove_binding() {
19057        let (shared, _defaults) = station_knobs_fixture();
19058        let profile = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
19059        let workspace_id = WorkspaceId::new("primary").unwrap();
19060        let address_a = SessionAddress {
19061            workspace_id: workspace_id.clone(),
19062            session: SessionKey {
19063                instance_id: AgentInstanceId(101),
19064                generation: SessionGeneration(1),
19065            },
19066        };
19067        let address_b = SessionAddress {
19068            workspace_id: workspace_id.clone(),
19069            session: SessionKey {
19070                instance_id: AgentInstanceId(102),
19071                generation: SessionGeneration(1),
19072            },
19073        };
19074        let guard_a = crate::dig2_station_lease::acquire_lease(
19075            &shared.browser_station_leases,
19076            profile.clone(),
19077            address_a.session.instance_id,
19078            1,
19079        )
19080        .expect("first lease");
19081        let busy = crate::dig2_station_lease::acquire_lease(
19082            &shared.browser_station_leases,
19083            profile.clone(),
19084            address_b.session.instance_id,
19085            2,
19086        )
19087        .expect_err("second holder must refuse");
19088        assert!(matches!(
19089            busy,
19090            crate::dig2_station_lease::BrowserStationLeaseError::ProfileBusy { .. }
19091        ));
19092        // Simulate a bound session so remove_binding clears the lease.
19093        shared.bind_session_with_policy(
19094            &address_a,
19095            ProviderRuntimePolicy::raw_pty(),
19096            None,
19097        );
19098        guard_a.retain();
19099        assert!(shared.remove_binding(&address_a).is_some());
19100        crate::dig2_station_lease::acquire_lease(
19101            &shared.browser_station_leases,
19102            profile,
19103            address_b.session.instance_id,
19104            3,
19105        )
19106        .expect("reacquire after clear")
19107        .retain();
19108        assert!(
19109            crate::dig2_station_lease::release_lease_for_holder(
19110                &shared.browser_station_leases,
19111                address_b.session.instance_id,
19112            )
19113        );
19114    }
19115
19116    /// Feature-off: no browser_station_leases field; stub echo documented in
19117    /// resolve_environment_profile_echoes_registered_station_network_and_browser_knobs.
19118    #[cfg(not(feature = "dig2-station-probe"))]
19119    #[test]
19120    fn browser_station_lease_feature_off_keeps_stub_without_lease_table() {
19121        let (shared, defaults) = station_knobs_fixture();
19122        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
19123        let spec = crate::protocol::SpawnSpec {
19124            target: crate::protocol::SpawnTarget {
19125                node_id: shared.node_id.clone(),
19126                workspace_id: WorkspaceId::new("primary").unwrap(),
19127                worktree_id: None,
19128            },
19129            profile_id: defaults.profile_id.clone(),
19130            expected_profile_revision: defaults.revision.clone(),
19131            overrides: crate::protocol::SpawnOverrides {
19132                browser_profile_id: Some(browser.clone()),
19133                ..crate::protocol::SpawnOverrides::default()
19134            },
19135            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19136            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("lease-stub-off")
19137                .unwrap(),
19138            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19139        };
19140        let resolved = spec.resolve(&defaults).unwrap();
19141        let receipt = shared
19142            .resolve_environment_profile(&resolved)
19143            .expect("stub resolve")
19144            .expect("env profile");
19145        assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
19146    }
19147
19148
19149    /// Startup file loader registers ids into the empty-default catalog.
19150    /// Feature-off dig2 probe stays stub. Never secrets on C2.
19151    #[test]
19152    fn network_allowlist_catalog_loads_from_temp_file() {
19153        let unique = std::time::SystemTime::now()
19154            .duration_since(std::time::UNIX_EPOCH)
19155            .unwrap()
19156            .as_nanos();
19157        let path = std::env::temp_dir().join(format!(
19158            "gate4agent-node-allowlist-config-{}-{unique}.txt",
19159            std::process::id(),
19160        ));
19161        std::fs::write(
19162            &path,
19163            "# opaque station allowlist ids\negress-default\nlab-egress\n",
19164        )
19165        .unwrap();
19166        let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
19167            .expect("temp catalog file must load");
19168        let _ = std::fs::remove_file(&path);
19169        assert_eq!(loaded.len(), 2);
19170        let (shared, defaults) = station_knobs_fixture();
19171        {
19172            let mut catalog = shared
19173                .network_allowlist_catalog
19174                .write()
19175                .unwrap_or_else(|poisoned| poisoned.into_inner());
19176            *catalog = loaded;
19177        }
19178        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19179        let spec = crate::protocol::SpawnSpec {
19180            target: crate::protocol::SpawnTarget {
19181                node_id: shared.node_id.clone(),
19182                workspace_id: WorkspaceId::new("primary").unwrap(),
19183                worktree_id: None,
19184            },
19185            profile_id: defaults.profile_id.clone(),
19186            expected_profile_revision: defaults.revision.clone(),
19187            overrides: crate::protocol::SpawnOverrides {
19188                network_allowlist: Some(allowlist.clone()),
19189                ..crate::protocol::SpawnOverrides::default()
19190            },
19191            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19192            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19193                "station-knobs-catalog-file",
19194            )
19195            .unwrap(),
19196            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19197        };
19198        let resolved = spec.resolve(&defaults).unwrap();
19199        let receipt = shared
19200            .resolve_environment_profile(&resolved)
19201            .expect("registered via file load")
19202            .expect("env profile present");
19203        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
19204    }
19205
19206    /// Snapshot launch inventory echoes registered catalog ids (opaque).
19207    /// Dig2 station probe is orthogonal (feature `dig2-station-probe`). Never secrets on C2.
19208    #[test]
19209    fn snapshot_launch_inventory_lists_network_allowlist_catalog_ids() {
19210        let (shared, _defaults) = station_knobs_fixture();
19211        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19212        shared
19213            .network_allowlist_catalog
19214            .write()
19215            .unwrap_or_else(|poisoned| poisoned.into_inner())
19216            .insert_id(allowlist.clone())
19217            .unwrap();
19218        let snapshot = shared.snapshot();
19219        let inventory = snapshot
19220            .launch_inventory
19221            .expect("launch inventory present");
19222        let ids = inventory
19223            .network_allowlists
19224            .expect("non-empty catalog surfaces ids");
19225        assert_eq!(ids, vec![allowlist]);
19226    }
19227
19228    /// Track B: registered id with provider_native codex_network_access refuses
19229    /// clearly for Claude (fixture provider). Unknown id still UnknownNetworkAllowlist.
19230    /// Never secrets on C2. Codex Moderate maps to `-c` overlay; RO/FullAuto refuse.
19231    #[test]
19232    fn resolve_environment_profile_refuses_unsupported_provider_native_mapping() {
19233        let (shared, defaults) = station_knobs_fixture();
19234        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-only").unwrap();
19235        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19236            id: allowlist.clone(),
19237            permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
19238                protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19239                peer: "127.0.0.1:443".to_owned(),
19240            }],
19241            provider_native: Some(
19242                crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
19243                    codex_network_access: Some(true),
19244                },
19245            ),
19246        };
19247        shared
19248            .network_allowlist_catalog
19249            .write()
19250            .unwrap_or_else(|poisoned| poisoned.into_inner())
19251            .insert(entry)
19252            .unwrap();
19253        let spec = crate::protocol::SpawnSpec {
19254            target: crate::protocol::SpawnTarget {
19255                node_id: shared.node_id.clone(),
19256                workspace_id: WorkspaceId::new("primary").unwrap(),
19257                worktree_id: None,
19258            },
19259            profile_id: defaults.profile_id.clone(),
19260            expected_profile_revision: defaults.revision.clone(),
19261            overrides: crate::protocol::SpawnOverrides {
19262                network_allowlist: Some(allowlist),
19263                ..crate::protocol::SpawnOverrides::default()
19264            },
19265            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19266            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19267                "station-knobs-unsupported-mapping",
19268            )
19269            .unwrap(),
19270            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19271        };
19272        let resolved = spec.resolve(&defaults).unwrap();
19273        assert_eq!(resolved.provider.as_str(), "claude");
19274        let failure = shared
19275            .resolve_environment_profile(&resolved)
19276            .expect_err("claude + codex_network_access must refuse");
19277        assert_eq!(
19278            failure.code,
19279            NodeFailureCode::UnsupportedNetworkAllowlistMapping
19280        );
19281    }
19282
19283    /// Codex Moderate + codex_network_access yields `-c` overlay argv.
19284    /// ReadOnly / FullAuto refuse UnsupportedNetworkAllowlistMapping.
19285    /// Never secrets on C2.
19286    #[test]
19287    fn resolve_codex_network_access_overlay_moderate_only() {
19288        let (shared, defaults) = station_knobs_fixture();
19289        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-net").unwrap();
19290        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19291            id: allowlist.clone(),
19292            permits: Vec::new(),
19293            provider_native: Some(
19294                crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
19295                    codex_network_access: Some(true),
19296                },
19297            ),
19298        };
19299        shared
19300            .network_allowlist_catalog
19301            .write()
19302            .unwrap_or_else(|poisoned| poisoned.into_inner())
19303            .insert(entry)
19304            .unwrap();
19305
19306        let overlay = shared
19307            .resolve_codex_network_access_overlay_args(
19308                &agent("codex"),
19309                SessionMode::Pty,
19310                ApprovalLevel::Moderate,
19311                Some(&allowlist),
19312            )
19313            .expect("Moderate Codex maps network_access");
19314        assert_eq!(
19315            overlay,
19316            vec![
19317                std::ffi::OsString::from("-c"),
19318                std::ffi::OsString::from("sandbox_workspace_write.network_access=true"),
19319            ]
19320        );
19321
19322        let acp = shared
19323            .resolve_codex_network_access_overlay_args(
19324                &agent("codex"),
19325                SessionMode::Acp,
19326                ApprovalLevel::Moderate,
19327                Some(&allowlist),
19328            )
19329            .unwrap();
19330        assert_eq!(acp, overlay);
19331
19332        let inline = shared
19333            .resolve_codex_network_access_overlay_args(
19334                &agent("codex"),
19335                SessionMode::Inline,
19336                ApprovalLevel::Moderate,
19337                Some(&allowlist),
19338            )
19339            .unwrap_err();
19340        assert_eq!(
19341            inline.code,
19342            NodeFailureCode::UnsupportedNetworkAllowlistMapping
19343        );
19344
19345        for level in [ApprovalLevel::ReadOnly, ApprovalLevel::FullAuto] {
19346            let spec = crate::protocol::SpawnSpec {
19347                target: crate::protocol::SpawnTarget {
19348                    node_id: shared.node_id.clone(),
19349                    workspace_id: WorkspaceId::new("primary").unwrap(),
19350                    worktree_id: None,
19351                },
19352                profile_id: defaults.profile_id.clone(),
19353                expected_profile_revision: defaults.revision.clone(),
19354                overrides: crate::protocol::SpawnOverrides {
19355                    provider: crate::protocol::SpawnOverride::Set {
19356                        value: agent("codex"),
19357                    },
19358                    approval_level: Some(level),
19359                    network_allowlist: Some(allowlist.clone()),
19360                    ..crate::protocol::SpawnOverrides::default()
19361                },
19362                deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19363                idempotency_key: crate::protocol::SpawnIdempotencyKey::new(format!(
19364                    "codex-net-refuse-{}",
19365                    match level {
19366                        ApprovalLevel::ReadOnly => "ro",
19367                        ApprovalLevel::FullAuto => "fa",
19368                        _ => "other",
19369                    }
19370                ))
19371                .unwrap(),
19372                required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19373            };
19374            let resolved = spec.resolve(&defaults).unwrap();
19375            let failure = shared
19376                .resolve_environment_profile(&resolved)
19377                .expect_err("RO/FullAuto must refuse");
19378            assert_eq!(
19379                failure.code,
19380                NodeFailureCode::UnsupportedNetworkAllowlistMapping
19381            );
19382        }
19383    }
19384
19385    /// Claude PTY + catalog permits → ProviderHome `--settings` sidecar with
19386    /// sandbox.network.allowedDomains. ACP / missing ProviderHome refuse.
19387    /// Never secrets on C2; does not invent provider_native Claude keys.
19388    #[test]
19389    fn resolve_claude_settings_network_overlay_pty_writes_sidecar() {
19390        let (shared, _defaults) = station_knobs_fixture();
19391        let allowlist =
19392            crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-domains").unwrap();
19393        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19394            id: allowlist.clone(),
19395            permits: vec![
19396                crate::network_allowlist_catalog::NetworkPermitSketch {
19397                    protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19398                    peer: "github.com:443".to_owned(),
19399                },
19400                crate::network_allowlist_catalog::NetworkPermitSketch {
19401                    protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19402                    peer: "*.npmjs.org".to_owned(),
19403                },
19404            ],
19405            provider_native: None,
19406        };
19407        shared
19408            .network_allowlist_catalog
19409            .write()
19410            .unwrap_or_else(|poisoned| poisoned.into_inner())
19411            .insert(entry)
19412            .unwrap();
19413
19414        let unique = std::time::SystemTime::now()
19415            .duration_since(std::time::UNIX_EPOCH)
19416            .unwrap()
19417            .as_nanos();
19418        let provider_home = std::env::temp_dir().join(format!(
19419            "gate4agent-claude-provider-home-{}-{unique}",
19420            std::process::id(),
19421        ));
19422        std::fs::create_dir_all(&provider_home).unwrap();
19423
19424        let overlay = shared
19425            .resolve_network_allowlist_overlay_args(
19426                &agent("claude"),
19427                SessionMode::Pty,
19428                ApprovalLevel::Moderate,
19429                Some(&allowlist),
19430                Some(provider_home.as_path()),
19431            )
19432            .expect("Claude PTY settings overlay on unix");
19433        let settings_path = provider_home.join(
19434            crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
19435        );
19436        assert_eq!(
19437            overlay,
19438            vec![
19439                std::ffi::OsString::from("--settings"),
19440                std::ffi::OsString::from(settings_path.as_os_str()),
19441            ]
19442        );
19443        let body = std::fs::read_to_string(&settings_path).unwrap();
19444        let value: serde_json::Value = serde_json::from_str(&body).unwrap();
19445        assert_eq!(value["sandbox"]["enabled"], true);
19446        assert_eq!(
19447            value["sandbox"]["network"]["allowedDomains"],
19448            serde_json::json!(["github.com", "*.npmjs.org"])
19449        );
19450        assert!(settings_path.starts_with(&provider_home));
19451
19452        let acp = shared
19453            .resolve_network_allowlist_overlay_args(
19454                &agent("claude"),
19455                SessionMode::Acp,
19456                ApprovalLevel::Moderate,
19457                Some(&allowlist),
19458                Some(provider_home.as_path()),
19459            )
19460            .expect_err("Claude ACP must refuse settings overlay");
19461        assert_eq!(
19462            acp.code,
19463            NodeFailureCode::UnsupportedNetworkAllowlistMapping
19464        );
19465
19466        let no_home = shared
19467            .resolve_network_allowlist_overlay_args(
19468                &agent("claude"),
19469                SessionMode::Pty,
19470                ApprovalLevel::Moderate,
19471                Some(&allowlist),
19472                None,
19473            )
19474            .expect_err("Claude overlay requires ProviderHome");
19475        assert_eq!(
19476            no_home.code,
19477            NodeFailureCode::UnsupportedNetworkAllowlistMapping
19478        );
19479
19480        let bare = crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-bare").unwrap();
19481        shared
19482            .network_allowlist_catalog
19483            .write()
19484            .unwrap_or_else(|poisoned| poisoned.into_inner())
19485            .insert_id(bare.clone())
19486            .unwrap();
19487        let empty = shared
19488            .resolve_network_allowlist_overlay_args(
19489                &agent("claude"),
19490                SessionMode::Pty,
19491                ApprovalLevel::Moderate,
19492                Some(&bare),
19493                Some(provider_home.as_path()),
19494            )
19495            .unwrap();
19496        assert!(empty.is_empty());
19497
19498        let _ = std::fs::remove_dir_all(&provider_home);
19499    }
19500
19501    /// Kimi / Grok with permits stay without invented argv overlays.
19502    #[test]
19503    fn resolve_kimi_grok_network_overlay_stays_empty_without_native() {
19504        let (shared, _defaults) = station_knobs_fixture();
19505        let allowlist =
19506            crate::protocol::SpawnNetworkAllowlistId::new("egress-kimi-permits").unwrap();
19507        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19508            id: allowlist.clone(),
19509            permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
19510                protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19511                peer: "example.com:443".to_owned(),
19512            }],
19513            provider_native: None,
19514        };
19515        shared
19516            .network_allowlist_catalog
19517            .write()
19518            .unwrap_or_else(|poisoned| poisoned.into_inner())
19519            .insert(entry)
19520            .unwrap();
19521        for provider in ["kimi", "grok"] {
19522            let overlay = shared
19523                .resolve_network_allowlist_overlay_args(
19524                    &agent(provider),
19525                    SessionMode::Pty,
19526                    ApprovalLevel::Moderate,
19527                    Some(&allowlist),
19528                    None,
19529                )
19530                .unwrap();
19531            assert!(
19532                overlay.is_empty(),
19533                "{provider} must not invent network argv from permits"
19534            );
19535        }
19536    }
19537
19538    /// Track B: JSON v2 catalog file loads permits; inventory still ids only.
19539    #[test]
19540    fn network_allowlist_catalog_loads_json_v2_and_lists_ids_only() {
19541        let unique = std::time::SystemTime::now()
19542            .duration_since(std::time::UNIX_EPOCH)
19543            .unwrap()
19544            .as_nanos();
19545        let path = std::env::temp_dir().join(format!(
19546            "gate4agent-node-allowlist-json-{}-{unique}.json",
19547            std::process::id(),
19548        ));
19549        std::fs::write(
19550            &path,
19551            r#"{
19552              "schema_version": 2,
19553              "entries": [
19554                {
19555                  "id": "egress-default",
19556                  "permits": [{ "protocol": "tcp", "peer": "127.0.0.1:443" }]
19557                },
19558                { "id": "lab-egress" }
19559              ]
19560            }"#,
19561        )
19562        .unwrap();
19563        let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
19564            .expect("json v2 catalog must load");
19565        let _ = std::fs::remove_file(&path);
19566        assert_eq!(loaded.len(), 2);
19567        let entry = loaded
19568            .get(&crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap())
19569            .unwrap();
19570        assert_eq!(entry.permits.len(), 1);
19571        let (shared, defaults) = station_knobs_fixture();
19572        {
19573            let mut catalog = shared
19574                .network_allowlist_catalog
19575                .write()
19576                .unwrap_or_else(|poisoned| poisoned.into_inner());
19577            *catalog = loaded;
19578        }
19579        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19580        let spec = crate::protocol::SpawnSpec {
19581            target: crate::protocol::SpawnTarget {
19582                node_id: shared.node_id.clone(),
19583                workspace_id: WorkspaceId::new("primary").unwrap(),
19584                worktree_id: None,
19585            },
19586            profile_id: defaults.profile_id.clone(),
19587            expected_profile_revision: defaults.revision.clone(),
19588            overrides: crate::protocol::SpawnOverrides {
19589                network_allowlist: Some(allowlist.clone()),
19590                ..crate::protocol::SpawnOverrides::default()
19591            },
19592            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19593            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19594                "station-knobs-catalog-json",
19595            )
19596            .unwrap(),
19597            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19598        };
19599        let resolved = spec.resolve(&defaults).unwrap();
19600        let receipt = shared
19601            .resolve_environment_profile(&resolved)
19602            .expect("permits-only entry ok for claude")
19603            .expect("env profile present");
19604        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
19605        let inventory = shared
19606            .snapshot()
19607            .launch_inventory
19608            .expect("launch inventory present");
19609        let ids = inventory.network_allowlists.expect("ids surface");
19610        assert_eq!(
19611            ids,
19612            vec![
19613                crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap(),
19614                crate::protocol::SpawnNetworkAllowlistId::new("lab-egress").unwrap(),
19615            ]
19616        );
19617    }
19618
19619
19620}
19621
19622
19623#[cfg(test)]
19624mod workspace_environment_collision_tests {
19625    use super::*;
19626    use crate::session_environment::{
19627        NodeSessionMaterializationProfile, NodeSessionPathBinding, NodeSessionPathClass,
19628    };
19629    use gate4agent_catalog::EnvMutation;
19630    use gate4agent_runtime_native::{
19631        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
19632        NativeLaunchProfileId,
19633    };
19634    use std::ffi::OsString;
19635    use std::sync::Arc;
19636
19637    struct EmptyEnvironmentResolver;
19638
19639    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19640        fn resolve_child_environment(
19641            &self,
19642        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19643            Ok(vec![EnvMutation {
19644                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19645                value: None,
19646            }])
19647        }
19648    }
19649
19650    fn agent(value: &str) -> AgentId {
19651        AgentId::new(value).unwrap()
19652    }
19653
19654    /// Two Claude ProviderHome profiles (`CLAUDE_CONFIG_DIR` A vs B) on one node.
19655    fn claude_config_dir_collision_fixture() -> (
19656        NodeShared,
19657        ResolvedEnvironmentProfileReceipt,
19658        ResolvedEnvironmentProfileReceipt,
19659    ) {
19660        let catalog = active_registry().unwrap();
19661        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19662        let control = runtime.native_launch_profile_control();
19663        let workspace = WorkspaceConfig::new(
19664            WorkspaceId::new("primary").unwrap(),
19665            std::env::current_dir().unwrap(),
19666        )
19667        .unwrap();
19668        let shared = NodeShared::new_with_incarnation(
19669            handle,
19670            "fixture-token".to_owned(),
19671            NodeId::new("node-env-collision-test").unwrap(),
19672            NodeIncarnationId::from_bytes([3; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19673            vec![workspace],
19674            vec![agent("claude")],
19675            ProviderRuntimeStatuses::default(),
19676            None,
19677            Vec::new(),
19678            Vec::new(),
19679            SpawnProfileRegistry::default(),
19680            Some(control),
19681            None,
19682            None,
19683            None,
19684            Vec::new(),
19685            Vec::new(),
19686            Vec::new(),
19687            Vec::new(),
19688            None,
19689            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19690        );
19691
19692        let mut receipts = Vec::new();
19693        for (suffix, native_suffix) in [("a", "a-pty"), ("b", "b-pty")] {
19694            let profile_id =
19695                SpawnEnvironmentProfileId::new(format!("claude-config-dir-{suffix}")).unwrap();
19696            let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(format!(
19697                "claude-config-dir-{suffix}-r1"
19698            ))
19699            .unwrap();
19700            let materialization = NodeSessionMaterializationProfile::new(
19701                Vec::new(),
19702                vec![NodeSessionPathBinding::new(
19703                    "CLAUDE_CONFIG_DIR",
19704                    NodeSessionPathClass::ProviderHome,
19705                )
19706                .unwrap()],
19707                Vec::new(),
19708            )
19709            .unwrap();
19710            let node_profile = NodeEnvironmentProfile::new_with_materialization(
19711                profile_id.clone(),
19712                profile_revision.clone(),
19713                agent("claude"),
19714                [NativeLaunchProfile::new(
19715                    NativeLaunchProfileId::new(format!("claude-config-dir-{native_suffix}"))
19716                        .unwrap(),
19717                    agent("claude"),
19718                    TransportKind::Pty,
19719                    vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19720                    Arc::new(EmptyEnvironmentResolver),
19721                )
19722                .unwrap()],
19723                Some(materialization),
19724            )
19725            .unwrap();
19726            let (binding, native_profiles, materialization) = node_profile.into_parts();
19727            for native_profile in native_profiles {
19728                runtime.upsert_native_launch_profile(native_profile).unwrap();
19729            }
19730            shared
19731                .environment_profiles
19732                .write()
19733                .unwrap_or_else(|poisoned| poisoned.into_inner())
19734                .insert(binding.id.clone(), binding);
19735            shared
19736                .environment_materialization_profiles
19737                .write()
19738                .unwrap_or_else(|poisoned| poisoned.into_inner())
19739                .insert(profile_id.clone(), materialization.unwrap());
19740            receipts.push(ResolvedEnvironmentProfileReceipt {
19741                profile_id,
19742                profile_revision,
19743                network_allowlist: None,
19744                browser_profile_id: None,
19745            });
19746        }
19747        let profile_b = receipts.pop().unwrap();
19748        let profile_a = receipts.pop().unwrap();
19749        (shared, profile_a, profile_b)
19750    }
19751
19752    #[test]
19753    fn refuse_distinct_claude_config_dir_profiles_on_same_workspace_cwd() {
19754        let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
19755        let primary = WorkspaceId::new("primary").unwrap();
19756        let worktree = WorkspaceId::new("worktree-b").unwrap();
19757        let address = SessionAddress {
19758            workspace_id: primary.clone(),
19759            session: SessionKey {
19760                instance_id: AgentInstanceId(41),
19761                generation: SessionGeneration(1),
19762            },
19763        };
19764        shared.bind_session_with_policy(
19765            &address,
19766            ProviderRuntimePolicy::raw_pty(),
19767            Some(profile_a.clone()),
19768        );
19769
19770        let refused = shared
19771            .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_b))
19772            .unwrap_err();
19773        assert_eq!(refused.code, NodeFailureCode::WorkspaceBusy);
19774
19775        // Some vs None on the same cwd is also a collision.
19776        let refused_none = shared
19777            .ensure_workspace_environment_profile_compatible(&primary, None)
19778            .unwrap_err();
19779        assert_eq!(refused_none.code, NodeFailureCode::WorkspaceBusy);
19780
19781        // Same profile on the same workspace remains allowed.
19782        shared
19783            .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_a))
19784            .unwrap();
19785
19786        // Distinct workspace id (managed-worktree parallel path) allows profile B.
19787        shared
19788            .ensure_workspace_environment_profile_compatible(&worktree, Some(&profile_b))
19789            .unwrap();
19790    }
19791
19792    #[tokio::test]
19793    async fn spawn_session_refuses_conflicting_environment_profile_before_mutation() {
19794        let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
19795        let primary = WorkspaceId::new("primary").unwrap();
19796        let address = SessionAddress {
19797            workspace_id: primary.clone(),
19798            session: SessionKey {
19799                instance_id: AgentInstanceId(41),
19800                generation: SessionGeneration(1),
19801            },
19802        };
19803        shared.bind_session_with_policy(
19804            &address,
19805            ProviderRuntimePolicy::raw_pty(),
19806            Some(profile_a),
19807        );
19808
19809        let failure = shared
19810            .spawn_session_with_deadline(
19811                primary,
19812                agent("claude"),
19813                SessionMode::Pty,
19814                gate4agent_types::TerminalSize {
19815                    rows: 24,
19816                    columns: 80,
19817                },
19818                None,
19819                Some(profile_b),
19820                None,
19821                None,
19822                None,
19823                None,
19824                SpawnRecordPolicy::ProviderIdentityOnly,
19825                Some(Instant::now() + Duration::from_secs(30)),
19826                &[],
19827                None,
19828                ApprovalLevel::default(),
19829                None,
19830                None,
19831            )
19832            .await
19833            .unwrap_err();
19834        assert_eq!(failure.code, NodeFailureCode::WorkspaceBusy);
19835        // Guard runs before instance id mint / bind.
19836        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
19837        assert!(shared.handle.snapshot().sessions.is_empty());
19838    }
19839}
19840
19841#[cfg(test)]
19842mod spawn_characterization_tests {
19843    use super::*;
19844    use crate::session_environment::{
19845        NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
19846        NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
19847        NodeSessionPathClass,
19848    };
19849    use gate4agent_catalog::EnvMutation;
19850    use gate4agent_runtime_native::{
19851        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
19852        NativeLaunchProfile, NativeLaunchProfileId,
19853    };
19854    use std::ffi::OsString;
19855    use std::sync::Arc;
19856
19857    struct EmptyEnvironmentResolver;
19858
19859    struct FixtureSecretResolver {
19860        deny: Arc<AtomicBool>,
19861    }
19862
19863    impl NodeSecretResolver for FixtureSecretResolver {
19864        fn resolve(
19865            &self,
19866            _reference: &NodeSecretReference,
19867        ) -> Result<NodeSecretValue, NodeSecretResolveError> {
19868            if self.deny.load(Ordering::Acquire) {
19869                Err(NodeSecretResolveError::Denied)
19870            } else {
19871                NodeSecretValue::text("fixture-secret")
19872                    .map_err(|_| NodeSecretResolveError::Unavailable)
19873            }
19874        }
19875    }
19876
19877    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19878        fn resolve_child_environment(
19879            &self,
19880        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19881            Ok(vec![EnvMutation {
19882                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19883                value: None,
19884            }])
19885        }
19886    }
19887
19888    fn agent(value: &str) -> AgentId {
19889        AgentId::new(value).unwrap()
19890    }
19891
19892    fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
19893        let unique = std::time::SystemTime::now()
19894            .duration_since(std::time::UNIX_EPOCH)
19895            .unwrap()
19896            .as_nanos();
19897        std::env::temp_dir().join(format!(
19898            "gate4agent-node-{label}-{}-{unique}",
19899            std::process::id(),
19900        ))
19901    }
19902
19903    fn materialization_test_shared(
19904        deny_secret: bool,
19905    ) -> (
19906        NodeShared,
19907        NativeRuntime,
19908        ResolvedEnvironmentProfileReceipt,
19909        PathBuf,
19910        Arc<AtomicBool>,
19911    ) {
19912        let workspace = WorkspaceConfig::new(
19913            WorkspaceId::new("primary").unwrap(),
19914            std::env::current_dir().unwrap(),
19915        )
19916        .unwrap();
19917        materialization_test_shared_with_workspace(deny_secret, workspace)
19918    }
19919
19920    fn materialization_test_shared_with_workspace(
19921        deny_secret: bool,
19922        workspace: WorkspaceConfig,
19923    ) -> (
19924        NodeShared,
19925        NativeRuntime,
19926        ResolvedEnvironmentProfileReceipt,
19927        PathBuf,
19928        Arc<AtomicBool>,
19929    ) {
19930        let root = temporary_workspace_root(if deny_secret {
19931            "materialization-denied"
19932        } else {
19933            "materialization-ready"
19934        });
19935        std::fs::create_dir_all(&root).unwrap();
19936        let materialization_root = root.join("session-environments");
19937        let deny = Arc::new(AtomicBool::new(deny_secret));
19938        let resolver = Arc::new(FixtureSecretResolver {
19939            deny: Arc::clone(&deny),
19940        });
19941        let materializer = SessionEnvironmentMaterializer::new(
19942            materialization_root,
19943            resolver,
19944        )
19945        .unwrap();
19946        let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
19947        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19948            "materialized-claude-r1",
19949        )
19950        .unwrap();
19951        let materialization = NodeSessionMaterializationProfile::new(
19952            vec![NodeSessionEnvironmentMutation::SetSecret {
19953                key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
19954                reference: NodeSecretReference::new("fixture-token").unwrap(),
19955            }],
19956            vec![NodeSessionPathBinding::new(
19957                "GATE4AGENT_PROVIDER_HOME",
19958                NodeSessionPathClass::ProviderHome,
19959            )
19960            .unwrap()],
19961            vec![NodeSessionFile::secret(
19962                NodeSessionPathClass::Config,
19963                "auth/token",
19964                NodeSecretReference::new("fixture-token").unwrap(),
19965            )
19966            .unwrap()],
19967        )
19968        .unwrap();
19969        let catalog = active_registry().unwrap();
19970        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19971        let control = runtime.native_launch_profile_control();
19972        let node_profile = NodeEnvironmentProfile::new_with_materialization(
19973            profile_id.clone(),
19974            profile_revision.clone(),
19975            agent("claude"),
19976            [NativeLaunchProfile::new(
19977                NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
19978                agent("claude"),
19979                TransportKind::Pty,
19980                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19981                Arc::new(EmptyEnvironmentResolver),
19982            )
19983            .unwrap()],
19984            Some(materialization),
19985        )
19986        .unwrap();
19987        let (binding, native_profiles, materialization) = node_profile.into_parts();
19988        for native_profile in native_profiles {
19989            runtime.upsert_native_launch_profile(native_profile).unwrap();
19990        }
19991        let shared = NodeShared::new_with_incarnation(
19992            handle,
19993            "fixture-token".to_owned(),
19994            NodeId::new("node-materialization-test").unwrap(),
19995            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19996            vec![workspace],
19997            vec![agent("claude")],
19998            ProviderRuntimeStatuses::default(),
19999            None,
20000            Vec::new(),
20001            Vec::new(),
20002            SpawnProfileRegistry::default(),
20003            Some(control),
20004            None,
20005            Some(materializer),
20006            None,
20007            Vec::new(),
20008            Vec::new(),
20009            Vec::new(),
20010            Vec::new(),
20011            None,
20012            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20013        );
20014        shared
20015            .environment_profiles
20016            .write()
20017            .unwrap_or_else(|poisoned| poisoned.into_inner())
20018            .insert(binding.id.clone(), binding);
20019        shared
20020            .environment_materialization_profiles
20021            .write()
20022            .unwrap_or_else(|poisoned| poisoned.into_inner())
20023            .insert(profile_id.clone(), materialization.unwrap());
20024        (
20025            shared,
20026            runtime,
20027            ResolvedEnvironmentProfileReceipt {
20028                profile_id,
20029                profile_revision,
20030                network_allowlist: None,
20031                browser_profile_id: None,
20032            },
20033            root,
20034            deny,
20035        )
20036    }
20037
20038
20039    // ---- Characterization of the spawn preparation block (pre-SpawnPreparer) ----
20040
20041    async fn characterization_spawn(
20042        shared: &NodeShared,
20043        mode: SessionMode,
20044        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
20045        required: &[ProviderRuntimeCapability],
20046        harness_mcp: Option<&PreparedHarnessMcpSpawn>,
20047    ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
20048        shared
20049            .spawn_session_with_deadline(
20050                WorkspaceId::new("primary").unwrap(),
20051                agent("claude"),
20052                mode,
20053                gate4agent_types::TerminalSize { rows: 24, columns: 80 },
20054                None,
20055                environment_profile,
20056                None,
20057                None,
20058                None,
20059                Some(ProviderRuntimePolicy::raw_pty()),
20060                SpawnRecordPolicy::ProviderIdentityOnly,
20061                Some(Instant::now() + Duration::from_secs(30)),
20062                required,
20063                harness_mcp,
20064                ApprovalLevel::default(),
20065                None,
20066                None,
20067            )
20068            .await
20069    }
20070
20071    fn assert_no_session_state(shared: &NodeShared) {
20072        assert!(shared.handle.snapshot().sessions.is_empty());
20073        assert!(shared
20074            .session_bindings
20075            .lock()
20076            .unwrap_or_else(|poisoned| poisoned.into_inner())
20077            .is_empty());
20078        assert!(shared
20079            .materializations
20080            .lock()
20081            .unwrap_or_else(|poisoned| poisoned.into_inner())
20082            .is_empty());
20083    }
20084
20085    #[tokio::test]
20086    async fn spawn_unadmitted_required_capability_refuses_before_id_mint() {
20087        let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
20088        let error = characterization_spawn(
20089            &shared,
20090            SessionMode::Pty,
20091            None,
20092            &[ProviderRuntimeCapability::SemanticReadiness],
20093            None,
20094        )
20095        .await
20096        .unwrap_err();
20097        assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
20098        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
20099        assert_no_session_state(&shared);
20100        drop(shared);
20101        drop(runtime);
20102        std::fs::remove_dir_all(root).unwrap();
20103    }
20104
20105    #[tokio::test]
20106    async fn spawn_denied_environment_materialization_fails_after_id_mint_with_no_state() {
20107        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
20108        let error = characterization_spawn(&shared, SessionMode::Pty, Some(receipt), &[], None)
20109            .await
20110            .unwrap_err();
20111        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
20112        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 2);
20113        assert_no_session_state(&shared);
20114        drop(shared);
20115        drop(runtime);
20116        std::fs::remove_dir_all(root).unwrap();
20117    }
20118
20119    #[test]
20120    fn remove_binding_returns_binding_and_is_idempotent_for_environment_session() {
20121        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20122        let address = SessionAddress {
20123            workspace_id: WorkspaceId::new("primary").unwrap(),
20124            session: SessionKey {
20125                instance_id: AgentInstanceId(77),
20126                generation: SessionGeneration(1),
20127            },
20128        };
20129        shared.bind_session_with_policy(&address, ProviderRuntimePolicy::raw_pty(), Some(receipt));
20130        let removed = shared.remove_binding(&address).expect("binding removed");
20131        assert!(removed.environment_profile.is_some());
20132        assert!(shared.remove_binding(&address).is_none());
20133        assert_no_session_state(&shared);
20134        drop(shared);
20135        drop(runtime);
20136        std::fs::remove_dir_all(root).unwrap();
20137    }
20138
20139    async fn characterization_prepared_harness(provider: &str) -> PreparedHarnessMcpSpawn {
20140        use crate::harness_mcp_proxy::{HarnessMcpProxyRegistry, ReviewedHarnessMcpProgram};
20141        use crate::protocol::{HarnessMcpActivationDigest, HarnessMcpLaunchV1, HarnessMcpReservationId};
20142        let helper = ReviewedHarnessMcpProgram::review(std::env::current_exe().unwrap())
20143            .expect("review current test executable as fixture helper");
20144        let registry = HarnessMcpProxyRegistry::new(helper);
20145        let spec = crate::protocol::SpawnSpec {
20146            target: crate::protocol::SpawnTarget {
20147                node_id: NodeId::new("node-characterization").unwrap(),
20148                workspace_id: WorkspaceId::new("primary").unwrap(),
20149                worktree_id: None,
20150            },
20151            profile_id: crate::protocol::SpawnProfileId::new("claude").unwrap(),
20152            expected_profile_revision: crate::protocol::SpawnProfileRevision::new("r1").unwrap(),
20153            overrides: crate::protocol::SpawnOverrides::default(),
20154            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
20155            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("characterization-harness").unwrap(),
20156            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
20157        };
20158        let reservation = HarnessMcpReservationId::new(format!("hmcpres_{}", "c".repeat(24))).unwrap();
20159        let digest = HarnessMcpActivationDigest::new(format!("sha256:{}", "c".repeat(64))).unwrap();
20160        let launch = HarnessMcpLaunchV1 {
20161            server_name: "fixture-harness".to_owned(),
20162            args: vec!["--session-proxy".to_owned()],
20163            endpoint_env: "FIXTURE_SESSION_ENDPOINT".to_owned(),
20164            token_env: "FIXTURE_SESSION_TOKEN".to_owned(),
20165            program_env: "FIXTURE_MCP_PROGRAM".to_owned(),
20166            trace: None,
20167            scrub_env: Vec::new(),
20168        };
20169        let expires = unix_time_ms().saturating_add(30_000);
20170        registry.arm(reservation.clone(), digest.clone(), spec.clone(), launch, expires).await.unwrap();
20171        registry
20172            .prepare_spawn(&reservation, &digest, &spec, expires, agent(provider))
20173            .unwrap()
20174    }
20175
20176    #[tokio::test]
20177    async fn spawn_harness_overlay_refuses_inline_and_provider_mismatch_with_no_state() {
20178        let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
20179        let prepared = characterization_prepared_harness("claude").await;
20180        let error = characterization_spawn(&shared, SessionMode::Inline, None, &[], Some(&prepared))
20181            .await
20182            .unwrap_err();
20183        // Inline is stopped by runtime admission before the harness check runs.
20184        assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
20185        let mismatched = characterization_prepared_harness("codex").await;
20186        let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&mismatched))
20187            .await
20188            .unwrap_err();
20189        assert_eq!(error.code, NodeFailureCode::BindingMismatch);
20190        assert_no_session_state(&shared);
20191        drop(shared);
20192        drop(runtime);
20193        std::fs::remove_dir_all(root).unwrap();
20194    }
20195
20196    #[tokio::test]
20197    async fn spawn_harness_overlay_without_launch_control_is_unavailable_with_no_state() {
20198        let (mut shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
20199        shared.native_launch_profile_control = None;
20200        let prepared = characterization_prepared_harness("claude").await;
20201        let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&prepared))
20202            .await
20203            .unwrap_err();
20204        assert_eq!(error.code, NodeFailureCode::HarnessMcpUnavailable);
20205        assert_no_session_state(&shared);
20206        drop(shared);
20207        drop(runtime);
20208        std::fs::remove_dir_all(root).unwrap();
20209    }
20210}
20211
20212#[cfg(all(test, windows))]
20213mod tests {
20214    use super::*;
20215    use gate4agent_types::ProviderActivity;
20216    use crate::session_environment::{
20217        NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
20218        NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
20219        NodeSessionPathClass,
20220    };
20221    use gate4agent_catalog::EnvMutation;
20222    use gate4agent_runtime_native::{
20223        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
20224        NativeLaunchProfile, NativeLaunchProfileId,
20225    };
20226    use std::ffi::OsString;
20227    use std::sync::Arc;
20228
20229    struct EmptyEnvironmentResolver;
20230
20231    struct FixtureSecretResolver {
20232        deny: Arc<AtomicBool>,
20233    }
20234
20235    impl NodeSecretResolver for FixtureSecretResolver {
20236        fn resolve(
20237            &self,
20238            _reference: &NodeSecretReference,
20239        ) -> Result<NodeSecretValue, NodeSecretResolveError> {
20240            if self.deny.load(Ordering::Acquire) {
20241                Err(NodeSecretResolveError::Denied)
20242            } else {
20243                NodeSecretValue::text("fixture-secret")
20244                    .map_err(|_| NodeSecretResolveError::Unavailable)
20245            }
20246        }
20247    }
20248
20249    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
20250        fn resolve_child_environment(
20251            &self,
20252        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
20253            Ok(vec![EnvMutation {
20254                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
20255                value: None,
20256            }])
20257        }
20258    }
20259
20260    fn agent(value: &str) -> AgentId {
20261        AgentId::new(value).unwrap()
20262    }
20263
20264    fn terminal_test_shared_with_mode(mode: WorktreeServiceMode) -> NodeShared {
20265        let catalog = active_registry().unwrap();
20266        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20267        let workspace = WorkspaceConfig::new(
20268            WorkspaceId::new("primary").unwrap(),
20269            std::env::current_dir().unwrap(),
20270        )
20271        .unwrap()
20272        .with_worktree_service_mode(mode);
20273        NodeShared::new(
20274            handle,
20275            "fixture-token".to_owned(),
20276            NodeId::new("node-terminal-test").unwrap(),
20277            vec![workspace],
20278            vec![agent("claude")],
20279        )
20280    }
20281
20282    fn terminal_test_shared() -> NodeShared {
20283        terminal_test_shared_with_mode(WorktreeServiceMode::Manual)
20284    }
20285
20286    #[cfg(feature = "fixture")]
20287    #[test]
20288    fn managed_worktree_v2_failure_probe_keeps_only_the_latest_code() {
20289        let probe = SpawnManagedWorktreeV2FailureProbe::default();
20290        assert_eq!(probe.latest_code(), None);
20291
20292        probe.record(Some(NodeFailureCode::ManagedWorktreeOwnershipConflict));
20293        assert_eq!(
20294            probe.latest_code(),
20295            Some(NodeFailureCode::ManagedWorktreeOwnershipConflict),
20296        );
20297
20298        probe.record(Some(NodeFailureCode::SpawnDeadlineExceeded));
20299        assert_eq!(
20300            probe.latest_code(),
20301            Some(NodeFailureCode::SpawnDeadlineExceeded),
20302        );
20303
20304        probe.record(None);
20305        assert_eq!(probe.latest_code(), None);
20306    }
20307
20308    #[test]
20309    fn launch_snapshot_is_authoritative_bounded_and_path_free() {
20310        let source = temporary_workspace_root("launch-inventory-source");
20311        let allocation = temporary_workspace_root("launch-inventory-allocation");
20312        std::fs::create_dir_all(&source).unwrap();
20313        std::fs::create_dir_all(&allocation).unwrap();
20314        let workspace_id = WorkspaceId::new("primary").unwrap();
20315        let mut workspace = WorkspaceConfig::new(workspace_id.clone(), &source)
20316            .unwrap()
20317            .with_worktree_service_mode(WorktreeServiceMode::Managed);
20318        for index in 0..MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE {
20319            workspace = workspace
20320                .with_managed_worktree_profile(
20321                    ManagedWorktreeProfile::new(
20322                        WorktreeProfileId::new(format!("profile-{index}")).unwrap(),
20323                        crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
20324                        &allocation,
20325                        "gate4agent",
20326                        "HEAD",
20327                        ManagedWorktreeRetention::RemoveWhenReleased,
20328                    )
20329                    .unwrap(),
20330                )
20331                .unwrap();
20332        }
20333        let overflow = workspace.clone().with_managed_worktree_profile(
20334            ManagedWorktreeProfile::new(
20335                WorktreeProfileId::new("overflow").unwrap(),
20336                crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
20337                &allocation,
20338                "gate4agent",
20339                "HEAD",
20340                ManagedWorktreeRetention::Retain,
20341            )
20342            .unwrap(),
20343        );
20344        assert!(matches!(
20345            overflow,
20346            Err(NodeServerError::ManagedWorktreeProfileCapacity {
20347                max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
20348                ..
20349            }),
20350        ));
20351
20352        let catalog = active_registry().unwrap();
20353        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20354        let shared = NodeShared::new(
20355            handle,
20356            "fixture-token".to_owned(),
20357            NodeId::new("node-launch-inventory").unwrap(),
20358            vec![workspace],
20359            vec![agent("claude")],
20360        );
20361        let snapshot = shared.snapshot();
20362        let launch = snapshot.launch_inventory.as_ref().unwrap();
20363        assert_eq!(launch.spawn_profiles.as_ref().unwrap().len(), 1);
20364        assert!(launch.bundles.as_ref().unwrap().is_empty());
20365        let profiles = snapshot.workspaces[0]
20366            .managed_worktree_profiles
20367            .as_ref()
20368            .unwrap();
20369        assert_eq!(
20370            snapshot.workspaces[0].worktree_service_mode,
20371            Some(WorktreeServiceMode::Managed),
20372        );
20373        assert_eq!(
20374            profiles.profiles.len(),
20375            MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
20376        );
20377        let public_inventory = serde_json::to_string(&(launch, profiles)).unwrap();
20378        for forbidden in ["allocation_root", "branch_prefix", "base", "prompt", "environment", "home"] {
20379            assert!(!public_inventory.contains(forbidden), "launch inventory leaked {forbidden}");
20380        }
20381        let legacy = snapshot_for_wire(
20382            &shared,
20383            true,
20384            true,
20385            false,
20386            false,
20387            true,
20388            false,
20389            true,
20390            false,
20391            true,
20392        );
20393        assert!(legacy.launch_inventory.is_none());
20394        assert!(legacy.workspaces[0].managed_worktree_profiles.is_none());
20395
20396        drop(shared);
20397        std::fs::remove_dir_all(source).unwrap();
20398        std::fs::remove_dir_all(allocation).unwrap();
20399    }
20400
20401    #[test]
20402    fn agent_progress_projection_omits_sensitive_provider_payloads_and_is_capability_gated() {
20403        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20404            capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
20405        }));
20406        let source = gate4agent_types::ProviderSource {
20407            family: AdapterFamily::PtySemantic,
20408            binding: AdapterBinding::new(
20409                gate4agent_types::AdapterId::new("claude").unwrap(),
20410                "fixture/v1",
20411                gate4agent_types::AdapterVerification::SyntheticFixture,
20412            )
20413            .unwrap(),
20414        };
20415        let provider = ProviderSnapshot {
20416            sequence: 17,
20417            session: Some(ProviderSessionIdentity {
20418                key: ProviderSessionKey::SessionId,
20419                id: "PRIVATE_PROVIDER_SESSION".to_owned(),
20420                transcript_path: Some(r"C:\PRIVATE_PROVIDER_PATH".to_owned()),
20421            }),
20422            model: Some("PRIVATE_MODEL".to_owned()),
20423            tools: vec!["PRIVATE_TOOL_INVENTORY".to_owned()],
20424            completed_turns: 3,
20425            usage: gate4agent_types::TokenUsage {
20426                input_tokens: 10,
20427                output_tokens: 20,
20428                cache_read_tokens: 30,
20429                cache_write_tokens: 40,
20430                reasoning_tokens: 50,
20431                context_window: Some(200_000),
20432            },
20433            lead_activity: ProviderActivity::WaitingForInput,
20434            activity: ProviderActivity::WaitingForInput,
20435            current_prompt: Some("PRIVATE_CURRENT_PROMPT".to_owned()),
20436            active_tools: (0..9)
20437                .map(|index| gate4agent_types::ActiveProviderTool {
20438                    id: format!("private-tool-id-{index}"),
20439                    name: if index == 0 {
20440                        format!("  Read\u{0000}{}", "x".repeat(80))
20441                    } else {
20442                        format!("Tool-{index}")
20443                    },
20444                    input_json: "PRIVATE_TOOL_INPUT_JSON".to_owned(),
20445                })
20446                .collect(),
20447            interactions: vec![gate4agent_types::ProviderInteraction {
20448                id: gate4agent_types::ProviderInteractionId(17),
20449                source: source.clone(),
20450                provider_request_id: Some("PRIVATE_PROVIDER_REQUEST".to_owned()),
20451                interaction_kind: ProviderInteractionKind::Approval,
20452                tool_name: " Approve\u{0007}Tool ".to_owned(),
20453                prompt: "PRIVATE_INTERACTION_PROMPT".to_owned(),
20454                agent_id: Some("PRIVATE_INTERACTION_AGENT".to_owned()),
20455                resume_lead_activity: Some(ProviderActivity::Working),
20456                status: ProviderInteractionStatus::Pending,
20457            }],
20458            subagents: vec![gate4agent_types::ProviderSubagent {
20459                source: source.clone(),
20460                provider_agent_id: "PRIVATE_SUBAGENT_ID".to_owned(),
20461                agent_type: Some("PRIVATE_SUBAGENT_TYPE".to_owned()),
20462                description: Some("PRIVATE_SUBAGENT_DESCRIPTION".to_owned()),
20463            }],
20464            sources: vec![gate4agent_types::ProviderSourceCursor {
20465                source,
20466                sequence: 17,
20467                gap_count: 2,
20468                stale: true,
20469            }],
20470            last_event: Some(ProviderEvent::Error {
20471                message: "PRIVATE_PROVIDER_ERROR".to_owned(),
20472            }),
20473            gap_count: 2,
20474            stale: true,
20475        };
20476        let address = terminal_address(3);
20477        let entry = agent_progress_from_provider_snapshot(address.clone(), &provider).unwrap();
20478        assert_eq!(entry.address, address);
20479        assert_eq!(entry.progress.provider_sequence, 17);
20480        assert_eq!(entry.progress.active_tool_count, 9);
20481        assert_eq!(
20482            entry.progress.active_tool_labels.len(),
20483            MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS,
20484        );
20485        assert_eq!(entry.progress.subagent_count, 1);
20486        assert_eq!(entry.progress.last_event_kind, Some(AgentProgressEventKindV1::Error));
20487        assert!(entry.progress.truncated);
20488        let encoded = serde_json::to_string(&entry).unwrap();
20489        for forbidden in [
20490            "PRIVATE_PROVIDER_SESSION",
20491            "PRIVATE_PROVIDER_PATH",
20492            "PRIVATE_MODEL",
20493            "PRIVATE_TOOL_INVENTORY",
20494            "PRIVATE_CURRENT_PROMPT",
20495            "PRIVATE_TOOL_INPUT_JSON",
20496            "PRIVATE_PROVIDER_REQUEST",
20497            "PRIVATE_INTERACTION_PROMPT",
20498            "PRIVATE_INTERACTION_AGENT",
20499            "PRIVATE_SUBAGENT_ID",
20500            "PRIVATE_SUBAGENT_TYPE",
20501            "PRIVATE_SUBAGENT_DESCRIPTION",
20502            "PRIVATE_PROVIDER_ERROR",
20503            "input_json",
20504            "current_prompt",
20505            "session_id",
20506        ] {
20507            assert!(!encoded.contains(forbidden), "agent progress leaked {forbidden}");
20508        }
20509
20510        let snapshot = NodeSnapshot {
20511            node_id: NodeId::new("node-progress-test").unwrap(),
20512            enabled_providers: vec![agent("claude")],
20513            provider_runtime_statuses: ProviderRuntimeStatuses::default(),
20514            workspaces: Vec::new(),
20515            session_records: Vec::new(),
20516            managed_worktrees: Vec::new(),
20517            launch_inventory: None,
20518            agent_progress: vec![entry],
20519        };
20520        let mut legacy_reply = ResponseEnvelope {
20521            request_id: 1,
20522            result: Ok(NodeResponse::Snapshot {
20523                event_sequence: 0,
20524                controller: None,
20525                snapshot: snapshot.clone(),
20526            }),
20527        };
20528        project_response_without_agent_progress(&mut legacy_reply);
20529        let Ok(NodeResponse::Snapshot { snapshot: legacy, .. }) = legacy_reply.result else {
20530            panic!("agent progress projection changed response kind");
20531        };
20532        assert!(legacy.agent_progress.is_empty());
20533        assert_eq!(snapshot.agent_progress.len(), 1);
20534    }
20535
20536    fn terminal_address(generation: u64) -> SessionAddress {
20537        SessionAddress {
20538            workspace_id: WorkspaceId::new("primary").unwrap(),
20539            session: SessionKey {
20540                instance_id: AgentInstanceId(41),
20541                generation: SessionGeneration(generation),
20542            },
20543        }
20544    }
20545
20546    fn spawn_spec_fixture() -> SpawnSpec {
20547        SpawnSpec {
20548            target: crate::protocol::SpawnTarget {
20549                node_id: NodeId::new("node-terminal-test").unwrap(),
20550                workspace_id: WorkspaceId::new("primary").unwrap(),
20551                worktree_id: None,
20552            },
20553            profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
20554            expected_profile_revision: crate::protocol::SpawnProfileRevision::new("builtin-v1")
20555                .unwrap(),
20556            overrides: crate::protocol::SpawnOverrides::default(),
20557            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
20558            idempotency_key: SpawnIdempotencyKey::new("spawn-spec-fixture").unwrap(),
20559            required_capabilities: SpawnRequiredCapabilities::default(),
20560        }
20561    }
20562
20563    fn environment_profile_test_shared(
20564    ) -> (NodeShared, NativeRuntime, ResolvedEnvironmentProfileReceipt) {
20565        let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
20566        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20567            "local-claude-r1",
20568        )
20569        .unwrap();
20570        let spawn_profiles = SpawnProfileRegistry::new([
20571            crate::protocol::SpawnProfileDefaults {
20572                profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
20573                revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
20574                provider: agent("claude"),
20575                mode: SessionMode::Pty,
20576                terminal_size: gate4agent_types::TerminalSize {
20577                    rows: 24,
20578                    columns: 80,
20579                },
20580                prompt: None,
20581                bundle_id: None,
20582                context_id: None,
20583                environment_profile_id: Some(profile_id.clone()),
20584            },
20585        ])
20586        .unwrap();
20587        let catalog = active_registry().unwrap();
20588        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20589        let control = runtime.native_launch_profile_control();
20590        let node_profile = NodeEnvironmentProfile::new(
20591            profile_id.clone(),
20592            profile_revision.clone(),
20593            agent("claude"),
20594            [NativeLaunchProfile::new(
20595                NativeLaunchProfileId::new("local-claude-pty").unwrap(),
20596                agent("claude"),
20597                TransportKind::Pty,
20598                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20599                Arc::new(EmptyEnvironmentResolver),
20600            )
20601            .unwrap()],
20602        )
20603        .unwrap();
20604        let (binding, native_profiles, _materialization) = node_profile.into_parts();
20605        for native_profile in native_profiles {
20606            runtime.upsert_native_launch_profile(native_profile).unwrap();
20607        }
20608        let workspace = WorkspaceConfig::new(
20609            WorkspaceId::new("primary").unwrap(),
20610            std::env::current_dir().unwrap(),
20611        )
20612        .unwrap();
20613        let shared = NodeShared::new_with_incarnation(
20614            handle,
20615            "fixture-token".to_owned(),
20616            NodeId::new("node-terminal-test").unwrap(),
20617            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20618            vec![workspace],
20619            vec![agent("claude")],
20620            ProviderRuntimeStatuses::default(),
20621            None,
20622            Vec::new(),
20623            Vec::new(),
20624            spawn_profiles,
20625            Some(control),
20626            None,
20627            None,
20628            None,
20629            Vec::new(),
20630            Vec::new(),
20631            Vec::new(),
20632            Vec::new(),
20633            None,
20634            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20635        );
20636        shared
20637            .environment_profiles
20638            .write()
20639            .unwrap_or_else(|poisoned| poisoned.into_inner())
20640            .insert(binding.id.clone(), binding);
20641        (
20642            shared,
20643            runtime,
20644            ResolvedEnvironmentProfileReceipt {
20645                profile_id,
20646                profile_revision,
20647                network_allowlist: None,
20648                browser_profile_id: None,
20649            },
20650        )
20651    }
20652
20653    fn materialization_test_shared(
20654        deny_secret: bool,
20655    ) -> (
20656        NodeShared,
20657        NativeRuntime,
20658        ResolvedEnvironmentProfileReceipt,
20659        PathBuf,
20660        Arc<AtomicBool>,
20661    ) {
20662        let workspace = WorkspaceConfig::new(
20663            WorkspaceId::new("primary").unwrap(),
20664            std::env::current_dir().unwrap(),
20665        )
20666        .unwrap();
20667        materialization_test_shared_with_workspace(deny_secret, workspace)
20668    }
20669
20670    fn materialization_test_shared_with_workspace(
20671        deny_secret: bool,
20672        workspace: WorkspaceConfig,
20673    ) -> (
20674        NodeShared,
20675        NativeRuntime,
20676        ResolvedEnvironmentProfileReceipt,
20677        PathBuf,
20678        Arc<AtomicBool>,
20679    ) {
20680        let root = temporary_workspace_root(if deny_secret {
20681            "materialization-denied"
20682        } else {
20683            "materialization-ready"
20684        });
20685        std::fs::create_dir_all(&root).unwrap();
20686        let materialization_root = root.join("session-environments");
20687        let deny = Arc::new(AtomicBool::new(deny_secret));
20688        let resolver = Arc::new(FixtureSecretResolver {
20689            deny: Arc::clone(&deny),
20690        });
20691        let materializer = SessionEnvironmentMaterializer::new(
20692            materialization_root,
20693            resolver,
20694        )
20695        .unwrap();
20696        let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
20697        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20698            "materialized-claude-r1",
20699        )
20700        .unwrap();
20701        let materialization = NodeSessionMaterializationProfile::new(
20702            vec![NodeSessionEnvironmentMutation::SetSecret {
20703                key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
20704                reference: NodeSecretReference::new("fixture-token").unwrap(),
20705            }],
20706            vec![NodeSessionPathBinding::new(
20707                "GATE4AGENT_PROVIDER_HOME",
20708                NodeSessionPathClass::ProviderHome,
20709            )
20710            .unwrap()],
20711            vec![NodeSessionFile::secret(
20712                NodeSessionPathClass::Config,
20713                "auth/token",
20714                NodeSecretReference::new("fixture-token").unwrap(),
20715            )
20716            .unwrap()],
20717        )
20718        .unwrap();
20719        let catalog = active_registry().unwrap();
20720        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20721        let control = runtime.native_launch_profile_control();
20722        let node_profile = NodeEnvironmentProfile::new_with_materialization(
20723            profile_id.clone(),
20724            profile_revision.clone(),
20725            agent("claude"),
20726            [NativeLaunchProfile::new(
20727                NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
20728                agent("claude"),
20729                TransportKind::Pty,
20730                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20731                Arc::new(EmptyEnvironmentResolver),
20732            )
20733            .unwrap()],
20734            Some(materialization),
20735        )
20736        .unwrap();
20737        let (binding, native_profiles, materialization) = node_profile.into_parts();
20738        for native_profile in native_profiles {
20739            runtime.upsert_native_launch_profile(native_profile).unwrap();
20740        }
20741        let shared = NodeShared::new_with_incarnation(
20742            handle,
20743            "fixture-token".to_owned(),
20744            NodeId::new("node-materialization-test").unwrap(),
20745            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20746            vec![workspace],
20747            vec![agent("claude")],
20748            ProviderRuntimeStatuses::default(),
20749            None,
20750            Vec::new(),
20751            Vec::new(),
20752            SpawnProfileRegistry::default(),
20753            Some(control),
20754            None,
20755            Some(materializer),
20756            None,
20757            Vec::new(),
20758            Vec::new(),
20759            Vec::new(),
20760            Vec::new(),
20761            None,
20762            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20763        );
20764        shared
20765            .environment_profiles
20766            .write()
20767            .unwrap_or_else(|poisoned| poisoned.into_inner())
20768            .insert(binding.id.clone(), binding);
20769        shared
20770            .environment_materialization_profiles
20771            .write()
20772            .unwrap_or_else(|poisoned| poisoned.into_inner())
20773            .insert(profile_id.clone(), materialization.unwrap());
20774        (
20775            shared,
20776            runtime,
20777            ResolvedEnvironmentProfileReceipt {
20778                profile_id,
20779                profile_revision,
20780                network_allowlist: None,
20781                browser_profile_id: None,
20782            },
20783            root,
20784            deny,
20785        )
20786    }
20787
20788    #[cfg(feature = "fixture")]
20789    #[derive(Clone, Copy)]
20790    enum CodexHomeProfile {
20791        Missing,
20792        WrongClass,
20793        Exact,
20794    }
20795
20796    #[cfg(feature = "fixture")]
20797    fn codex_bundle_test_shared(
20798        home_profile: CodexHomeProfile,
20799    ) -> (
20800        NodeShared,
20801        NativeRuntime,
20802        ResolvedEnvironmentProfileReceipt,
20803        NodeBundle,
20804        PathBuf,
20805    ) {
20806        const ROOT_MANIFEST: &[u8] = br#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"review-tools"}"#;
20807        const CLAUDE_MANIFEST: &[u8] =
20808            br#"{"name":"review-tools","version":"1.0.0","description":"Review helpers"}"#;
20809        const SKILL: &[u8] = b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n";
20810        const DIGEST: &str =
20811            "sha256:941f20868a6ef49b4329bf1bb1368515763f5b64d8279b05cd9700966083d707";
20812
20813        let (shared, mut runtime, _claude_receipt, root, _deny) =
20814            materialization_test_shared(false);
20815        let profile_id = SpawnEnvironmentProfileId::new(match home_profile {
20816            CodexHomeProfile::Missing => "codex-home-missing",
20817            CodexHomeProfile::WrongClass => "codex-home-wrong",
20818            CodexHomeProfile::Exact => "codex-home-exact",
20819        })
20820        .unwrap();
20821        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20822            "codex-home-r1",
20823        )
20824        .unwrap();
20825        let materialization = match home_profile {
20826            CodexHomeProfile::Missing => None,
20827            CodexHomeProfile::WrongClass | CodexHomeProfile::Exact => {
20828                Some(NodeSessionMaterializationProfile::new(
20829                    Vec::new(),
20830                    vec![NodeSessionPathBinding::new(
20831                        "CODEX_HOME",
20832                        if matches!(home_profile, CodexHomeProfile::Exact) {
20833                            NodeSessionPathClass::ProviderHome
20834                        } else {
20835                            NodeSessionPathClass::Config
20836                        },
20837                    )
20838                    .unwrap()],
20839                    Vec::new(),
20840                )
20841                .unwrap())
20842            }
20843        };
20844        let node_profile = NodeEnvironmentProfile::new_with_materialization(
20845            profile_id.clone(),
20846            profile_revision.clone(),
20847            agent("codex"),
20848            [NativeLaunchProfile::new(
20849                NativeLaunchProfileId::new(format!("{profile_id}-pty")).unwrap(),
20850                agent("codex"),
20851                TransportKind::Pty,
20852                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20853                Arc::new(EmptyEnvironmentResolver),
20854            )
20855            .unwrap()],
20856            materialization,
20857        )
20858        .unwrap();
20859        let (binding, native_profiles, materialization) = node_profile.into_parts();
20860        for native_profile in native_profiles {
20861            runtime.upsert_native_launch_profile(native_profile).unwrap();
20862        }
20863        shared
20864            .environment_profiles
20865            .write()
20866            .unwrap_or_else(|poisoned| poisoned.into_inner())
20867            .insert(binding.id.clone(), binding);
20868        if let Some(materialization) = materialization {
20869            shared
20870                .environment_materialization_profiles
20871                .write()
20872                .unwrap_or_else(|poisoned| poisoned.into_inner())
20873                .insert(profile_id.clone(), materialization);
20874        }
20875
20876        let bundle_source = root.join(format!("bundle-source-{profile_id}"));
20877        std::fs::create_dir_all(bundle_source.join(".claude-plugin")).unwrap();
20878        std::fs::create_dir_all(bundle_source.join("skills/review-code")).unwrap();
20879        std::fs::write(bundle_source.join("plugin.json"), ROOT_MANIFEST).unwrap();
20880        std::fs::write(
20881            bundle_source.join(".claude-plugin/plugin.json"),
20882            CLAUDE_MANIFEST,
20883        )
20884        .unwrap();
20885        std::fs::write(bundle_source.join("skills/review-code/SKILL.md"), SKILL).unwrap();
20886        crate::bundle_catalog::protect_bundle_source_tree_fixture(&bundle_source).unwrap();
20887        let bundle = NodeBundle::new(
20888            crate::protocol::SpawnBundleId::new("review-tools").unwrap(),
20889            crate::protocol::SpawnBundleRevision::new("review-tools-r1").unwrap(),
20890            crate::protocol::SpawnBundleDigest::new(DIGEST).unwrap(),
20891            &bundle_source,
20892        )
20893        .unwrap();
20894        *shared
20895            .bundle_catalog
20896            .write()
20897            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
20898            BundleCatalog::new([bundle.clone()]).unwrap();
20899        (
20900            shared,
20901            runtime,
20902            ResolvedEnvironmentProfileReceipt {
20903                profile_id,
20904                profile_revision,
20905                network_allowlist: None,
20906                browser_profile_id: None,
20907            },
20908            bundle,
20909            root,
20910        )
20911    }
20912
20913    #[cfg(feature = "fixture")]
20914    #[test]
20915    fn codex_bundle_preflight_rejects_missing_or_wrong_home_before_materialization() {
20916        for home_profile in [CodexHomeProfile::Missing, CodexHomeProfile::WrongClass] {
20917            let (shared, runtime, receipt, bundle, root) =
20918                codex_bundle_test_shared(home_profile);
20919            let error = shared
20920                .bundle_layout(
20921                    &agent("codex"),
20922                    SessionMode::Pty,
20923                    Some(&receipt),
20924                    &bundle,
20925                )
20926                .unwrap_err();
20927            assert_eq!(error.code, NodeFailureCode::BundleBindingMismatch);
20928            assert!(shared
20929                .materializations
20930                .lock()
20931                .unwrap_or_else(|poisoned| poisoned.into_inner())
20932                .is_empty());
20933            let materialization_root = root.join("session-environments");
20934            assert!(!std::fs::read_dir(&materialization_root)
20935                .unwrap()
20936                .filter_map(Result::ok)
20937                .any(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())));
20938            drop(shared);
20939            drop(runtime);
20940            let _ = std::fs::remove_dir_all(root);
20941        }
20942    }
20943
20944    #[cfg(feature = "fixture")]
20945    #[test]
20946    fn codex_bundle_exact_home_profile_materializes_only_the_skill_tree() {
20947        let (shared, runtime, receipt, bundle, root) =
20948            codex_bundle_test_shared(CodexHomeProfile::Exact);
20949        let address = SessionAddress {
20950            workspace_id: WorkspaceId::new("primary").unwrap(),
20951            session: SessionKey {
20952                instance_id: AgentInstanceId(310),
20953                generation: SessionGeneration(1),
20954            },
20955        };
20956        let (overlay, guard) = shared
20957            .prepare_session_materialization(
20958                &address,
20959                &agent("codex"),
20960                SessionMode::Pty,
20961                Some(&receipt),
20962                Some(&bundle.receipt()),
20963                None,
20964                None,
20965            )
20966            .unwrap()
20967            .unwrap();
20968        assert!(matches!(overlay, Some(PreparedNativeLaunchOverlay::Instance(_))));
20969        let ownership = shared
20970            .materializations
20971            .lock()
20972            .unwrap_or_else(|poisoned| poisoned.into_inner())
20973            .get(guard.id().unwrap())
20974            .unwrap()
20975            .clone();
20976        let (profile, _, _) = shared
20977            .materialization_profile(
20978                &agent("codex"),
20979                SessionMode::Pty,
20980                Some(&receipt),
20981                Some(&bundle.receipt()),
20982                None,
20983            )
20984            .unwrap()
20985            .unwrap();
20986        let environment = shared
20987            .session_environment_materializer
20988            .as_ref()
20989            .unwrap()
20990            .resolve_environment(&ownership, &profile)
20991            .unwrap();
20992        assert!(environment.iter().any(|mutation| {
20993            mutation.key == OsString::from("CODEX_HOME")
20994                && mutation.value.as_deref() == Some(ownership.provider_home().as_os_str())
20995        }));
20996        assert_eq!(
20997            std::fs::read(ownership.provider_home().join("skills/review-code/SKILL.md"))
20998                .unwrap(),
20999            b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n",
21000        );
21001        assert!(std::fs::read_dir(ownership.bundle_root())
21002            .unwrap()
21003            .next()
21004            .is_none());
21005        drop(guard);
21006        drop(shared);
21007        drop(runtime);
21008        let _ = std::fs::remove_dir_all(root);
21009    }
21010
21011    #[cfg(feature = "fixture")]
21012    #[test]
21013    fn codex_cold_record_resume_revalidates_the_isolated_skill_tree() {
21014        let (shared, runtime, receipt, bundle, root) =
21015            codex_bundle_test_shared(CodexHomeProfile::Exact);
21016        let bundle_receipt = bundle.receipt();
21017        let address = SessionAddress {
21018            workspace_id: WorkspaceId::new("primary").unwrap(),
21019            session: SessionKey {
21020                instance_id: AgentInstanceId(311),
21021                generation: SessionGeneration(1),
21022            },
21023        };
21024        let (_overlay, guard) = shared
21025            .prepare_session_materialization(
21026                &address,
21027                &agent("codex"),
21028                SessionMode::Pty,
21029                Some(&receipt),
21030                Some(&bundle_receipt),
21031                None,
21032                None,
21033            )
21034            .unwrap()
21035            .unwrap();
21036        let materialization_id = guard.id().unwrap().clone();
21037        let skill_path = shared
21038            .materializations
21039            .lock()
21040            .unwrap_or_else(|poisoned| poisoned.into_inner())
21041            .get(&materialization_id)
21042            .unwrap()
21043            .provider_home()
21044            .join("skills/review-code/SKILL.md");
21045        let record_id = shared
21046            .bind_spawn_session_with_materialization(
21047                &address,
21048                agent("codex"),
21049                SessionMode::Pty,
21050                ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap(),
21051                SpawnRecordPolicy::ProviderIdentityOnly,
21052                Some(receipt.clone()),
21053                Some(bundle_receipt.clone()),
21054                None,
21055                Some(materialization_id.clone()),
21056            )
21057            .unwrap()
21058            .unwrap();
21059        guard.retain();
21060        std::fs::write(skill_path, b"changed-after-record-bind").unwrap();
21061
21062        let error = match shared.resolve_record_materialization(
21063                &record_id,
21064                &agent("codex"),
21065                SessionMode::Pty,
21066                Some(&receipt),
21067                Some(&bundle_receipt),
21068                None,
21069            ) {
21070                Ok(_) => panic!("changed Codex skill tree unexpectedly revalidated"),
21071                Err(error) => error,
21072            };
21073        assert_eq!(error.code, NodeFailureCode::BundleMaterializationFailed);
21074        assert_eq!(
21075            shared
21076                .materializations
21077                .lock()
21078                .unwrap_or_else(|poisoned| poisoned.into_inner())
21079                .get(&materialization_id)
21080                .unwrap()
21081                .state(),
21082            MaterializationState::RecoveryRequired,
21083        );
21084        drop(shared);
21085        drop(runtime);
21086        let _ = std::fs::remove_dir_all(root);
21087    }
21088
21089    fn bind_record_owned_materialization_fixture(
21090        shared: &NodeShared,
21091        receipt: &ResolvedEnvironmentProfileReceipt,
21092        instance_id: u64,
21093    ) -> (SessionAddress, SessionRecordId, MaterializationId, PathBuf) {
21094        let address = SessionAddress {
21095            workspace_id: WorkspaceId::new("primary").unwrap(),
21096            session: SessionKey {
21097                instance_id: AgentInstanceId(instance_id),
21098                generation: SessionGeneration(1),
21099            },
21100        };
21101        let (overlay, materialization_guard) = shared
21102            .prepare_session_materialization(
21103                &address,
21104                &agent("claude"),
21105                SessionMode::Pty,
21106                Some(receipt),
21107                None,
21108                None,
21109                None,
21110            )
21111            .unwrap()
21112            .unwrap();
21113        let materialization_id = materialization_guard.id().unwrap().clone();
21114        let materialization_root = shared
21115            .materializations
21116            .lock()
21117            .unwrap_or_else(|poisoned| poisoned.into_inner())
21118            .get(&materialization_id)
21119            .unwrap()
21120            .root()
21121            .to_path_buf();
21122        let selection = shared
21123            .select_environment_profile(
21124                address.session.instance_id,
21125                &agent("claude"),
21126                SessionMode::Pty,
21127                Some(receipt),
21128            )
21129            .unwrap()
21130            .unwrap();
21131        assert!(shared
21132            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21133            .unwrap()
21134            .is_none());
21135        let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
21136        let record_id = shared
21137            .bind_spawn_session_with_materialization(
21138                &address,
21139                agent("claude"),
21140                SessionMode::Pty,
21141                policy,
21142                SpawnRecordPolicy::ProviderIdentityOnly,
21143                Some(receipt.clone()),
21144                None,
21145                None,
21146                Some(materialization_id.clone()),
21147            )
21148            .unwrap()
21149            .unwrap();
21150        selection.retain();
21151        materialization_guard.retain();
21152        (address, record_id, materialization_id, materialization_root)
21153    }
21154
21155    #[test]
21156    fn child_environment_profile_capability_is_state_aware_and_legacy_safe() {
21157        let (shared, _runtime, environment_profile) = environment_profile_test_shared();
21158        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21159            capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
21160        }));
21161        let support = node_compatibility_support_for_manifest(&[], &[]).unwrap();
21162        assert_eq!(support.state_schema.versions.maximum(), NODE_STATE_SCHEMA_V10);
21163        let mut spec = spawn_spec_fixture();
21164        spec.expected_profile_revision =
21165            crate::protocol::SpawnProfileRevision::new("test-r1").unwrap();
21166        assert!(matches!(
21167            &spec.overrides.environment_profile_id,
21168            crate::protocol::SpawnOverride::Inherit,
21169        ));
21170        assert!(!NodeRequest::SpawnSpec { spec: spec.clone() }
21171            .requires_child_environment_profile_capability());
21172        assert!(request_requires_child_environment_profile(
21173            &shared,
21174            &NodeRequest::SpawnSpec { spec: spec.clone() },
21175        ));
21176
21177        let address = terminal_address(1);
21178        shared.bind_session_with_policy(
21179            &address,
21180            ProviderRuntimePolicy::raw_pty(),
21181            Some(environment_profile.clone()),
21182        );
21183        assert!(request_requires_child_environment_profile(
21184            &shared,
21185            &NodeRequest::Input {
21186                session: address.clone(),
21187                text: "x".to_owned(),
21188            },
21189        ));
21190
21191        let mut record = record("claude", "profile-record");
21192        record.active_session = Some(address.clone());
21193        record.environment_profile = Some(environment_profile.clone());
21194        let record_id = record.record_id.clone();
21195        shared.insert_record(record.clone()).unwrap();
21196        assert!(request_requires_child_environment_profile(
21197            &shared,
21198            &NodeRequest::ResumeSessionRecord {
21199                record_id,
21200                terminal_size: gate4agent_types::TerminalSize {
21201                    rows: 24,
21202                    columns: 80,
21203                },
21204                initial_prompt: None,
21205            },
21206        ));
21207
21208        let snapshot = snapshot_for_wire(
21209            &shared, false, true, true, true, false, false, false, false, true,
21210        );
21211        assert_eq!(snapshot.session_records.len(), 1);
21212        assert!(snapshot.session_records[0].environment_profile.is_none());
21213        let event = project_event_without_child_environment_profile(NodeEventEnvelope {
21214            sequence: 1,
21215            event: NodeEvent::SessionRecordUpserted { record },
21216        });
21217        let NodeEvent::SessionRecordUpserted { record } = event.event else {
21218            panic!("record projection changed the event kind");
21219        };
21220        assert!(record.environment_profile.is_none());
21221
21222        let resolved = shared.resolve_spawn_spec(&spec).unwrap();
21223        let mut reply = ResponseEnvelope {
21224            request_id: 1,
21225            result: Ok(NodeResponse::SpawnSpecAccepted {
21226                receipt: resolved.receipt_with_environment(
21227                    shared.incarnation_id,
21228                    address,
21229                    Some(environment_profile),
21230                ),
21231            }),
21232        };
21233        project_response_without_child_environment_profile(&mut reply);
21234        assert_eq!(
21235            reply.result.unwrap_err().code,
21236            NodeFailureCode::UnsupportedCapability,
21237        );
21238    }
21239
21240    #[test]
21241    fn spawn_profile_environment_authority_is_exact_and_legacy_safe() {
21242        let (shared, _runtime, environment_profile) = environment_profile_test_shared();
21243        let snapshot = shared.snapshot();
21244        let profiles = snapshot
21245            .launch_inventory
21246            .as_ref()
21247            .and_then(|inventory| inventory.spawn_profiles.as_ref())
21248            .unwrap();
21249        assert_eq!(profiles.len(), 1);
21250        assert_eq!(profiles[0].environment_profile, Some(environment_profile));
21251        assert!(snapshot.requires_child_environment_profile_capability());
21252
21253        let mut legacy = snapshot.clone();
21254        clear_snapshot_child_environment_profiles(&mut legacy);
21255        assert!(legacy
21256            .launch_inventory
21257            .as_ref()
21258            .unwrap()
21259            .spawn_profiles
21260            .as_ref()
21261            .unwrap()[0]
21262            .environment_profile
21263            .is_none());
21264        assert!(!legacy.requires_child_environment_profile_capability());
21265
21266        let profile = shared.spawn_profiles.iter().next().unwrap().clone();
21267        let bindings = shared
21268            .environment_profiles
21269            .read()
21270            .unwrap_or_else(|poisoned| poisoned.into_inner())
21271            .clone();
21272        let mut provider_mismatch = bindings.clone();
21273        provider_mismatch
21274            .values_mut()
21275            .next()
21276            .unwrap()
21277            .provider = agent("codex");
21278        assert!(resolved_spawn_profile_summary(&profile, &provider_mismatch).is_none());
21279        let mut mode_mismatch = profile;
21280        mode_mismatch.mode = SessionMode::Inline;
21281        assert!(resolved_spawn_profile_summary(&mode_mismatch, &bindings).is_none());
21282
21283        shared
21284            .environment_profiles
21285            .write()
21286            .unwrap_or_else(|poisoned| poisoned.into_inner())
21287            .clear();
21288        assert!(shared
21289            .snapshot()
21290            .launch_inventory
21291            .unwrap()
21292            .spawn_profiles
21293            .unwrap()
21294            .is_empty());
21295    }
21296
21297    #[test]
21298    fn session_bundle_capability_is_state_aware_and_legacy_safe() {
21299        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21300            capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
21301        }));
21302        let shared = terminal_test_shared();
21303        let bundle = ResolvedBundleReceipt {
21304            id: crate::protocol::SpawnBundleId::new("review-bundle").unwrap(),
21305            revision: crate::protocol::SpawnBundleRevision::new("r1").unwrap(),
21306            digest: crate::protocol::SpawnBundleDigest::new(format!(
21307                "sha256:{}",
21308                "0".repeat(64),
21309            ))
21310            .unwrap(),
21311        };
21312        let mut bundled = record("claude", "bundle-record");
21313        bundled.bundle = Some(bundle.clone());
21314        let record_id = bundled.record_id.clone();
21315        shared.insert_record(bundled.clone()).unwrap();
21316        assert!(request_requires_session_bundle(
21317            &shared,
21318            &NodeRequest::ForgetSessionRecord {
21319                record_id: record_id.clone(),
21320            },
21321        ));
21322
21323        let snapshot = snapshot_for_wire(
21324            &shared, false, true, true, true, true, false, false, false, true,
21325        );
21326        assert!(snapshot.session_records[0].bundle.is_none());
21327        let launch = snapshot.launch_inventory.as_ref().unwrap();
21328        assert!(launch.spawn_profiles.is_some());
21329        assert!(launch.bundles.is_none());
21330        let event = project_event_without_session_bundle(NodeEventEnvelope {
21331            sequence: 1,
21332            event: NodeEvent::SessionRecordUpserted { record: bundled },
21333        });
21334        let NodeEvent::SessionRecordUpserted { record } = event.event else {
21335            panic!("record projection changed the event kind");
21336        };
21337        assert!(record.bundle.is_none());
21338
21339        let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
21340        let mut reply = ResponseEnvelope {
21341            request_id: 1,
21342            result: Ok(NodeResponse::SpawnSpecAccepted {
21343                receipt: resolved.receipt_with_materialization(
21344                    shared.incarnation_id,
21345                    terminal_address(17),
21346                    None,
21347                    Some(bundle),
21348                    None,
21349                ),
21350            }),
21351        };
21352        project_response_without_session_bundle(&mut reply);
21353        assert_eq!(
21354            reply.result.unwrap_err().code,
21355            NodeFailureCode::UnsupportedCapability,
21356        );
21357    }
21358
21359    #[test]
21360    fn history_context_pack_capability_redacts_loaded_messages_and_legacy_metadata() {
21361        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21362            capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
21363        }));
21364        let context_id = SpawnContextId::new("context-review").unwrap();
21365        let context = ResolvedContextPackReceipt {
21366            id: context_id.clone(),
21367            digest: crate::protocol::SpawnContextDigest::new(format!(
21368                "sha256:{}",
21369                "c".repeat(64),
21370            ))
21371            .unwrap(),
21372            lineage: ContextPackLineageReceipt {
21373                source_node_id: NodeId::new("node-source").unwrap(),
21374                source_session: terminal_address(1),
21375                source_provider: agent("kimi"),
21376            },
21377            source_message_count: 2,
21378            retained_message_count: 2,
21379            byte_len: 256,
21380            truncated: false,
21381        };
21382        assert!(context.is_valid());
21383        let mut bound_record = record("codex", "context-record");
21384        bound_record.context_id = Some(context_id);
21385        bound_record.context = Some(context.clone());
21386        let session = gate4agent_types::SessionSnapshot {
21387            instance_id: AgentInstanceId(91),
21388            agent_id: agent("kimi"),
21389            transport: TransportKind::Pty,
21390            generation: SessionGeneration(3),
21391            status: SessionStatus::Running,
21392            pending_operation: None,
21393            pending_input: None,
21394            process_id: Some(9001),
21395            terminal_size: None,
21396            terminal_frame: None,
21397            terminal_stale: None,
21398            session_options: None,
21399            capabilities: gate4agent_types::CapabilitySnapshot::default(),
21400            history: gate4agent_types::HistorySnapshot {
21401                pending: None,
21402                candidates: vec![HistoryCandidateSummary {
21403                    id: "candidate-opaque".to_owned(),
21404                    session_id_hint: "session-hint".to_owned(),
21405                    modified_at_unix_ms: Some(1),
21406                }],
21407                loaded_candidate_id: Some("candidate-opaque".to_owned()),
21408                loaded: Some(HistorySessionRecord {
21409                    session_id: "private-vendor-session".to_owned(),
21410                    title: Some("private title".to_owned()),
21411                    cwd: Some(r"C:\private\history-root".to_owned()),
21412                    model: Some("private-model".to_owned()),
21413                    message_count: 2,
21414                    completed_turn_count: None,
21415                    total_tokens: 19,
21416                    messages: vec![
21417                        gate4agent_types::HistoryMessageRecord {
21418                            role: gate4agent_types::HistoryMessageRole::User,
21419                            text: "F7_PRIVATE_HISTORY_MESSAGE".to_owned(),
21420                        },
21421                        gate4agent_types::HistoryMessageRecord {
21422                            role: gate4agent_types::HistoryMessageRole::Assistant,
21423                            text: "private answer".to_owned(),
21424                        },
21425                    ],
21426                }),
21427                last_error: None,
21428            },
21429            resume: gate4agent_types::ResumeSnapshot::default(),
21430            foreground: gate4agent_types::ForegroundSnapshot::default(),
21431            provider: gate4agent_types::ProviderSnapshot::default(),
21432            screen_state: gate4agent_types::PtyScreenState::default(),
21433        };
21434        let original = NodeSnapshot {
21435            node_id: NodeId::new("node-terminal-test").unwrap(),
21436            enabled_providers: vec![agent("kimi"), agent("codex")],
21437            provider_runtime_statuses: ProviderRuntimeStatuses::default(),
21438            workspaces: vec![WorkspaceSnapshot {
21439                workspace_id: WorkspaceId::new("primary").unwrap(),
21440                canonical_root: opaque_windows_path(
21441                    std::env::current_dir().unwrap().to_string_lossy().into_owned(),
21442                ),
21443                sessions: vec![session],
21444                worktree_service_mode: None,
21445                managed_worktree_profiles: None,
21446            }],
21447            session_records: vec![bound_record],
21448            managed_worktrees: Vec::new(),
21449            launch_inventory: None,
21450            agent_progress: Vec::new(),
21451        };
21452
21453        let mut negotiated = original.clone();
21454        project_snapshot_history_for_wire(&mut negotiated, true);
21455        assert_eq!(negotiated.workspaces[0].sessions[0].history.candidates.len(), 1);
21456        assert!(negotiated.workspaces[0].sessions[0].history.loaded.is_none());
21457        assert_eq!(negotiated.session_records[0].context.as_ref(), Some(&context));
21458        assert!(!serde_json::to_string(&negotiated)
21459            .unwrap()
21460            .contains("F7_PRIVATE_HISTORY_MESSAGE"));
21461
21462        let mut legacy = original;
21463        project_snapshot_history_for_wire(&mut legacy, false);
21464        clear_snapshot_context_packs(&mut legacy);
21465        assert_eq!(
21466            legacy.workspaces[0].sessions[0].history,
21467            gate4agent_types::HistorySnapshot::default(),
21468        );
21469        assert!(legacy.session_records[0].context_id.is_none());
21470        assert!(legacy.session_records[0].context.is_none());
21471        let encoded = serde_json::to_string(&legacy).unwrap();
21472        assert!(!encoded.contains("F7_PRIVATE_HISTORY_MESSAGE"));
21473        assert!(!encoded.contains("context-review"));
21474
21475        let mut reply = ResponseEnvelope {
21476            request_id: 7,
21477            result: Ok(NodeResponse::ContextPackExported { context }),
21478        };
21479        project_response_without_context_pack(&mut reply);
21480        assert_eq!(
21481            reply.result.unwrap_err().code,
21482            NodeFailureCode::UnsupportedCapability,
21483        );
21484    }
21485
21486    #[test]
21487    fn forgetting_unbound_context_pack_evicts_replay_but_retained_record_is_busy() {
21488        let shared = terminal_test_shared();
21489        let history = HistorySessionRecord {
21490            session_id: "fixture-history".to_owned(),
21491            title: None,
21492            cwd: None,
21493            model: None,
21494            message_count: 1,
21495            completed_turn_count: None,
21496            total_tokens: 0,
21497            messages: vec![gate4agent_types::HistoryMessageRecord {
21498                role: gate4agent_types::HistoryMessageRole::User,
21499                text: "bounded handoff".to_owned(),
21500            }],
21501        };
21502        let pack = NodeContextPack::export(
21503            ContextPackLineageReceipt {
21504                source_node_id: shared.node_id.clone(),
21505                source_session: terminal_address(1),
21506                source_provider: agent("claude"),
21507            },
21508            &history,
21509        )
21510        .unwrap();
21511        let context = shared
21512            .context_catalog
21513            .write()
21514            .unwrap_or_else(|poisoned| poisoned.into_inner())
21515            .insert(pack)
21516            .unwrap();
21517        let mut retained = record("codex", "context-retained-record");
21518        retained.context_id = Some(context.id.clone());
21519        retained.context = Some(context.clone());
21520        let record_id = retained.record_id.clone();
21521        shared.insert_record(retained).unwrap();
21522        assert_eq!(
21523            shared.forget_context_pack(&context.id).unwrap_err().code,
21524            NodeFailureCode::ContextPackBusy,
21525        );
21526        assert!(shared
21527            .context_catalog
21528            .read()
21529            .unwrap_or_else(|poisoned| poisoned.into_inner())
21530            .get(&context.id)
21531            .is_some());
21532        shared
21533            .session_records
21534            .lock()
21535            .unwrap_or_else(|poisoned| poisoned.into_inner())
21536            .records
21537            .remove(&record_id);
21538
21539        let mut spec = spawn_spec_fixture();
21540        spec.overrides.context_id = crate::protocol::SpawnOverride::Set {
21541            value: context.id.clone(),
21542        };
21543        let resolved = shared.resolve_spawn_spec(&spec).unwrap();
21544        let receipt = resolved.receipt_with_materialization(
21545            shared.incarnation_id,
21546            terminal_address(2),
21547            None,
21548            None,
21549            Some(context.clone()),
21550        );
21551        assert!(receipt.context_binding_is_valid());
21552        shared.remember_spawn_spec(spec.clone(), Ok(receipt), Instant::now());
21553
21554        shared.forget_context_pack(&context.id).unwrap();
21555        assert!(shared
21556            .context_catalog
21557            .read()
21558            .unwrap_or_else(|poisoned| poisoned.into_inner())
21559            .get(&context.id)
21560            .is_none());
21561        assert!(shared
21562            .replay_spawn_spec(&spec, Instant::now())
21563            .unwrap()
21564            .is_none());
21565    }
21566
21567    #[test]
21568    fn environment_profile_selection_is_owned_by_the_exact_session_binding() {
21569        let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
21570        let address = terminal_address(1);
21571        let selection = shared
21572            .select_environment_profile(
21573                address.session.instance_id,
21574                &agent("claude"),
21575                SessionMode::Pty,
21576                Some(&environment_profile),
21577            )
21578            .unwrap()
21579            .unwrap();
21580        shared.bind_session_with_policy(
21581            &address,
21582            ProviderRuntimePolicy::raw_pty(),
21583            Some(environment_profile.clone()),
21584        );
21585        selection.retain();
21586        let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
21587        assert!(matches!(
21588            runtime.remove_native_launch_profile(&native_id),
21589            Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
21590        ));
21591        assert!(shared.remove_binding(&address).is_some());
21592        assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
21593
21594        let mismatched = ResolvedEnvironmentProfileReceipt {
21595            profile_id: environment_profile.profile_id,
21596            profile_revision: crate::protocol::SpawnEnvironmentProfileRevision::new(
21597                "local-claude-r2",
21598            )
21599            .unwrap(),
21600            network_allowlist: None,
21601            browser_profile_id: None,
21602        };
21603        let error = match shared.select_environment_profile(
21604                AgentInstanceId(42),
21605                &agent("claude"),
21606                SessionMode::Pty,
21607                Some(&mismatched),
21608            ) {
21609            Ok(_) => panic!("mismatched environment profile revision was selected"),
21610            Err(error) => error,
21611        };
21612        assert_eq!(
21613            error.code,
21614            NodeFailureCode::EnvironmentProfileBindingMismatch,
21615        );
21616        assert!(!shared
21617            .session_bindings
21618            .lock()
21619            .unwrap_or_else(|poisoned| poisoned.into_inner())
21620            .contains_key(&AgentInstanceId(42)));
21621    }
21622
21623    #[test]
21624    fn combined_environment_and_bundle_overlay_clears_with_its_binding() {
21625        let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
21626        let address = terminal_address(1);
21627        let selection = shared
21628            .select_environment_profile(
21629                address.session.instance_id,
21630                &agent("claude"),
21631                SessionMode::Pty,
21632                Some(&environment_profile),
21633            )
21634            .unwrap()
21635            .unwrap();
21636        let overlay = NativeInstanceLaunchOverlay::new(
21637            agent("claude"),
21638            TransportKind::Pty,
21639            vec![EnvMutation {
21640                key: OsString::from("GATE4AGENT_SESSION_TOKEN"),
21641                value: Some(OsString::from("fixture-secret")),
21642            }],
21643            vec![
21644                OsString::from("--plugin-dir"),
21645                std::env::current_dir().unwrap().into_os_string(),
21646            ],
21647        )
21648        .unwrap();
21649        let instance_overlay = shared
21650            .install_prepared_launch_overlay(
21651                address.session.instance_id,
21652                PreparedNativeLaunchOverlay::Instance(overlay),
21653            )
21654            .unwrap()
21655            .unwrap();
21656        let bundle = ResolvedBundleReceipt {
21657            id: crate::protocol::SpawnBundleId::new("combined-bundle").unwrap(),
21658            revision: crate::protocol::SpawnBundleRevision::new("combined-r1").unwrap(),
21659            digest: crate::protocol::SpawnBundleDigest::new(format!(
21660                "sha256:{}",
21661                "0".repeat(64),
21662            ))
21663            .unwrap(),
21664        };
21665        shared.bind_session_with_materialization(
21666            &address,
21667            ProviderRuntimePolicy::raw_pty(),
21668            Some(environment_profile),
21669            Some(bundle),
21670            None,
21671            None,
21672        );
21673        selection.retain();
21674        instance_overlay.retain();
21675
21676        let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
21677        assert!(matches!(
21678            runtime.remove_native_launch_profile(&native_id),
21679            Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
21680        ));
21681        assert!(shared.remove_binding(&address).is_some());
21682        assert!(!shared
21683            .native_launch_profile_control
21684            .as_ref()
21685            .unwrap()
21686            .clear_native_instance_launch_overlay(address.session.instance_id));
21687        assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
21688    }
21689
21690    #[test]
21691    fn session_environment_materialization_failure_is_pre_child_and_durable() {
21692        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
21693        let address = SessionAddress {
21694            workspace_id: WorkspaceId::new("primary").unwrap(),
21695            session: SessionKey {
21696                instance_id: AgentInstanceId(201),
21697                generation: SessionGeneration(1),
21698            },
21699        };
21700        let error = match shared.prepare_session_materialization(
21701            &address,
21702            &agent("claude"),
21703            SessionMode::Pty,
21704            Some(&receipt),
21705            None,
21706            None,
21707            None,
21708        ) {
21709            Ok(_) => panic!("denied local reference unexpectedly materialized"),
21710            Err(error) => error,
21711        };
21712        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21713        assert!(shared.handle.snapshot().sessions.is_empty());
21714        assert!(shared
21715            .session_bindings
21716            .lock()
21717            .unwrap_or_else(|poisoned| poisoned.into_inner())
21718            .is_empty());
21719        assert!(shared
21720            .materializations
21721            .lock()
21722            .unwrap_or_else(|poisoned| poisoned.into_inner())
21723            .is_empty());
21724        drop(shared);
21725        drop(runtime);
21726        std::fs::remove_dir_all(root).unwrap();
21727    }
21728
21729    #[test]
21730    fn session_environment_binding_retains_on_stop_and_cleans_after_remove_reap() {
21731        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21732        let address = SessionAddress {
21733            workspace_id: WorkspaceId::new("primary").unwrap(),
21734            session: SessionKey {
21735                instance_id: AgentInstanceId(202),
21736                generation: SessionGeneration(1),
21737            },
21738        };
21739        let (overlay, materialization_guard) = shared
21740            .prepare_session_materialization(
21741                &address,
21742                &agent("claude"),
21743                SessionMode::Pty,
21744                Some(&receipt),
21745                None,
21746                None,
21747                None,
21748            )
21749            .unwrap()
21750            .unwrap();
21751        let materialization_id = materialization_guard.id().unwrap().clone();
21752        let materialization_root = shared
21753            .materializations
21754            .lock()
21755            .unwrap_or_else(|poisoned| poisoned.into_inner())
21756            .get(&materialization_id)
21757            .unwrap()
21758            .root()
21759            .to_path_buf();
21760        let selection = shared
21761            .select_environment_profile(
21762                address.session.instance_id,
21763                &agent("claude"),
21764                SessionMode::Pty,
21765                Some(&receipt),
21766            )
21767            .unwrap()
21768            .unwrap();
21769        assert!(shared
21770            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21771            .unwrap()
21772            .is_none());
21773        shared.bind_session_with_materialization(
21774            &address,
21775            ProviderRuntimePolicy::raw_pty(),
21776            Some(receipt),
21777            None,
21778            None,
21779            Some(materialization_id),
21780        );
21781        selection.retain();
21782        materialization_guard.retain();
21783
21784        assert!(materialization_root.is_dir());
21785        assert!(shared
21786            .session_bindings
21787            .lock()
21788            .unwrap_or_else(|poisoned| poisoned.into_inner())
21789            .contains_key(&address.session.instance_id));
21790
21791        let removed = shared.remove_binding(&address).unwrap();
21792        shared
21793            .cleanup_session_owned_materialization(&address, &removed)
21794            .unwrap();
21795        assert!(!materialization_root.exists());
21796        assert!(shared
21797            .materializations
21798            .lock()
21799            .unwrap_or_else(|poisoned| poisoned.into_inner())
21800            .is_empty());
21801        drop(shared);
21802        drop(runtime);
21803        std::fs::remove_dir_all(root).unwrap();
21804    }
21805
21806    #[tokio::test]
21807    async fn record_owned_session_environment_reuses_on_resume_and_forget_cleans() {
21808        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21809        let address = SessionAddress {
21810            workspace_id: WorkspaceId::new("primary").unwrap(),
21811            session: SessionKey {
21812                instance_id: AgentInstanceId(203),
21813                generation: SessionGeneration(1),
21814            },
21815        };
21816        let (overlay, materialization_guard) = shared
21817            .prepare_session_materialization(
21818                &address,
21819                &agent("claude"),
21820                SessionMode::Pty,
21821                Some(&receipt),
21822                None,
21823                None,
21824                None,
21825            )
21826            .unwrap()
21827            .unwrap();
21828        let materialization_id = materialization_guard.id().unwrap().clone();
21829        let materialization_root = shared
21830            .materializations
21831            .lock()
21832            .unwrap_or_else(|poisoned| poisoned.into_inner())
21833            .get(&materialization_id)
21834            .unwrap()
21835            .root()
21836            .to_path_buf();
21837        let selection = shared
21838            .select_environment_profile(
21839                address.session.instance_id,
21840                &agent("claude"),
21841                SessionMode::Pty,
21842                Some(&receipt),
21843            )
21844            .unwrap()
21845            .unwrap();
21846        assert!(shared
21847            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21848            .unwrap()
21849            .is_none());
21850        let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
21851        let record_id = shared
21852            .bind_spawn_session_with_materialization(
21853                &address,
21854                agent("claude"),
21855                SessionMode::Pty,
21856                policy,
21857                SpawnRecordPolicy::ProviderIdentityOnly,
21858                Some(receipt.clone()),
21859                None,
21860                None,
21861                Some(materialization_id.clone()),
21862            )
21863            .unwrap()
21864            .unwrap();
21865        selection.retain();
21866        materialization_guard.retain();
21867        assert_eq!(
21868            shared
21869                .materializations
21870                .lock()
21871                .unwrap_or_else(|poisoned| poisoned.into_inner())
21872                .get(&materialization_id)
21873                .unwrap()
21874                .owner(),
21875            &MaterializationOwner::Record {
21876                record_id: record_id.clone(),
21877            },
21878        );
21879
21880        let removed = shared.remove_binding(&address).unwrap();
21881        shared
21882            .cleanup_session_owned_materialization(&address, &removed)
21883            .unwrap();
21884        assert!(materialization_root.is_dir());
21885        assert!(shared
21886            .resolve_record_materialization(
21887                &record_id,
21888                &agent("claude"),
21889                SessionMode::Pty,
21890                Some(&receipt),
21891                None,
21892                None,
21893            )
21894            .unwrap()
21895            .is_some());
21896        {
21897            let mut records = shared
21898                .session_records
21899                .lock()
21900                .unwrap_or_else(|poisoned| poisoned.into_inner());
21901            let record = records.records.get_mut(&record_id).unwrap();
21902            record.active_session = None;
21903            record.state = ManagedSessionState::Unavailable;
21904        }
21905        shared.forget_session_record(&record_id).await.unwrap();
21906        assert!(!materialization_root.exists());
21907        assert!(shared
21908            .materializations
21909            .lock()
21910            .unwrap_or_else(|poisoned| poisoned.into_inner())
21911            .is_empty());
21912        drop(shared);
21913        drop(runtime);
21914        std::fs::remove_dir_all(root).unwrap();
21915    }
21916
21917    #[test]
21918    fn record_materialization_revalidation_failure_persists_recovery_required() {
21919        let (mut shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21920        let state_path = root.join("node-state-v6.json");
21921        shared.state_path = Some(state_path.clone());
21922        let (_address, _record_id, materialization_id, materialization_root) =
21923            bind_record_owned_materialization_fixture(&shared, &receipt, 205);
21924        std::fs::write(
21925            materialization_root.join(".gate4agent-materialization-owner"),
21926            b"tampered",
21927        )
21928        .unwrap();
21929
21930        let error = match shared.resolve_record_materialization(
21931            &_record_id,
21932            &agent("claude"),
21933            SessionMode::Pty,
21934            Some(&receipt),
21935            None,
21936            None,
21937        ) {
21938            Ok(_) => panic!("tampered owner marker unexpectedly revalidated"),
21939            Err(error) => error,
21940        };
21941        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21942        assert_eq!(
21943            shared
21944                .materializations
21945                .lock()
21946                .unwrap_or_else(|poisoned| poisoned.into_inner())
21947                .get(&materialization_id)
21948                .unwrap()
21949                .state(),
21950            MaterializationState::RecoveryRequired,
21951        );
21952        let loaded = session_registry::load(Some(&state_path), &shared.node_id).unwrap();
21953        assert_eq!(loaded.materializations.len(), 1);
21954        assert_eq!(
21955            loaded.materializations[0].state(),
21956            MaterializationState::RecoveryRequired,
21957        );
21958        drop(shared);
21959        drop(runtime);
21960        std::fs::remove_dir_all(root).unwrap();
21961    }
21962
21963    #[test]
21964    fn transient_record_materialization_resolution_failure_remains_ready() {
21965        let (shared, runtime, receipt, root, deny) = materialization_test_shared(false);
21966        let (_address, record_id, materialization_id, _materialization_root) =
21967            bind_record_owned_materialization_fixture(&shared, &receipt, 206);
21968        deny.store(true, Ordering::Release);
21969
21970        let error = match shared.resolve_record_materialization(
21971            &record_id,
21972            &agent("claude"),
21973            SessionMode::Pty,
21974            Some(&receipt),
21975            None,
21976            None,
21977        ) {
21978            Ok(_) => panic!("denied transient resolver unexpectedly succeeded"),
21979            Err(error) => error,
21980        };
21981        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21982        assert_eq!(
21983            shared
21984                .materializations
21985                .lock()
21986                .unwrap_or_else(|poisoned| poisoned.into_inner())
21987                .get(&materialization_id)
21988                .unwrap()
21989                .state(),
21990            MaterializationState::Ready,
21991        );
21992        drop(shared);
21993        drop(runtime);
21994        std::fs::remove_dir_all(root).unwrap();
21995    }
21996
21997    #[test]
21998    fn startup_missing_record_materialization_is_not_resumable() {
21999        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
22000        let record_id = SessionRecordId::new("sr-missing-materialization").unwrap();
22001        shared
22002            .insert_record(ManagedSessionRecord {
22003                record_id: record_id.clone(),
22004                display_name: "missing materialization".to_owned(),
22005                provider: agent("claude"),
22006                mode: SessionMode::Pty,
22007                state: ManagedSessionState::Dormant,
22008                workspace_id: WorkspaceId::new("primary").unwrap(),
22009                canonical_root: opaque_windows_path(
22010                    std::env::current_dir().unwrap().to_string_lossy().into_owned(),
22011                ),
22012                provider_session: Some(gate4agent_types::ProviderSessionIdentity {
22013                    key: gate4agent_types::ProviderSessionKey::SessionId,
22014                    id: "provider-session-missing-materialization".to_owned(),
22015                    transcript_path: None,
22016                }),
22017                active_session: None,
22018                environment_profile: Some(receipt),
22019                bundle: None,
22020                context_id: None,
22021                context: None,
22022                exported_context: None,
22023                task_binding: None,
22024                created_at_unix_ms: 1,
22025                updated_at_unix_ms: 1,
22026                last_error: None,
22027            })
22028            .unwrap();
22029
22030        shared.reconcile_materializations();
22031        let record = shared.record(&record_id).unwrap();
22032        assert_eq!(record.state, ManagedSessionState::Unavailable);
22033        assert!(record.provider_session.is_none());
22034        assert_eq!(
22035            record.last_error.as_deref(),
22036            Some("environment-profile-unavailable"),
22037        );
22038        drop(shared);
22039        drop(runtime);
22040        std::fs::remove_dir_all(root).unwrap();
22041    }
22042
22043    #[tokio::test]
22044    async fn managed_worktree_cleanup_waits_for_session_environment_cleanup() {
22045        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
22046        let lease = install_managed_lease(&shared);
22047        let address = SessionAddress {
22048            workspace_id: WorkspaceId::new("primary").unwrap(),
22049            session: SessionKey {
22050                instance_id: AgentInstanceId(204),
22051                generation: SessionGeneration(1),
22052            },
22053        };
22054        let (overlay, materialization_guard) = shared
22055            .prepare_session_materialization(
22056                &address,
22057                &agent("claude"),
22058                SessionMode::Pty,
22059                Some(&receipt),
22060                None,
22061                None,
22062                Some(lease.lease_id.clone()),
22063            )
22064            .unwrap()
22065            .unwrap();
22066        let materialization_id = materialization_guard.id().unwrap().clone();
22067        let materialization_root = shared
22068            .materializations
22069            .lock()
22070            .unwrap_or_else(|poisoned| poisoned.into_inner())
22071            .get(&materialization_id)
22072            .unwrap()
22073            .root()
22074            .to_path_buf();
22075        let selection = shared
22076            .select_environment_profile(
22077                address.session.instance_id,
22078                &agent("claude"),
22079                SessionMode::Pty,
22080                Some(&receipt),
22081            )
22082            .unwrap()
22083            .unwrap();
22084        assert!(shared
22085            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
22086            .unwrap()
22087            .is_none());
22088        shared.bind_session_with_materialization(
22089            &address,
22090            ProviderRuntimePolicy::raw_pty(),
22091            Some(receipt),
22092            None,
22093            None,
22094            Some(materialization_id),
22095        );
22096        selection.retain();
22097        materialization_guard.retain();
22098
22099        let blocked = shared
22100            .cleanup_managed_worktree(&lease.lease_id, false)
22101            .await
22102            .unwrap_err();
22103        assert_eq!(blocked.code, NodeFailureCode::ManagedWorktreeRecoveryRequired);
22104        assert!(materialization_root.is_dir());
22105        let removed = shared.remove_binding(&address).unwrap();
22106        shared
22107            .cleanup_session_owned_materialization(&address, &removed)
22108            .unwrap();
22109        assert!(!materialization_root.exists());
22110        drop(shared);
22111        drop(runtime);
22112        std::fs::remove_dir_all(root).unwrap();
22113    }
22114
22115    #[tokio::test]
22116    async fn raw_managed_record_is_inventory_only_and_does_not_block_session_cleanup() {
22117        let git_root = temporary_workspace_root("raw-managed-inventory");
22118        let source = git_root.join("source");
22119        let allocation = git_root.join("allocation");
22120        std::fs::create_dir_all(&source).unwrap();
22121        std::fs::create_dir_all(&allocation).unwrap();
22122        run_test_git(&source, &["init"]);
22123        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
22124        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
22125        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
22126        run_test_git(&source, &["add", "seed.txt"]);
22127        run_test_git(&source, &["commit", "-m", "seed"]);
22128        let profile = ManagedWorktreeProfile::new(
22129            WorktreeProfileId::new("default").unwrap(),
22130            crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
22131            &allocation,
22132            "gate4agent",
22133            "HEAD",
22134            ManagedWorktreeRetention::RemoveWhenReleased,
22135        )
22136        .unwrap();
22137        let workspace = WorkspaceConfig::new(
22138            WorkspaceId::new("primary").unwrap(),
22139            &source,
22140        )
22141        .unwrap()
22142        .with_worktree_service_mode(WorktreeServiceMode::Managed)
22143        .with_managed_worktree_profile(profile.clone())
22144        .unwrap();
22145        let (shared, mut runtime, receipt, materialization_fixture_root, _deny) =
22146            materialization_test_shared_with_workspace(false, workspace);
22147        let target = PathBuf::from(profile.allocation_root()).join("mw-raw-inventory");
22148        let source_text = source.to_string_lossy().into_owned();
22149        let target_text = target.to_string_lossy().into_owned();
22150        let created = create_git_worktree(
22151            &source_text,
22152            &target_text,
22153            "gate4agent/mw-raw-inventory",
22154            Some("HEAD"),
22155        )
22156        .await
22157        .unwrap();
22158        let lease = ManagedWorktreeLeaseRecord {
22159            lease_id: ManagedWorktreeLeaseId::new("mw-raw-inventory").unwrap(),
22160            source_workspace_id: WorkspaceId::new("primary").unwrap(),
22161            workspace_id: WorkspaceId::new("managed-raw-inventory").unwrap(),
22162            profile_id: profile.profile_id().clone(),
22163            profile_revision: profile.revision().clone(),
22164            target_root: created.path.clone(),
22165            branch: "gate4agent/mw-raw-inventory".to_owned(),
22166            base_commit: created.head.clone(),
22167            expected_head: Some(created.head.clone()),
22168            retention: ManagedWorktreeRetention::RemoveWhenReleased,
22169            state: ManagedWorktreeLeaseState::Ready,
22170            session_holders: Vec::new(),
22171            record_holders: Vec::new(),
22172            cleanup_failure: None,
22173            created_at_unix_ms: 1,
22174            updated_at_unix_ms: 1,
22175        };
22176        shared
22177            .workspaces
22178            .write()
22179            .unwrap_or_else(|poisoned| poisoned.into_inner())
22180            .insert(lease.workspace_id.clone(), created.path.clone());
22181        *shared
22182            .managed_worktrees
22183            .lock()
22184            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22185            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
22186        let address = SessionAddress {
22187            workspace_id: lease.workspace_id.clone(),
22188            session: SessionKey {
22189                instance_id: AgentInstanceId(205),
22190                generation: SessionGeneration(1),
22191            },
22192        };
22193        let (overlay, materialization_guard) = shared
22194            .prepare_session_materialization(
22195                &address,
22196                &agent("claude"),
22197                SessionMode::Pty,
22198                Some(&receipt),
22199                None,
22200                None,
22201                Some(lease.lease_id.clone()),
22202            )
22203            .unwrap()
22204            .unwrap();
22205        let materialization_id = materialization_guard.id().unwrap().clone();
22206        let materialization_root = shared
22207            .materializations
22208            .lock()
22209            .unwrap_or_else(|poisoned| poisoned.into_inner())
22210            .get(&materialization_id)
22211            .unwrap()
22212            .root()
22213            .to_path_buf();
22214        let selection = shared
22215            .select_environment_profile(
22216                address.session.instance_id,
22217                &agent("claude"),
22218                SessionMode::Pty,
22219                Some(&receipt),
22220            )
22221            .unwrap()
22222            .unwrap();
22223        assert!(shared
22224            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
22225            .unwrap()
22226            .is_none());
22227        let record_id = shared
22228            .bind_spawn_session_with_materialization(
22229                &address,
22230                agent("claude"),
22231                SessionMode::Pty,
22232                ProviderRuntimePolicy::raw_pty(),
22233                SpawnRecordPolicy::Always,
22234                Some(receipt.clone()),
22235                None,
22236                None,
22237                Some(materialization_id.clone()),
22238            )
22239            .unwrap()
22240            .unwrap();
22241        selection.retain();
22242        materialization_guard.retain();
22243        let record = shared.record(&record_id).unwrap();
22244        assert_eq!(record.state, ManagedSessionState::Live);
22245        assert!(record.provider_session.is_none());
22246        assert!(record.environment_profile.is_none());
22247        assert!(record.bundle.is_none());
22248        assert!(record.context_id.is_none());
22249        assert!(record.context.is_none());
22250        assert_eq!(record.active_session.as_ref(), Some(&address));
22251        assert_eq!(
22252            shared
22253                .materializations
22254                .lock()
22255                .unwrap_or_else(|poisoned| poisoned.into_inner())
22256                .get(&materialization_id)
22257                .unwrap()
22258                .owner(),
22259            &MaterializationOwner::Session {
22260                incarnation_id: shared.incarnation_id,
22261                instance_id: address.session.instance_id,
22262                generation: address.session.generation,
22263            },
22264        );
22265        {
22266            let mut bindings = shared
22267                .session_bindings
22268                .lock()
22269                .unwrap_or_else(|poisoned| poisoned.into_inner());
22270            bindings
22271                .get_mut(&address.session.instance_id)
22272                .unwrap()
22273                .managed_worktree_lease_id = Some(lease.lease_id.clone());
22274        }
22275        let lease_snapshot = shared
22276            .managed_worktrees
22277            .lock()
22278            .unwrap_or_else(|poisoned| poisoned.into_inner())
22279            .bind_session(
22280                &lease.lease_id,
22281                ManagedWorktreeSessionHolder {
22282                    incarnation_id: shared.incarnation_id,
22283                    instance_id: address.session.instance_id,
22284                    generation: address.session.generation,
22285                },
22286                None,
22287                unix_time_ms(),
22288            )
22289            .unwrap();
22290        assert_eq!(lease_snapshot.active_session_count, 1);
22291        assert_eq!(lease_snapshot.managed_record_count, 0);
22292        shared.persist_state().unwrap();
22293        assert!(shared
22294            .managed_worktrees
22295            .lock()
22296            .unwrap_or_else(|poisoned| poisoned.into_inner())
22297            .get(&lease.lease_id)
22298            .unwrap()
22299            .record_holders
22300            .is_empty());
22301        shared
22302            .handle
22303            .dispatch(shared.prepare_command(ControlCommand::Register {
22304                instance_id: address.session.instance_id,
22305                agent_id: agent("claude"),
22306                transport: TransportKind::Pty,
22307            }))
22308            .unwrap();
22309        runtime.tick().await;
22310        shared.reconcile_managed_record(
22311            &record_id,
22312            &address,
22313            &ControlEvent {
22314                sequence: 1,
22315                command_id: None,
22316                instance_id: address.session.instance_id,
22317                generation: address.session.generation,
22318                event: ControlEventKind::Removed,
22319            },
22320            false,
22321            false,
22322        );
22323        let remove = shared.remove_session(&address);
22324        let drive_runtime = async {
22325            runtime.tick().await;
22326        };
22327        let (removed, ()) = tokio::join!(remove, drive_runtime);
22328        removed.unwrap();
22329
22330        assert!(!materialization_root.exists());
22331        assert!(!Path::new(&created.path).exists());
22332        assert_eq!(
22333            shared
22334                .managed_worktrees
22335                .lock()
22336                .unwrap_or_else(|poisoned| poisoned.into_inner())
22337                .get(&lease.lease_id)
22338                .unwrap()
22339                .state,
22340            ManagedWorktreeLeaseState::Removed,
22341        );
22342        let renamed = shared
22343            .rename_session_record(&record_id, "raw managed inventory".to_owned())
22344            .unwrap();
22345        assert_eq!(renamed.record_id, record_id);
22346        assert_eq!(renamed.display_name, "raw managed inventory");
22347        assert_eq!(renamed.state, ManagedSessionState::Unavailable);
22348        assert!(renamed.active_session.is_none());
22349
22350        drop(shared);
22351        drop(runtime);
22352        std::fs::remove_dir_all(materialization_fixture_root).unwrap();
22353        std::fs::remove_dir_all(git_root).unwrap();
22354    }
22355
22356    fn install_managed_lease(shared: &NodeShared) -> ManagedWorktreeLeaseRecord {
22357        let root = std::env::current_dir().unwrap()
22358            .join("managed-test-target")
22359            .to_string_lossy()
22360            .into_owned();
22361        let lease = ManagedWorktreeLeaseRecord {
22362            lease_id: ManagedWorktreeLeaseId::new("mw-test").unwrap(),
22363            source_workspace_id: WorkspaceId::new("primary").unwrap(),
22364            workspace_id: WorkspaceId::new("managed-test").unwrap(),
22365            profile_id: WorktreeProfileId::new("default").unwrap(),
22366            profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
22367            target_root: root.clone(),
22368            branch: "gate4agent/mw-test".to_owned(),
22369            base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
22370            expected_head: Some("0123456789abcdef0123456789abcdef01234567".to_owned()),
22371            retention: ManagedWorktreeRetention::RemoveWhenReleased,
22372            state: ManagedWorktreeLeaseState::Ready,
22373            session_holders: Vec::new(),
22374            record_holders: Vec::new(),
22375            cleanup_failure: None,
22376            created_at_unix_ms: 1,
22377            updated_at_unix_ms: 1,
22378        };
22379        shared.workspaces.write()
22380            .unwrap_or_else(|poisoned| poisoned.into_inner())
22381            .insert(lease.workspace_id.clone(), root);
22382        *shared.managed_worktrees.lock()
22383            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22384            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
22385        lease
22386    }
22387
22388    #[cfg(feature = "fixture")]
22389    #[test]
22390    fn monitoring_fixture_managed_spawn_retains_effective_record_holder_and_converges_live() {
22391        let mut shared = terminal_test_shared();
22392        shared.fixture_semantic_hook_policy = true;
22393        let lease = install_managed_lease(&shared);
22394        let address = SessionAddress {
22395            workspace_id: lease.workspace_id.clone(),
22396            session: SessionKey {
22397                instance_id: AgentInstanceId(206),
22398                generation: SessionGeneration(1),
22399            },
22400        };
22401        let pre_admission_policy = ProviderRuntimePolicy::raw_pty();
22402        let effective_runtime_policy =
22403            shared.effective_spawn_runtime_policy(pre_admission_policy);
22404        assert_ne!(effective_runtime_policy, pre_admission_policy);
22405        assert!(effective_runtime_policy.provider_session_identity);
22406
22407        let record_id = shared
22408            .bind_spawn_session(
22409                &address,
22410                agent("claude"),
22411                SessionMode::Pty,
22412                effective_runtime_policy,
22413                SpawnRecordPolicy::Always,
22414                None,
22415            )
22416            .unwrap()
22417            .unwrap();
22418        assert_eq!(
22419            shared.record(&record_id).unwrap().state,
22420            ManagedSessionState::IdentityPending,
22421        );
22422        let (record_holder, raw_inventory_record_id) = {
22423            let mut bindings = shared
22424                .session_bindings
22425                .lock()
22426                .unwrap_or_else(|poisoned| poisoned.into_inner());
22427            let binding = bindings.get_mut(&address.session.instance_id).unwrap();
22428            binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
22429            managed_spawn_record_ownership(binding, effective_runtime_policy)
22430        };
22431        assert_eq!(record_holder.as_ref(), Some(&record_id));
22432        assert!(raw_inventory_record_id.is_none());
22433        let snapshot = shared
22434            .managed_worktrees
22435            .lock()
22436            .unwrap_or_else(|poisoned| poisoned.into_inner())
22437            .bind_session(
22438                &lease.lease_id,
22439                ManagedWorktreeSessionHolder {
22440                    incarnation_id: shared.incarnation_id,
22441                    instance_id: address.session.instance_id,
22442                    generation: address.session.generation,
22443                },
22444                record_holder,
22445                unix_time_ms(),
22446            )
22447            .unwrap();
22448        assert_eq!(snapshot.managed_record_count, 1);
22449        assert_eq!(
22450            shared
22451                .managed_worktrees
22452                .lock()
22453                .unwrap_or_else(|poisoned| poisoned.into_inner())
22454                .get(&lease.lease_id)
22455                .unwrap()
22456                .record_holders,
22457            vec![record_id.clone()],
22458        );
22459
22460        let identity = ProviderSessionIdentity {
22461            key: ProviderSessionKey::SessionId,
22462            id: "monitoring-fixture-managed-session".to_owned(),
22463            transcript_path: None,
22464        };
22465        shared.publish_control(ControlEvent {
22466            sequence: 1,
22467            command_id: None,
22468            instance_id: address.session.instance_id,
22469            generation: address.session.generation,
22470            event: ControlEventKind::ProviderEvent {
22471                sequence: 1,
22472                source: gate4agent_types::ProviderSource {
22473                    family: AdapterFamily::Hook,
22474                    binding: AdapterBinding::new(
22475                        gate4agent_types::AdapterId::new("claude-code").unwrap(),
22476                        "fixture/v1",
22477                        gate4agent_types::AdapterVerification::SyntheticFixture,
22478                    )
22479                    .unwrap(),
22480                },
22481                source_sequence: 1,
22482                event: ProviderEvent::SessionIdentityObserved {
22483                    identity: identity.clone(),
22484                },
22485            },
22486        });
22487
22488        let live = shared.record(&record_id).unwrap();
22489        assert_eq!(live.state, ManagedSessionState::Live);
22490        assert_eq!(live.provider_session.as_ref(), Some(&identity));
22491        assert_eq!(live.active_session.as_ref(), Some(&address));
22492        assert_eq!(
22493            shared
22494                .managed_worktrees
22495                .lock()
22496                .unwrap_or_else(|poisoned| poisoned.into_inner())
22497                .get(&lease.lease_id)
22498                .unwrap()
22499                .record_holders,
22500            vec![record_id],
22501        );
22502    }
22503
22504    fn managed_v2_test_shared(
22505        allocation_root: &Path,
22506        state_path: Option<PathBuf>,
22507        profile_revision: &str,
22508    ) -> NodeShared {
22509        let profile = ManagedWorktreeProfile::new(
22510            WorktreeProfileId::new("default").unwrap(),
22511            WorktreeProfileRevision::new(profile_revision).unwrap(),
22512            allocation_root,
22513            "gate4agent",
22514            "HEAD",
22515            ManagedWorktreeRetention::RemoveWhenReleased,
22516        )
22517        .unwrap();
22518        let workspace = WorkspaceConfig::new(
22519            WorkspaceId::new("primary").unwrap(),
22520            std::env::current_dir().unwrap(),
22521        )
22522        .unwrap()
22523        .with_worktree_service_mode(WorktreeServiceMode::Managed)
22524        .with_managed_worktree_profile(profile)
22525        .unwrap();
22526        let catalog = active_registry().unwrap();
22527        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22528        let mut shared = NodeShared::new(
22529            handle,
22530            "fixture-token".to_owned(),
22531            NodeId::new("node-terminal-test").unwrap(),
22532            vec![workspace],
22533            vec![agent("claude")],
22534        );
22535        shared.state_path = state_path;
22536        shared
22537    }
22538
22539    fn managed_v2_request(idempotency_key: &str) -> ManagedWorktreeSpawnRequestV2 {
22540        let mut spawn_spec = spawn_spec_fixture();
22541        spawn_spec.idempotency_key = SpawnIdempotencyKey::new(idempotency_key).unwrap();
22542        ManagedWorktreeSpawnRequestV2 {
22543            spawn_spec,
22544            worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
22545            expected_profile_revision: WorktreeProfileRevision::new("v1").unwrap(),
22546        }
22547    }
22548
22549    #[test]
22550    fn managed_spawn_receipt_rebinds_allocated_worktree_and_roundtrips_wire() {
22551        let shared = terminal_test_shared();
22552        let mut lease = install_managed_lease(&shared);
22553        let session = SessionAddress {
22554            workspace_id: lease.workspace_id.clone(),
22555            session: SessionKey {
22556                instance_id: AgentInstanceId(73),
22557                generation: SessionGeneration(4),
22558            },
22559        };
22560        lease.state = ManagedWorktreeLeaseState::InUse;
22561        lease.session_holders.push(ManagedWorktreeSessionHolder {
22562            incarnation_id: shared.incarnation_id,
22563            instance_id: session.session.instance_id,
22564            generation: session.session.generation,
22565        });
22566        let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
22567        assert!(resolved.target.worktree_id.is_none());
22568
22569        let receipt = managed_spawn_receipt(
22570            &resolved,
22571            shared.incarnation_id,
22572            session.clone(),
22573            lease.snapshot(),
22574            None,
22575            None,
22576            None,
22577        );
22578        assert!(resolved.target.worktree_id.is_none());
22579        assert_eq!(receipt.spawn.target.workspace_id, lease.source_workspace_id);
22580        assert_eq!(receipt.spawn.target.worktree_id.as_ref(), Some(&lease.workspace_id));
22581        assert_eq!(receipt.spawn.session, session);
22582        assert_eq!(receipt.lease.workspace_id, lease.workspace_id);
22583        let encoded = serde_json::to_vec(&receipt).unwrap();
22584        let decoded: ManagedWorktreeSpawnReceipt = serde_json::from_slice(&encoded).unwrap();
22585        assert_eq!(decoded, receipt);
22586    }
22587
22588    #[tokio::test]
22589    async fn managed_spawn_v2_rejects_profile_revision_before_allocation() {
22590        let root = temporary_workspace_root("managed-v2-revision");
22591        let allocation = root.join("allocation");
22592        std::fs::create_dir_all(&allocation).unwrap();
22593        let shared = managed_v2_test_shared(
22594            &allocation,
22595            Some(root.join("node-state.json")),
22596            "v1",
22597        );
22598        let mut request = managed_v2_request("managed-v2-revision");
22599        request.expected_profile_revision = WorktreeProfileRevision::new("v2").unwrap();
22600
22601        let failure = shared.spawn_managed_worktree_v2(request).await.unwrap_err();
22602
22603        assert_eq!(
22604            failure.code,
22605            NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
22606        );
22607        assert!(shared
22608            .managed_worktrees
22609            .lock()
22610            .unwrap_or_else(|poisoned| poisoned.into_inner())
22611            .records()
22612            .is_empty());
22613        assert!(shared
22614            .managed_spawn_replays
22615            .lock()
22616            .unwrap_or_else(|poisoned| poisoned.into_inner())
22617            .is_empty());
22618        std::fs::remove_dir_all(root).unwrap();
22619    }
22620
22621    #[test]
22622    fn managed_spawn_v2_concurrent_same_key_has_one_durable_reservation() {
22623        let root = temporary_workspace_root("managed-v2-concurrent");
22624        let allocation = root.join("allocation");
22625        std::fs::create_dir_all(&allocation).unwrap();
22626        let shared = Arc::new(managed_v2_test_shared(
22627            &allocation,
22628            Some(root.join("node-state.json")),
22629            "v1",
22630        ));
22631        let request = managed_v2_request("managed-v2-concurrent");
22632        let barrier = Arc::new(std::sync::Barrier::new(3));
22633        let mut joins = Vec::new();
22634        for _ in 0..2 {
22635            let shared = Arc::clone(&shared);
22636            let request = request.clone();
22637            let barrier = Arc::clone(&barrier);
22638            joins.push(std::thread::spawn(move || {
22639                let digest = managed_spawn_request_digest_v2(&request).unwrap();
22640                barrier.wait();
22641                shared.acquire_durable_managed_spawn_v2(&request, digest)
22642            }));
22643        }
22644        barrier.wait();
22645        let results = joins
22646            .into_iter()
22647            .map(|join| join.join().unwrap())
22648            .collect::<Vec<_>>();
22649        assert_eq!(
22650            results
22651                .iter()
22652                .filter(|result| {
22653                    matches!(result, Ok(DurableManagedSpawnReplayDecision::Reserved))
22654                })
22655                .count(),
22656            1,
22657        );
22658        assert_eq!(
22659            results
22660                .iter()
22661                .filter(|result| {
22662                    matches!(result, Err(error) if error.code == NodeFailureCode::BackendBusy)
22663                })
22664                .count(),
22665            1,
22666        );
22667        assert_eq!(
22668            shared
22669                .managed_spawn_replays
22670                .lock()
22671                .unwrap_or_else(|poisoned| poisoned.into_inner())
22672                .len(),
22673            1,
22674        );
22675        assert!(shared
22676            .managed_worktrees
22677            .lock()
22678            .unwrap_or_else(|poisoned| poisoned.into_inner())
22679            .records()
22680            .is_empty());
22681        drop(shared);
22682        std::fs::remove_dir_all(root).unwrap();
22683    }
22684
22685    #[tokio::test]
22686    async fn managed_spawn_v2_linked_pending_reopen_fails_closed_without_reallocation() {
22687        let root = temporary_workspace_root("managed-v2-linked-pending");
22688        let allocation = root.join("allocation");
22689        std::fs::create_dir_all(&allocation).unwrap();
22690        let state_path = root.join("node-state.json");
22691        let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
22692        let lease = install_managed_lease(&shared);
22693        let request = managed_v2_request("managed-v2-linked-pending");
22694        assert!(matches!(
22695            shared
22696                .acquire_durable_managed_spawn_v2(
22697                    &request,
22698                    managed_spawn_request_digest_v2(&request).unwrap(),
22699                )
22700                .unwrap(),
22701            DurableManagedSpawnReplayDecision::Reserved,
22702        ));
22703        {
22704            let _transaction = shared
22705                .state_transaction
22706                .lock()
22707                .unwrap_or_else(|poisoned| poisoned.into_inner());
22708            shared
22709                .link_durable_managed_spawn_v2_lease_locked(
22710                    &request.spawn_spec.idempotency_key,
22711                    &lease.lease_id,
22712                )
22713                .unwrap();
22714            shared.persist_state_locked().unwrap();
22715        }
22716        drop(shared);
22717
22718        let mut loaded = session_registry::load(
22719            Some(&state_path),
22720            &NodeId::new("node-terminal-test").unwrap(),
22721        )
22722        .unwrap();
22723        let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22724        *reopened
22725            .managed_worktrees
22726            .lock()
22727            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22728            ManagedWorktreeRegistry::from_records(
22729                std::mem::take(&mut loaded.managed_worktrees),
22730                std::mem::take(&mut loaded.managed_worktree_tombstones),
22731            )
22732            .unwrap();
22733        *reopened
22734            .managed_spawn_replays
22735            .lock()
22736            .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22737            .managed_spawn_replays
22738            .into_iter()
22739            .map(|record| (record.idempotency_key.clone(), record))
22740            .collect();
22741        let lease_count = reopened
22742            .managed_worktrees
22743            .lock()
22744            .unwrap_or_else(|poisoned| poisoned.into_inner())
22745            .records()
22746            .len();
22747
22748        let failure = reopened
22749            .spawn_managed_worktree_v2(request)
22750            .await
22751            .unwrap_err();
22752
22753        assert_eq!(
22754            failure.code,
22755            NodeFailureCode::ManagedWorktreeRecoveryRequired,
22756        );
22757        assert_eq!(
22758            reopened
22759                .managed_worktrees
22760                .lock()
22761                .unwrap_or_else(|poisoned| poisoned.into_inner())
22762                .records()
22763                .len(),
22764            lease_count,
22765        );
22766        std::fs::remove_dir_all(root).unwrap();
22767    }
22768
22769    #[tokio::test]
22770    async fn managed_spawn_v2_replays_exact_receipt_after_durable_reopen_and_conflicts() {
22771        let root = temporary_workspace_root("managed-v2-reopen");
22772        let allocation = root.join("allocation");
22773        std::fs::create_dir_all(&allocation).unwrap();
22774        let state_path = root.join("node-state.json");
22775        let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
22776        let lease = install_managed_lease(&shared);
22777        let request = managed_v2_request("managed-v2-reopen");
22778        let session = SessionAddress {
22779            workspace_id: lease.workspace_id.clone(),
22780            session: SessionKey {
22781                instance_id: AgentInstanceId(91),
22782                generation: SessionGeneration(1),
22783            },
22784        };
22785        let snapshot = {
22786            let mut registry = shared
22787                .managed_worktrees
22788                .lock()
22789                .unwrap_or_else(|poisoned| poisoned.into_inner());
22790            let current = registry.get_mut(&lease.lease_id).unwrap();
22791            current.state = ManagedWorktreeLeaseState::InUse;
22792            current.session_holders.push(ManagedWorktreeSessionHolder {
22793                incarnation_id: shared.incarnation_id,
22794                instance_id: session.session.instance_id,
22795                generation: session.session.generation,
22796            });
22797            current.snapshot()
22798        };
22799        let resolved = shared.resolve_spawn_spec(&request.spawn_spec).unwrap();
22800        let receipt = managed_spawn_receipt(
22801            &resolved,
22802            shared.incarnation_id,
22803            session,
22804            snapshot,
22805            None,
22806            None,
22807            None,
22808        );
22809        assert!(matches!(
22810            shared
22811                .acquire_durable_managed_spawn_v2(
22812                    &request,
22813                    managed_spawn_request_digest_v2(&request).unwrap(),
22814                )
22815                .unwrap(),
22816            DurableManagedSpawnReplayDecision::Reserved,
22817        ));
22818        {
22819            let _transaction = shared
22820                .state_transaction
22821                .lock()
22822                .unwrap_or_else(|poisoned| poisoned.into_inner());
22823            shared
22824                .link_durable_managed_spawn_v2_lease_locked(
22825                    &request.spawn_spec.idempotency_key,
22826                    &lease.lease_id,
22827                )
22828                .unwrap();
22829            shared.persist_state_locked().unwrap();
22830        }
22831        shared
22832            .commit_durable_managed_spawn_v2(&request, receipt.clone())
22833            .unwrap();
22834        let legacy_same_key = ManagedWorktreeSpawnRequest {
22835            spawn_spec: request.spawn_spec.clone(),
22836            worktree_profile_id: request.worktree_profile_id.clone(),
22837        };
22838        shared.remember_managed_spawn_attempt(
22839            Some(&request.spawn_spec.idempotency_key),
22840            legacy_same_key.clone(),
22841            Ok(receipt.clone()),
22842            Instant::now(),
22843        );
22844        assert!(shared
22845            .replay_managed_spawn(&legacy_same_key, Instant::now())
22846            .unwrap()
22847            .is_none());
22848        drop(shared);
22849
22850        let mut loaded = session_registry::load(
22851            Some(&state_path),
22852            &NodeId::new("node-terminal-test").unwrap(),
22853        )
22854        .unwrap();
22855        assert_eq!(loaded.managed_spawn_replays.len(), 1);
22856        let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22857        *reopened
22858            .managed_worktrees
22859            .lock()
22860            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22861            ManagedWorktreeRegistry::from_records(
22862                std::mem::take(&mut loaded.managed_worktrees),
22863                std::mem::take(&mut loaded.managed_worktree_tombstones),
22864            )
22865            .unwrap();
22866        *reopened
22867            .managed_spawn_replays
22868            .lock()
22869            .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22870            .managed_spawn_replays
22871            .into_iter()
22872            .map(|record| (record.idempotency_key.clone(), record))
22873            .collect();
22874        let lease_count_before = reopened
22875            .managed_worktrees
22876            .lock()
22877            .unwrap_or_else(|poisoned| poisoned.into_inner())
22878            .records()
22879            .len();
22880
22881        assert_eq!(
22882            reopened
22883                .spawn_managed_worktree_v2(request.clone())
22884                .await
22885                .unwrap(),
22886            receipt,
22887        );
22888        assert_eq!(
22889            reopened
22890                .managed_worktrees
22891                .lock()
22892                .unwrap_or_else(|poisoned| poisoned.into_inner())
22893                .records()
22894                .len(),
22895            lease_count_before,
22896        );
22897        let mut changed = request;
22898        changed.spawn_spec.deadline_ms = crate::protocol::SpawnDeadlineMs::new(29_000).unwrap();
22899        let failure = reopened
22900            .spawn_managed_worktree_v2(changed)
22901            .await
22902            .unwrap_err();
22903        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22904        assert_eq!(
22905            reopened
22906                .managed_worktrees
22907                .lock()
22908                .unwrap_or_else(|poisoned| poisoned.into_inner())
22909                .records()
22910                .len(),
22911            lease_count_before,
22912        );
22913        let mut new_key = managed_v2_request("managed-v2-new-after-profile-change");
22914        new_key.expected_profile_revision = WorktreeProfileRevision::new("v1").unwrap();
22915        let failure = reopened
22916            .spawn_managed_worktree_v2(new_key)
22917            .await
22918            .unwrap_err();
22919        assert_eq!(
22920            failure.code,
22921            NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
22922        );
22923        assert_eq!(
22924            reopened
22925                .managed_worktrees
22926                .lock()
22927                .unwrap_or_else(|poisoned| poisoned.into_inner())
22928                .records()
22929                .len(),
22930            lease_count_before,
22931        );
22932        assert_eq!(
22933            reopened
22934                .managed_spawn_replays
22935                .lock()
22936                .unwrap_or_else(|poisoned| poisoned.into_inner())
22937                .len(),
22938            1,
22939        );
22940        std::fs::remove_dir_all(root).unwrap();
22941    }
22942
22943    #[test]
22944    fn legacy_managed_spawn_idempotency_remains_transient_and_exact() {
22945        let shared = terminal_test_shared();
22946        let request = ManagedWorktreeSpawnRequest {
22947            spawn_spec: spawn_spec_fixture(),
22948            worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
22949        };
22950        let expected = failure(NodeFailureCode::BackendBusy, "legacy fixture failure");
22951        shared.remember_managed_spawn(
22952            request.clone(),
22953            Err(expected.clone()),
22954            Instant::now(),
22955        );
22956
22957        assert_eq!(
22958            shared
22959                .replay_managed_spawn(&request, Instant::now())
22960                .unwrap()
22961                .unwrap(),
22962            Err(expected),
22963        );
22964        assert!(shared
22965            .managed_spawn_replays
22966            .lock()
22967            .unwrap_or_else(|poisoned| poisoned.into_inner())
22968            .is_empty());
22969    }
22970
22971    #[tokio::test]
22972    async fn ordinary_spawn_and_spawn_spec_cannot_bypass_managed_lease_holder_fence() {
22973        let shared = terminal_test_shared();
22974        let lease = install_managed_lease(&shared);
22975        let raw = shared.spawn_session(
22976            lease.workspace_id.clone(),
22977            agent("claude"),
22978            SessionMode::Pty,
22979            gate4agent_types::TerminalSize { rows: 24, columns: 80 },
22980            None,
22981        ).await.unwrap_err();
22982        assert_eq!(raw.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22983        let mut spec = spawn_spec_fixture();
22984        spec.target.workspace_id = lease.workspace_id.clone();
22985        let specified = shared.spawn_from_spec(spec).await.unwrap_err();
22986        assert_eq!(specified.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22987        let mut selected = spawn_spec_fixture();
22988        selected.idempotency_key = SpawnIdempotencyKey::new("managed-selected-bypass").unwrap();
22989        selected.target.worktree_id = Some(lease.workspace_id);
22990        let selected = shared.spawn_from_spec(selected).await.unwrap_err();
22991        assert_eq!(selected.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22992        assert!(shared.handle.snapshot().sessions.is_empty());
22993    }
22994
22995    #[test]
22996    fn legacy_projection_omits_managed_snapshot_events_and_path_bearing_diagnostics() {
22997        let shared = terminal_test_shared();
22998        let lease = install_managed_lease(&shared);
22999        let snapshot = snapshot_for_wire(
23000            &shared, true, true, true, false, true, true, false, false, true,
23001        );
23002        assert!(snapshot.managed_worktrees.is_empty());
23003        assert!(snapshot.workspaces.iter().all(|workspace| {
23004            workspace.managed_worktree_profiles.is_none()
23005        }));
23006        let mut reply = ResponseEnvelope {
23007            request_id: 1,
23008            result: Ok(NodeResponse::Resync {
23009                event_sequence: 1,
23010                oldest_available_sequence: 1,
23011                snapshot: shared.snapshot(),
23012                events: vec![NodeEventEnvelope {
23013                    sequence: 1,
23014                    event: NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() },
23015                }],
23016            }),
23017        };
23018        project_response_without_managed_worktrees(&mut reply);
23019        let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
23020            panic!("projection changed the response kind");
23021        };
23022        assert!(snapshot.managed_worktrees.is_empty());
23023        assert!(events.is_empty());
23024        let mut git = git_snapshot_for_parser();
23025        git.branch = Some(lease.branch.clone());
23026        git.managed_worktree = Some(crate::protocol::ManagedWorktreeGitScope {
23027            lease_id: lease.lease_id.clone(),
23028            source_workspace_id: lease.source_workspace_id.clone(),
23029            branch: lease.branch.clone(),
23030            base_commit: GitObjectId::new(lease.base_commit.clone()).unwrap(),
23031            active_session_count: 1,
23032            managed_record_count: 0,
23033        });
23034        let mut inspection_reply = ResponseEnvelope {
23035            request_id: 2,
23036            result: Ok(NodeResponse::WorkspaceInspected {
23037                inspection: WorkspaceInspection {
23038                    workspace_id: lease.workspace_id.clone(),
23039                    entries: Vec::new(),
23040                    tree_truncated: false,
23041                    git,
23042                    truncation: None,
23043                },
23044            }),
23045        };
23046        project_response_without_managed_worktrees(&mut inspection_reply);
23047        let Ok(NodeResponse::WorkspaceInspected { inspection }) = inspection_reply.result else {
23048            panic!("projection changed the inspection response kind");
23049        };
23050        assert!(inspection.git.managed_worktree.is_none());
23051        let failure = managed_git_worktree_failure(GitWorktreeError {
23052            kind: GitWorktreeErrorKind::Failed,
23053            message: r"git failed at C:\private\managed-target".to_owned(),
23054        });
23055        assert!(!failure.message.contains("private"));
23056        assert!(!failure.message.contains("managed-target"));
23057    }
23058
23059    #[tokio::test]
23060    async fn restart_reconciliation_retains_exact_owned_worktree_and_explicit_cleanup_removes_it() {
23061        let root = temporary_workspace_root("managed-restart");
23062        let source = root.join("source");
23063        let allocation = root.join("allocation");
23064        std::fs::create_dir_all(&source).unwrap();
23065        std::fs::create_dir_all(&allocation).unwrap();
23066        run_test_git(&source, &["init"]);
23067        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
23068        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
23069        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
23070        run_test_git(&source, &["add", "seed.txt"]);
23071        run_test_git(&source, &["commit", "-m", "seed"]);
23072        let profile = ManagedWorktreeProfile::new(
23073            WorktreeProfileId::new("default").unwrap(),
23074            crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
23075            &allocation,
23076            "gate4agent",
23077            "HEAD",
23078            ManagedWorktreeRetention::Retain,
23079        ).unwrap();
23080        let workspace = WorkspaceConfig::new(
23081            WorkspaceId::new("primary").unwrap(),
23082            &source,
23083        ).unwrap()
23084            .with_worktree_service_mode(WorktreeServiceMode::Managed)
23085            .with_managed_worktree_profile(profile.clone()).unwrap();
23086        let target = PathBuf::from(profile.allocation_root()).join("mw-restart");
23087        let source_text = workspace.canonical_root().to_owned();
23088        let target_text = target.to_string_lossy().into_owned();
23089        let created = create_git_worktree(
23090            &source_text,
23091            &target_text,
23092            "gate4agent/mw-restart",
23093            Some("HEAD"),
23094        ).await.unwrap();
23095        let lease = ManagedWorktreeLeaseRecord {
23096            lease_id: ManagedWorktreeLeaseId::new("mw-restart").unwrap(),
23097            source_workspace_id: WorkspaceId::new("primary").unwrap(),
23098            workspace_id: WorkspaceId::new("managed-restart").unwrap(),
23099            profile_id: profile.profile_id().clone(),
23100            profile_revision: profile.revision().clone(),
23101            target_root: created.path.clone(),
23102            branch: "gate4agent/mw-restart".to_owned(),
23103            base_commit: created.head.clone(),
23104            expected_head: None,
23105            retention: ManagedWorktreeRetention::Retain,
23106            state: ManagedWorktreeLeaseState::Allocating,
23107            session_holders: Vec::new(),
23108            record_holders: Vec::new(),
23109            cleanup_failure: None,
23110            created_at_unix_ms: 1,
23111            updated_at_unix_ms: 1,
23112        };
23113        let catalog = active_registry().unwrap();
23114        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23115        let shared = NodeShared::new_with_incarnation(
23116            handle,
23117            "fixture-token".to_owned(),
23118            NodeId::new("node-managed-restart").unwrap(),
23119            NodeIncarnationId::from_bytes([7; crate::protocol::NODE_INCARNATION_ID_BYTES]),
23120            vec![workspace],
23121            vec![agent("claude")],
23122            ProviderRuntimeStatuses::default(),
23123            None,
23124            Vec::new(),
23125            Vec::new(),
23126            SpawnProfileRegistry::default(),
23127            None,
23128            None,
23129            None,
23130            None,
23131            Vec::new(),
23132            vec![lease.clone()],
23133            Vec::new(),
23134            Vec::new(),
23135            None,
23136            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
23137        );
23138        shared.reconcile_managed_worktrees().await;
23139        let retained = shared.managed_worktrees.lock()
23140            .unwrap_or_else(|poisoned| poisoned.into_inner())
23141            .get(&lease.lease_id).unwrap().clone();
23142        assert_eq!(retained.state, ManagedWorktreeLeaseState::Retained);
23143        assert!(shared.workspaces.read()
23144            .unwrap_or_else(|poisoned| poisoned.into_inner())
23145            .contains_key(&lease.workspace_id));
23146        let removed = shared.cleanup_managed_worktree(&lease.lease_id, true).await.unwrap();
23147        assert_eq!(removed.state, ManagedWorktreeLeaseState::Removed);
23148        assert!(!Path::new(&created.path).exists());
23149        std::fs::remove_dir_all(root).unwrap();
23150    }
23151
23152    fn run_test_git(root: &Path, arguments: &[&str]) {
23153        let output = std::process::Command::new("git")
23154            .arg("-C").arg(root).args(arguments).output().unwrap();
23155        assert!(
23156            output.status.success(),
23157            "git {:?} failed: {}",
23158            arguments,
23159            String::from_utf8_lossy(&output.stderr),
23160        );
23161    }
23162
23163    #[tokio::test]
23164    async fn spawn_spec_validation_fails_before_session_mutation() {
23165        let shared = terminal_test_shared();
23166        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23167            capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
23168        }));
23169        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23170            capability.as_str() == NODE_SPAWN_PROFILE_REVISION_CAPABILITY
23171        }));
23172        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23173            capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
23174        }));
23175
23176        let mut target_mismatch = spawn_spec_fixture();
23177        target_mismatch.target.node_id = NodeId::new("other-node").unwrap();
23178        let failure = shared.spawn_from_spec(target_mismatch).await.unwrap_err();
23179        assert_eq!(failure.code, NodeFailureCode::SpawnTargetMismatch);
23180
23181        let mut unknown_profile = spawn_spec_fixture();
23182        unknown_profile.idempotency_key = SpawnIdempotencyKey::new("unknown-profile").unwrap();
23183        unknown_profile.profile_id = crate::protocol::SpawnProfileId::new("missing").unwrap();
23184        let failure = shared.spawn_from_spec(unknown_profile).await.unwrap_err();
23185        assert_eq!(failure.code, NodeFailureCode::UnknownSpawnProfile);
23186
23187        let mut revision_mismatch = spawn_spec_fixture();
23188        revision_mismatch.idempotency_key =
23189            SpawnIdempotencyKey::new("profile-revision-mismatch").unwrap();
23190        revision_mismatch.expected_profile_revision =
23191            crate::protocol::SpawnProfileRevision::new("test-r2").unwrap();
23192        let failure = shared.spawn_from_spec(revision_mismatch).await.unwrap_err();
23193        assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
23194
23195        let mut unsupported_materializer = spawn_spec_fixture();
23196        unsupported_materializer.idempotency_key =
23197            SpawnIdempotencyKey::new("unsupported-materializer").unwrap();
23198        unsupported_materializer.overrides.bundle_id = crate::protocol::SpawnOverride::Set {
23199            value: crate::protocol::SpawnBundleId::new("bundle-v1").unwrap(),
23200        };
23201        let failure = shared
23202            .spawn_from_spec(unsupported_materializer)
23203            .await
23204            .unwrap_err();
23205        assert_eq!(failure.code, NodeFailureCode::UnknownBundle);
23206
23207        let failure = shared
23208            .spawn_session_with_deadline(
23209                WorkspaceId::new("primary").unwrap(),
23210                agent("claude"),
23211                SessionMode::Pty,
23212                gate4agent_types::TerminalSize {
23213                    rows: 24,
23214                    columns: 80,
23215                },
23216                None,
23217                None,
23218                None,
23219                None,
23220                None,
23221                None,
23222                SpawnRecordPolicy::ProviderIdentityOnly,
23223                Some(Instant::now()),
23224                &[],
23225                None,
23226                ApprovalLevel::default(),
23227                None,
23228                None,
23229            )
23230            .await
23231            .unwrap_err();
23232        assert_eq!(failure.code, NodeFailureCode::SpawnDeadlineExceeded);
23233        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
23234        assert!(shared.handle.snapshot().sessions.is_empty());
23235    }
23236
23237    #[test]
23238    fn worktree_selection_requires_its_separate_negotiated_capability() {
23239        let mut spec = spawn_spec_fixture();
23240        spec.target.worktree_id = Some(WorkspaceId::new("selected-worktree").unwrap());
23241        let request = NodeRequest::SpawnSpec { spec };
23242        let spawn_spec_capability =
23243            CapabilityId::new(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY).unwrap();
23244        assert!(request_uses_unnegotiated_capability(
23245            &request,
23246            &[spawn_spec_capability.clone()],
23247        ));
23248        let worktree_capability =
23249            CapabilityId::new(NODE_WORKTREE_SELECTION_CAPABILITY).unwrap();
23250        assert!(request_uses_unnegotiated_capability(
23251            &request,
23252            &[spawn_spec_capability.clone(), worktree_capability.clone()],
23253        ));
23254        let profile_revision_capability =
23255            CapabilityId::new(NODE_SPAWN_PROFILE_REVISION_CAPABILITY).unwrap();
23256        assert!(request_uses_unnegotiated_capability(
23257            &request,
23258            &[
23259                spawn_spec_capability.clone(),
23260                worktree_capability.clone(),
23261                profile_revision_capability.clone(),
23262            ],
23263        ));
23264        // Default `SpawnOverrides` leaves `bundle_id`/`context_id` at
23265        // `Inherit`, not `Clear` — the profile's own defaults might still
23266        // materialize a bundle or a context pack, so both capabilities stay
23267        // conservatively required until negotiated (see
23268        // `requires_session_bundle_materialization_capability` /
23269        // `requires_history_context_pack_capability`).
23270        let bundle_materialization_capability =
23271            CapabilityId::new(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY).unwrap();
23272        assert!(request_uses_unnegotiated_capability(
23273            &request,
23274            &[
23275                spawn_spec_capability.clone(),
23276                worktree_capability.clone(),
23277                profile_revision_capability.clone(),
23278                bundle_materialization_capability.clone(),
23279            ],
23280        ));
23281        let history_context_pack_capability =
23282            CapabilityId::new(NODE_HISTORY_CONTEXT_PACK_CAPABILITY).unwrap();
23283        assert!(!request_uses_unnegotiated_capability(
23284            &request,
23285            &[
23286                spawn_spec_capability,
23287                worktree_capability,
23288                profile_revision_capability,
23289                bundle_materialization_capability,
23290                history_context_pack_capability,
23291            ],
23292        ));
23293    }
23294
23295    #[tokio::test]
23296    async fn spawn_spec_idempotency_replays_receipt_and_conflicts_before_session_mutation() {
23297        let mut shared = terminal_test_shared();
23298        let spec = spawn_spec_fixture();
23299        let receipt = shared
23300            .resolve_spawn_spec(&spec)
23301            .unwrap()
23302            .receipt(shared.incarnation_id, terminal_address(1));
23303        shared.remember_spawn_spec(spec.clone(), Ok(receipt.clone()), Instant::now());
23304
23305        let mut revised = shared.spawn_profiles.get(&spec.profile_id).unwrap().clone();
23306        revised.revision = crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
23307        shared.spawn_profiles = SpawnProfileRegistry::new([revised]).unwrap();
23308
23309        assert_eq!(shared.spawn_from_spec(spec.clone()).await.unwrap(), receipt);
23310
23311        let mut revision_conflicting = spec.clone();
23312        revision_conflicting.expected_profile_revision =
23313            crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
23314        let failure = shared.spawn_from_spec(revision_conflicting).await.unwrap_err();
23315        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
23316
23317        let mut stale_cache_miss = spec.clone();
23318        stale_cache_miss.idempotency_key =
23319            SpawnIdempotencyKey::new("stale-profile-cache-miss").unwrap();
23320        let failure = shared.spawn_from_spec(stale_cache_miss).await.unwrap_err();
23321        assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
23322
23323        let mut conflicting = spec;
23324        conflicting.deadline_ms = crate::protocol::SpawnDeadlineMs::new(31_000).unwrap();
23325        let failure = shared.spawn_from_spec(conflicting).await.unwrap_err();
23326        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
23327        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
23328        assert!(shared.handle.snapshot().sessions.is_empty());
23329    }
23330
23331    #[tokio::test]
23332    async fn off_worktree_service_rejects_selection_and_mutation_before_git() {
23333        let shared = terminal_test_shared_with_mode(WorktreeServiceMode::Off);
23334        let mut spec = spawn_spec_fixture();
23335        spec.target.worktree_id = Some(WorkspaceId::new("unregistered-worktree").unwrap());
23336        let failure = shared.spawn_from_spec(spec).await.unwrap_err();
23337        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
23338
23339        let failure = shared
23340            .create_worktree(
23341                WorkspaceId::new("primary").unwrap(),
23342                WorkspaceId::new("new-worktree").unwrap(),
23343                "not-an-absolute-path".to_owned(),
23344                "invalid branch".to_owned(),
23345                None,
23346            )
23347            .await
23348            .unwrap_err();
23349        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
23350
23351        let failure = shared
23352            .remove_worktree(
23353                WorkspaceId::new("primary").unwrap(),
23354                "not-an-absolute-path".to_owned(),
23355            )
23356            .await
23357            .unwrap_err();
23358        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
23359        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
23360        assert!(shared.handle.snapshot().sessions.is_empty());
23361    }
23362
23363    #[tokio::test]
23364    async fn manual_worktree_registration_failure_removes_the_created_worktree() {
23365        let root = temporary_workspace_root("manual-worktree-compensation");
23366        let source = root.join("source");
23367        let target = root.join("target");
23368        let invalid_state_parent = root.join("state-parent-is-a-file");
23369        let invalid_state_path = invalid_state_parent.join("state.json");
23370        std::fs::create_dir_all(&source).unwrap();
23371        std::fs::write(&invalid_state_parent, "not a directory").unwrap();
23372        run_test_git(&source, &["init"]);
23373        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
23374        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
23375        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
23376        run_test_git(&source, &["add", "seed.txt"]);
23377        run_test_git(&source, &["commit", "-m", "seed"]);
23378
23379        let catalog = active_registry().unwrap();
23380        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23381        let workspace = WorkspaceConfig::new(
23382            WorkspaceId::new("primary").unwrap(),
23383            &source,
23384        ).unwrap();
23385        let mut shared = NodeShared::new(
23386            handle,
23387            "fixture-token".to_owned(),
23388            NodeId::new("node-worktree-compensation").unwrap(),
23389            vec![workspace],
23390            vec![agent("claude")],
23391        );
23392        shared.state_path = Some(invalid_state_path);
23393
23394        let error = shared.create_worktree(
23395            WorkspaceId::new("primary").unwrap(),
23396            WorkspaceId::new("compensated").unwrap(),
23397            target.to_string_lossy().into_owned(),
23398            "gate4agent/compensated".to_owned(),
23399            Some("HEAD".to_owned()),
23400        ).await.unwrap_err();
23401
23402        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
23403        assert!(!target.exists());
23404        assert!(list_git_worktrees(source.to_str().unwrap()).await.unwrap()
23405            .iter()
23406            .all(|worktree| !worktree_paths_equal(&worktree.path, &target.to_string_lossy())));
23407        std::fs::remove_dir_all(root).unwrap();
23408    }
23409
23410    #[test]
23411    fn selected_worktree_requires_authoritative_non_main_non_bare_entry() {
23412        fn worktree(path: &str, is_main: bool, is_bare: bool) -> NativeGitWorktreeSnapshot {
23413            NativeGitWorktreeSnapshot {
23414                path: path.to_owned(),
23415                head: "0123456789abcdef".to_owned(),
23416                branch: Some("refs/heads/test".to_owned()),
23417                is_bare,
23418                is_main,
23419                locked: false,
23420                lock_reason: None,
23421                prunable: false,
23422                prunable_reason: None,
23423                workspace_id: None,
23424            }
23425        }
23426
23427        let source = r"C:\repo";
23428        let selected = r"C:\repo-worktree";
23429        let listed = vec![
23430            worktree(source, true, false),
23431            worktree(selected, false, false),
23432        ];
23433        assert!(validate_selected_worktree(source, selected, &listed).is_ok());
23434
23435        let main_failure = validate_selected_worktree(source, source, &listed).unwrap_err();
23436        assert_eq!(main_failure.code, NodeFailureCode::WorktreeProtected);
23437
23438        let bare = vec![
23439            worktree(source, true, false),
23440            worktree(selected, false, true),
23441        ];
23442        let bare_failure = validate_selected_worktree(source, selected, &bare).unwrap_err();
23443        assert_eq!(bare_failure.code, NodeFailureCode::WorktreeProtected);
23444
23445        let foreign_failure =
23446            validate_selected_worktree(source, r"C:\foreign", &listed).unwrap_err();
23447        assert_eq!(foreign_failure.code, NodeFailureCode::WorktreeProtected);
23448    }
23449
23450    fn terminal_frame(sequence: u64) -> TerminalFrame {
23451        TerminalFrame {
23452            sequence,
23453            size: gate4agent_types::TerminalSize {
23454                rows: 24,
23455                columns: 80,
23456            },
23457            cursor_row: 2,
23458            cursor_column: 3,
23459            contents: format!("frame-{sequence}"),
23460            formatted: vec![1, 2, 3],
23461            scrollback_formatted: Vec::new(),
23462            alternate_screen: false,
23463            mouse_protocol_enabled: false,
23464            mouse_protocol_encoding: Default::default(),
23465            produced_at_unix_ms: 0,
23466            screen_state: gate4agent_types::PtyScreenState::default(),
23467            bracketed_paste: None,
23468        }
23469    }
23470
23471    #[test]
23472    fn terminal_frame_watermark_coalesces_and_resets_for_exact_session_address() {
23473        let shared = terminal_test_shared();
23474        let mut receiver = shared.terminal_event_tx.subscribe();
23475        let original = terminal_address(1);
23476
23477        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
23478        let first = receiver.try_recv().unwrap();
23479        assert_eq!(first.len(), 1);
23480        assert!(matches!(
23481            &first[0].event,
23482            NodeEvent::TerminalFrame { address, frame }
23483                if address == &original && frame.sequence == 7
23484        ));
23485
23486        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
23487        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(6))]);
23488        assert!(matches!(
23489            receiver.try_recv(),
23490            Err(broadcast::error::TryRecvError::Empty)
23491        ));
23492
23493        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(8))]);
23494        let advanced = receiver.try_recv().unwrap();
23495        assert_eq!(advanced[0].sequence, 2);
23496
23497        let resumed = terminal_address(2);
23498        shared.publish_terminal_frame_candidates(vec![(resumed.clone(), terminal_frame(1))]);
23499        let reset = receiver.try_recv().unwrap();
23500        assert_eq!(reset[0].sequence, 3);
23501        assert!(matches!(
23502            &reset[0].event,
23503            NodeEvent::TerminalFrame { address, frame }
23504                if address == &resumed && frame.sequence == 1
23505        ));
23506        shared.clear_terminal_frame_watermark(&resumed);
23507        assert!(!shared
23508            .terminal_frame_watermarks
23509            .lock()
23510            .unwrap_or_else(|poisoned| poisoned.into_inner())
23511            .contains_key(&resumed.session.instance_id));
23512    }
23513
23514    #[test]
23515    fn terminal_frame_batches_replace_without_entering_node_event_history() {
23516        let shared = terminal_test_shared();
23517        let mut receiver = shared.terminal_event_tx.subscribe();
23518        let address = terminal_address(1);
23519        let count = NODE_EVENT_HISTORY_MAX as u64 + 17;
23520
23521        for sequence in 1..=count {
23522            shared.publish_terminal_frame_candidates(vec![(
23523                address.clone(),
23524                terminal_frame(sequence),
23525            )]);
23526        }
23527
23528        let history = shared
23529            .history
23530            .lock()
23531            .unwrap_or_else(|poisoned| poisoned.into_inner());
23532        assert!(history.events.is_empty());
23533        assert_eq!(history.last_sequence, count);
23534        assert_eq!(history.replay_floor_sequence, 1);
23535        drop(history);
23536        let NodeResponse::Resync {
23537            event_sequence,
23538            oldest_available_sequence,
23539            events,
23540            ..
23541        } = shared.resync(0) else {
23542            unreachable!("resync helper returned another response")
23543        };
23544        assert_eq!(event_sequence, count);
23545        assert_eq!(oldest_available_sequence, 1);
23546        assert!(events.is_empty());
23547        assert!(matches!(
23548            resync_required_event(&shared).event,
23549            NodeEvent::ResyncRequired {
23550                oldest_available_sequence: 1,
23551            }
23552        ));
23553        assert!(matches!(
23554            receiver.try_recv(),
23555            Err(broadcast::error::TryRecvError::Lagged(skipped)) if skipped == count - 1
23556        ));
23557        let latest = receiver.try_recv().unwrap();
23558        assert_eq!(latest.len(), 1);
23559        assert_eq!(latest[0].sequence, count);
23560    }
23561
23562    #[test]
23563    fn real_durable_eviction_advances_authoritative_replay_floor() {
23564        let shared = terminal_test_shared();
23565        for _ in 0..=NODE_EVENT_HISTORY_MAX {
23566            shared.publish(NodeEvent::ControllerChanged { controller: None });
23567        }
23568
23569        let NodeResponse::Resync {
23570            event_sequence,
23571            oldest_available_sequence,
23572            events,
23573            ..
23574        } = shared.resync(0) else {
23575            unreachable!("resync helper returned another response")
23576        };
23577        assert_eq!(event_sequence, NODE_EVENT_HISTORY_MAX as u64 + 1);
23578        assert_eq!(oldest_available_sequence, 2);
23579        assert_eq!(events.first().map(|event| event.sequence), Some(2));
23580        assert_eq!(events.len(), NODE_EVENT_HISTORY_MAX);
23581        assert!(matches!(
23582            resync_required_event(&shared).event,
23583            NodeEvent::ResyncRequired {
23584                oldest_available_sequence: 2,
23585            }
23586        ));
23587    }
23588
23589    #[test]
23590    fn legacy_terminal_frame_connection_has_no_live_subscription() {
23591        let shared = terminal_test_shared();
23592        let without_capability = terminal_event_subscription(&shared, false);
23593        let with_capability = terminal_event_subscription(&shared, true);
23594        assert!(without_capability.is_none());
23595        assert!(with_capability.is_some());
23596        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23597            capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
23598        }));
23599    }
23600
23601    #[test]
23602    fn continuous_terminal_output_leaves_request_service_after_bounded_event_burst() {
23603        let mut pending = Vec::new();
23604        let mut event_sequence = 0_u64;
23605        for frame_sequence in 1..=4 {
23606            for instance in 1..=(NODE_CONNECTION_EVENT_BURST_MAX as u64 * 2) {
23607                event_sequence += 1;
23608                let mut address = terminal_address(1);
23609                address.session.instance_id = AgentInstanceId(instance);
23610                queue_connection_event(
23611                    &mut pending,
23612                    NodeEventEnvelope {
23613                        sequence: event_sequence,
23614                        event: NodeEvent::TerminalFrame {
23615                            address,
23616                            frame: terminal_frame(frame_sequence),
23617                        },
23618                    },
23619                    0,
23620                );
23621            }
23622        }
23623        assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX * 2);
23624        pending.sort_unstable_by_key(|event| event.sequence);
23625
23626        let mut uninterrupted = pending.clone();
23627        let first_burst = connection_event_burst_len(uninterrupted.len());
23628        uninterrupted.drain(..first_burst);
23629        let second_burst = connection_event_burst_len(uninterrupted.len());
23630        uninterrupted.drain(..second_burst);
23631        assert!(uninterrupted.is_empty());
23632
23633        let (frame_tx, mut frame_rx) =
23634            mpsc::channel::<Result<ClientFrame, FrameError>>(1);
23635        frame_tx
23636            .try_send(Ok(ClientFrame::Request(RequestEnvelope {
23637                request_id: 77,
23638                request: NodeRequest::Snapshot,
23639            })))
23640            .unwrap();
23641        let burst_len = connection_event_burst_len(pending.len());
23642        assert_eq!(burst_len, NODE_CONNECTION_EVENT_BURST_MAX);
23643        pending.drain(..burst_len);
23644        assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX);
23645        assert!(matches!(
23646            frame_rx.try_recv(),
23647            Ok(Ok(ClientFrame::Request(RequestEnvelope {
23648                request_id: 77,
23649                request: NodeRequest::Snapshot,
23650            })))
23651        ));
23652    }
23653
23654    #[test]
23655    fn terminal_lag_watermark_discards_retained_and_replayed_batches() {
23656        let mut pending = Vec::new();
23657        for sequence in [7_u64, 8] {
23658            let mut address = terminal_address(1);
23659            address.session.instance_id = AgentInstanceId(sequence);
23660            queue_connection_event(
23661                &mut pending,
23662                NodeEventEnvelope {
23663                    sequence,
23664                    event: NodeEvent::TerminalFrame {
23665                        address,
23666                        frame: terminal_frame(sequence),
23667                    },
23668                },
23669                0,
23670            );
23671        }
23672        let mut discard_through = 0;
23673        apply_connection_resync_watermark(&mut pending, &mut discard_through, 8);
23674        assert!(pending.is_empty());
23675
23676        let replayed = NodeEventEnvelope {
23677            sequence: 8,
23678            event: NodeEvent::TerminalFrame {
23679                address: terminal_address(1),
23680                frame: terminal_frame(8),
23681            },
23682        };
23683        let advanced = NodeEventEnvelope {
23684            sequence: 9,
23685            event: NodeEvent::TerminalFrame {
23686                address: terminal_address(2),
23687                frame: terminal_frame(1),
23688            },
23689        };
23690        queue_connection_event_batch(
23691            &mut pending,
23692            &[replayed, advanced.clone()],
23693            discard_through,
23694        );
23695        assert_eq!(pending, vec![advanced]);
23696    }
23697
23698    fn record(provider: &str, record_id: &str) -> ManagedSessionRecord {
23699        ManagedSessionRecord {
23700            record_id: SessionRecordId::new(record_id).unwrap(),
23701            display_name: format!("{provider} record"),
23702            provider: agent(provider),
23703            mode: SessionMode::Pty,
23704            state: ManagedSessionState::Unavailable,
23705            workspace_id: WorkspaceId::new("primary").unwrap(),
23706            canonical_root: opaque_windows_path(
23707                std::env::current_dir().unwrap().to_string_lossy().into_owned(),
23708            ),
23709            provider_session: None,
23710            active_session: None,
23711            environment_profile: None,
23712            bundle: None,
23713            context_id: None,
23714            context: None,
23715            exported_context: None,
23716            task_binding: None,
23717            created_at_unix_ms: 1,
23718            updated_at_unix_ms: 1,
23719            last_error: None,
23720        }
23721    }
23722
23723    fn retention_test_record(
23724        workspace_id: &str,
23725        record_id: &str,
23726        state: ManagedSessionState,
23727        updated_at_unix_ms: u64,
23728    ) -> ManagedSessionRecord {
23729        ManagedSessionRecord {
23730            record_id: SessionRecordId::new(record_id).unwrap(),
23731            display_name: format!("{record_id} display"),
23732            provider: agent("claude"),
23733            mode: SessionMode::Pty,
23734            state,
23735            workspace_id: WorkspaceId::new(workspace_id).unwrap(),
23736            canonical_root: opaque_windows_path(
23737                std::env::current_dir().unwrap().to_string_lossy().into_owned(),
23738            ),
23739            provider_session: None,
23740            active_session: None,
23741            environment_profile: None,
23742            bundle: None,
23743            context_id: None,
23744            context: None,
23745            exported_context: None,
23746            task_binding: None,
23747            created_at_unix_ms: updated_at_unix_ms,
23748            updated_at_unix_ms,
23749            last_error: None,
23750        }
23751    }
23752
23753    /// Two workspaces of three `Unavailable` records each, staggered by
23754    /// age, plus one never-touchable decoy per non-`Unavailable` state in
23755    /// `workspace-a` (all as old as that workspace's oldest real record --
23756    /// if the sweep's state gate ever leaked, these would be the first
23757    /// thing it wrongly retired). Ages, oldest to newest:
23758    /// `workspace-a`: a1 (500s), a2 (300s), a3 (100s).
23759    /// `workspace-b`: b1 (400s), b2 (200s), b3 (50s).
23760    fn retention_fixture_records(now: u64) -> Vec<ManagedSessionRecord> {
23761        vec![
23762            retention_test_record("workspace-a", "a1", ManagedSessionState::Unavailable, now - 500_000),
23763            retention_test_record("workspace-a", "a2", ManagedSessionState::Unavailable, now - 300_000),
23764            retention_test_record("workspace-a", "a3", ManagedSessionState::Unavailable, now - 100_000),
23765            retention_test_record("workspace-b", "b1", ManagedSessionState::Unavailable, now - 400_000),
23766            retention_test_record("workspace-b", "b2", ManagedSessionState::Unavailable, now - 200_000),
23767            retention_test_record("workspace-b", "b3", ManagedSessionState::Unavailable, now - 50_000),
23768            retention_test_record("workspace-a", "decoy-live", ManagedSessionState::Live, now - 500_000),
23769            retention_test_record(
23770                "workspace-a",
23771                "decoy-pending",
23772                ManagedSessionState::IdentityPending,
23773                now - 500_000,
23774            ),
23775            retention_test_record(
23776                "workspace-a",
23777                "decoy-dormant",
23778                ManagedSessionState::Dormant,
23779                now - 500_000,
23780            ),
23781        ]
23782    }
23783
23784    fn retention_fixture_shared() -> (NodeShared, NativeRuntime, PathBuf, u64) {
23785        let root = temporary_workspace_root("session-record-retention");
23786        std::fs::create_dir_all(&root).unwrap();
23787        let catalog = active_registry().unwrap();
23788        let (handle, runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23789        let workspace_a =
23790            WorkspaceConfig::new(WorkspaceId::new("workspace-a").unwrap(), &root).unwrap();
23791        let workspace_b =
23792            WorkspaceConfig::new(WorkspaceId::new("workspace-b").unwrap(), &root).unwrap();
23793        let mut shared = NodeShared::new(
23794            handle,
23795            "fixture-token".to_owned(),
23796            NodeId::new("node-retention-test").unwrap(),
23797            vec![workspace_a, workspace_b],
23798            vec![agent("claude")],
23799        );
23800        shared.state_path = Some(root.join("node-state-v10.json"));
23801        let now = unix_time_ms();
23802        for record in retention_fixture_records(now) {
23803            shared.insert_record(record).unwrap();
23804        }
23805        shared
23806            .persist_state_locked()
23807            .expect("fixture-only, empty-history persist against a fresh temp state path");
23808        (shared, runtime, root, now)
23809    }
23810
23811    fn retention_removed_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23812        shared
23813            .history
23814            .lock()
23815            .unwrap_or_else(|poisoned| poisoned.into_inner())
23816            .events
23817            .iter()
23818            .filter_map(|envelope| match &envelope.event {
23819                NodeEvent::SessionRecordRemoved { record_id } => {
23820                    Some(record_id.as_str().to_owned())
23821                }
23822                _ => None,
23823            })
23824            .collect()
23825    }
23826
23827    fn retention_remaining_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23828        shared
23829            .session_records
23830            .lock()
23831            .unwrap_or_else(|poisoned| poisoned.into_inner())
23832            .records
23833            .keys()
23834            .map(|record_id| record_id.as_str().to_owned())
23835            .collect()
23836    }
23837
23838    fn owned(values: &[&str]) -> std::collections::BTreeSet<String> {
23839        values.iter().map(|value| (*value).to_owned()).collect()
23840    }
23841
23842    #[tokio::test]
23843    async fn session_record_retention_keep_n_only_keeps_the_newest_per_workspace() {
23844        let (mut shared, runtime, root, _now) = retention_fixture_shared();
23845        shared.session_record_retention = SessionRecordRetentionConfig {
23846            age_ms: 0,
23847            keep_per_workspace: 1,
23848        };
23849        shared.retire_unavailable_session_records().await;
23850
23851        assert_eq!(
23852            retention_remaining_record_ids(&shared),
23853            owned(&["a3", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23854        );
23855        assert_eq!(
23856            retention_removed_record_ids(&shared),
23857            owned(&["a1", "a2", "b1", "b2"]),
23858        );
23859        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 4);
23860
23861        drop(shared);
23862        drop(runtime);
23863        std::fs::remove_dir_all(root).unwrap();
23864    }
23865
23866    /// Age-only would retire {a1, a2, b1}; keep-N-only would retire
23867    /// {a1, a2, b1, b2}. With both flags set the sweep must retire exactly
23868    /// the intersection {a1, a2, b1} -- `b2` fails the age test (it is only
23869    /// 200s old against a 250s threshold) even though it fails the keep-N
23870    /// test too, proving retirement needs BOTH, not either.
23871    #[tokio::test]
23872    async fn session_record_retention_both_set_retires_only_the_intersection() {
23873        let (mut shared, runtime, root, _now) = retention_fixture_shared();
23874        shared.session_record_retention = SessionRecordRetentionConfig {
23875            age_ms: 250_000,
23876            keep_per_workspace: 1,
23877        };
23878        shared.retire_unavailable_session_records().await;
23879
23880        assert_eq!(
23881            retention_remaining_record_ids(&shared),
23882            owned(&["a3", "b2", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23883        );
23884        assert_eq!(retention_removed_record_ids(&shared), owned(&["a1", "a2", "b1"]));
23885        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 3);
23886
23887        drop(shared);
23888        drop(runtime);
23889        std::fs::remove_dir_all(root).unwrap();
23890    }
23891
23892    #[tokio::test]
23893    async fn session_record_retention_both_disabled_is_a_no_op() {
23894        let (mut shared, runtime, root, _now) = retention_fixture_shared();
23895        shared.session_record_retention = SessionRecordRetentionConfig::default();
23896        shared.retire_unavailable_session_records().await;
23897
23898        assert_eq!(
23899            retention_remaining_record_ids(&shared),
23900            owned(&[
23901                "a1", "a2", "a3", "b1", "b2", "b3",
23902                "decoy-live", "decoy-pending", "decoy-dormant",
23903            ]),
23904        );
23905        assert!(retention_removed_record_ids(&shared).is_empty());
23906        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 0);
23907
23908        drop(shared);
23909        drop(runtime);
23910        std::fs::remove_dir_all(root).unwrap();
23911    }
23912
23913    #[test]
23914    fn session_record_context_export_selects_one_exact_history_identity() {
23915        let candidates = vec![
23916            HistoryCandidateSummary {
23917                id: "candidate-other".to_owned(),
23918                session_id_hint: "provider-other".to_owned(),
23919                modified_at_unix_ms: Some(1),
23920            },
23921            HistoryCandidateSummary {
23922                id: "candidate-exact".to_owned(),
23923                session_id_hint: "provider-exact".to_owned(),
23924                modified_at_unix_ms: Some(2),
23925            },
23926        ];
23927        assert_eq!(
23928            select_history_candidate(&candidates, Some("provider-exact")).unwrap(),
23929            "candidate-exact",
23930        );
23931    }
23932
23933    #[test]
23934    fn session_record_context_export_rejects_missing_and_ambiguous_history_identity() {
23935        let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
23936            id: id.to_owned(),
23937            session_id_hint: hint.to_owned(),
23938            modified_at_unix_ms: None,
23939        };
23940        let missing = select_history_candidate(
23941            &[candidate("candidate-other", "provider-other")],
23942            Some("provider-exact"),
23943        )
23944        .unwrap_err();
23945        assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23946        let ambiguous = select_history_candidate(
23947            &[
23948                candidate("candidate-a", "provider-exact"),
23949                candidate("candidate-b", "provider-exact"),
23950            ],
23951            Some("provider-exact"),
23952        )
23953        .unwrap_err();
23954        assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
23955    }
23956
23957    /// The PTY-sidecar-observation shape (no fleet provider today, but
23958    /// capability-keyed rather than vendor-locked) never carries a verified
23959    /// `provider_session`; its record-bound export instead falls back to
23960    /// "exactly one discovered candidate" (see
23961    /// `export_context_pack_for_session_record_inner`). This is the same
23962    /// ambiguity refusal as the identity-filtered path, just without an
23963    /// identity to filter by.
23964    #[test]
23965    fn session_record_context_export_selects_sole_history_candidate_without_identity() {
23966        let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
23967            id: id.to_owned(),
23968            session_id_hint: hint.to_owned(),
23969            modified_at_unix_ms: None,
23970        };
23971        assert_eq!(
23972            select_history_candidate(&[candidate("candidate-sole", "sidecar-session")], None)
23973                .unwrap(),
23974            "candidate-sole",
23975        );
23976        let missing = select_history_candidate(&[], None).unwrap_err();
23977        assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23978        let ambiguous = select_history_candidate(
23979            &[
23980                candidate("candidate-a", "sidecar-session-a"),
23981                candidate("candidate-b", "sidecar-session-b"),
23982            ],
23983            None,
23984        )
23985        .unwrap_err();
23986        assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
23987    }
23988
23989    #[test]
23990    fn session_record_context_export_revalidation_rejects_record_change() {
23991        let session = terminal_address(1);
23992        let identity = ProviderSessionIdentity {
23993            key: ProviderSessionKey::SessionId,
23994            id: "provider-exact".to_owned(),
23995            transcript_path: Some(r"C:\private\provider.jsonl".to_owned()),
23996        };
23997        let mut expected = record("claude", "record-context-export");
23998        expected.state = ManagedSessionState::Live;
23999        expected.provider_session = Some(identity.clone());
24000        expected.active_session = Some(session.clone());
24001        let binding = SessionBinding {
24002            workspace_id: session.workspace_id.clone(),
24003            generation: session.session.generation,
24004            runtime_policy: ProviderRuntimePolicy::raw_pty(),
24005            pending_resume: None,
24006            record_id: Some(expected.record_id.clone()),
24007            managed_worktree_lease_id: None,
24008            environment_profile: None,
24009            bundle: None,
24010            context: None,
24011            materialization_id: None,
24012        };
24013        let current_root = windows_path_text(&expected.canonical_root).to_owned();
24014        assert!(session_record_context_export_binding_is_exact(
24015            &expected,
24016            &expected,
24017            Some(&identity),
24018            &session,
24019            &binding,
24020            &expected.provider,
24021            &current_root,
24022            false,
24023        ));
24024        let mut changed = expected.clone();
24025        changed.updated_at_unix_ms += 1;
24026        assert!(!session_record_context_export_binding_is_exact(
24027            &expected,
24028            &changed,
24029            Some(&identity),
24030            &session,
24031            &binding,
24032            &expected.provider,
24033            &current_root,
24034            false,
24035        ));
24036        // A same-instant timestamp bump alone (state and active_session
24037        // otherwise unchanged) is not the clean-detachment transition
24038        // either, even with the reactive flag on: only the exact Live ->
24039        // Dormant + active_session cleared shape qualifies.
24040        assert!(!session_record_context_export_binding_is_exact(
24041            &expected,
24042            &changed,
24043            Some(&identity),
24044            &session,
24045            &binding,
24046            &expected.provider,
24047            &current_root,
24048            true,
24049        ));
24050        let mut detached = expected.clone();
24051        detached.state = ManagedSessionState::Dormant;
24052        detached.active_session = None;
24053        detached.updated_at_unix_ms += 1;
24054        assert!(!session_record_context_export_binding_is_exact(
24055            &expected,
24056            &detached,
24057            Some(&identity),
24058            &session,
24059            &binding,
24060            &expected.provider,
24061            &current_root,
24062            false,
24063        ));
24064        assert!(session_record_context_export_binding_is_exact(
24065            &expected,
24066            &detached,
24067            Some(&identity),
24068            &session,
24069            &binding,
24070            &expected.provider,
24071            &current_root,
24072            true,
24073        ));
24074    }
24075
24076    /// Mirrors `session_record_context_export_revalidation_rejects_record_change`
24077    /// for a record whose provider never carries a verified identity (the
24078    /// PTY-sidecar-observation shape -- capability-keyed, no fleet provider
24079    /// declares one today): `identity` is `None` throughout, and the
24080    /// exactness check falls back to comparing `provider_session` itself,
24081    /// which must still be `None` on both sides.
24082    #[test]
24083    fn session_record_context_export_binding_is_exact_without_identity() {
24084        let session = terminal_address(1);
24085        let mut expected = record("pty-sidecar-fixture", "record-context-export-no-identity");
24086        expected.state = ManagedSessionState::Live;
24087        expected.active_session = Some(session.clone());
24088        let binding = SessionBinding {
24089            workspace_id: session.workspace_id.clone(),
24090            generation: session.session.generation,
24091            runtime_policy: ProviderRuntimePolicy::raw_pty(),
24092            pending_resume: None,
24093            record_id: Some(expected.record_id.clone()),
24094            managed_worktree_lease_id: None,
24095            environment_profile: None,
24096            bundle: None,
24097            context: None,
24098            materialization_id: None,
24099        };
24100        let current_root = windows_path_text(&expected.canonical_root).to_owned();
24101        assert!(session_record_context_export_binding_is_exact(
24102            &expected,
24103            &expected,
24104            None,
24105            &session,
24106            &binding,
24107            &expected.provider,
24108            &current_root,
24109            false,
24110        ));
24111        // A record that gained an identity between the initial lookup and
24112        // this recheck no longer matches the `None` this shape expects to
24113        // keep seeing -- treated the same as any other mid-export drift.
24114        let mut drifted = expected.clone();
24115        drifted.provider_session = Some(ProviderSessionIdentity {
24116            key: ProviderSessionKey::SessionId,
24117            id: "unexpected-identity".to_owned(),
24118            transcript_path: None,
24119        });
24120        assert!(!session_record_context_export_binding_is_exact(
24121            &expected,
24122            &drifted,
24123            None,
24124            &session,
24125            &binding,
24126            &expected.provider,
24127            &current_root,
24128            false,
24129        ));
24130    }
24131
24132    #[test]
24133    fn session_record_context_export_preflight_requires_live_usable_source() {
24134        let mut source = record("claude", "record-context-export-preflight");
24135        source.state = ManagedSessionState::Live;
24136        assert!(session_record_context_export_source_is_usable(
24137            &source,
24138            &SessionStatus::Running,
24139            false,
24140        ));
24141        assert!(session_record_context_export_source_is_usable(
24142            &source,
24143            &SessionStatus::Exited { exit_code: Some(0) },
24144            false,
24145        ));
24146
24147        source.state = ManagedSessionState::Dormant;
24148        assert!(!session_record_context_export_source_is_usable(
24149            &source,
24150            &SessionStatus::Running,
24151            false,
24152        ));
24153        // The reactive auto-export-at-exit path (allow_clean_detachment) is
24154        // the only caller that may treat a Dormant, cleanly-exited record as
24155        // still usable — the on-demand path above never does.
24156        assert!(session_record_context_export_source_is_usable(
24157            &source,
24158            &SessionStatus::Exited { exit_code: Some(0) },
24159            true,
24160        ));
24161        assert!(!session_record_context_export_source_is_usable(
24162            &source,
24163            &SessionStatus::Exited { exit_code: Some(0) },
24164            false,
24165        ));
24166        source.state = ManagedSessionState::Live;
24167        assert!(!session_record_context_export_source_is_usable(
24168            &source,
24169            &SessionStatus::Starting,
24170            false,
24171        ));
24172    }
24173
24174    #[test]
24175    fn session_record_context_export_final_conflict_leaves_catalog_empty() {
24176        let catalog = active_registry().unwrap();
24177        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24178        let workspace = WorkspaceConfig::new(
24179            WorkspaceId::new("primary").unwrap(),
24180            std::env::current_dir().unwrap(),
24181        )
24182        .unwrap();
24183        let shared = NodeShared::new(
24184            handle,
24185            "fixture-token".to_owned(),
24186            NodeId::new("node-context-export-conflict").unwrap(),
24187            vec![workspace],
24188            vec![agent("claude")],
24189        );
24190        let history = HistorySessionRecord {
24191            session_id: "provider-exact".to_owned(),
24192            title: None,
24193            cwd: None,
24194            model: None,
24195            message_count: 1,
24196            completed_turn_count: None,
24197            total_tokens: 0,
24198            messages: vec![gate4agent_types::HistoryMessageRecord {
24199                role: gate4agent_types::HistoryMessageRole::User,
24200                text: "bounded context".to_owned(),
24201            }],
24202        };
24203        let pack = NodeContextPack::export(
24204            ContextPackLineageReceipt {
24205                source_node_id: shared.node_id.clone(),
24206                source_session: terminal_address(1),
24207                source_provider: agent("claude"),
24208            },
24209            &history,
24210        )
24211        .unwrap();
24212        let context_id = pack.receipt().id.clone();
24213        let session = terminal_address(1);
24214        let identity = ProviderSessionIdentity {
24215            key: ProviderSessionKey::SessionId,
24216            id: history.session_id.clone(),
24217            transcript_path: None,
24218        };
24219        let mut expected = record("claude", "record-context-export-final-conflict");
24220        expected.state = ManagedSessionState::Live;
24221        expected.provider_session = Some(identity.clone());
24222        expected.active_session = Some(session.clone());
24223        let mut changed = expected.clone();
24224        changed.updated_at_unix_ms += 1;
24225        shared.insert_record(changed).unwrap();
24226
24227        let error = shared
24228            .commit_context_pack_for_session_record(
24229                &expected,
24230                Some(&identity),
24231                &session,
24232                "candidate-exact",
24233                &history,
24234                pack,
24235                false,
24236            )
24237            .unwrap_err();
24238
24239        assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
24240        assert!(shared
24241            .context_catalog
24242            .read()
24243            .unwrap_or_else(|poisoned| poisoned.into_inner())
24244            .get(&context_id)
24245            .is_none());
24246    }
24247
24248    #[tokio::test]
24249    async fn session_task_binding_mints_assigns_clears_with_cas_and_persists_v9() {
24250        let root = temporary_workspace_root("session-task-binding");
24251        std::fs::create_dir_all(&root).unwrap();
24252        let state_path = root.join("node-state.json");
24253        let catalog = active_registry().unwrap();
24254        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24255        let workspace = WorkspaceConfig::new(WorkspaceId::new("primary").unwrap(), &root).unwrap();
24256        let node_id = NodeId::new("node-1").unwrap();
24257        let mut shared = NodeShared::new(
24258            handle,
24259            "fixture-token".to_owned(),
24260            node_id.clone(),
24261            vec![workspace],
24262            vec![agent("claude")],
24263        );
24264        shared.state_path = Some(state_path.clone());
24265        let record = record("claude", "sr-task");
24266        let record_id = record.record_id.clone();
24267        shared.insert_record(record).unwrap();
24268        shared.persist_state().unwrap();
24269        shared
24270            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
24271            .unwrap();
24272
24273        let minted = process_request_inner(
24274            &shared,
24275            77,
24276            ClientRole::Operator,
24277            NodeRequest::SetSessionTask {
24278                record_id: record_id.clone(),
24279                expected_revision: 0,
24280                target: SessionTaskTargetV1::New,
24281            },
24282        )
24283        .await
24284        .unwrap();
24285        let NodeResponse::SessionRecordUpdated { record: minted } = minted else {
24286            panic!("session task mutation returned another response");
24287        };
24288        let minted_binding = minted.task_binding.clone().unwrap();
24289        let minted_task_id = minted_binding.task_id.clone().unwrap();
24290        assert_eq!(minted_binding.revision, 1);
24291        assert!(minted_task_id.as_str().starts_with("task-"));
24292        assert_eq!(minted_task_id.as_str().len(), 29);
24293
24294        let event_count = shared.current_sequence();
24295        let repeated = process_request_inner(
24296            &shared,
24297            77,
24298            ClientRole::Operator,
24299            NodeRequest::SetSessionTask {
24300                record_id: record_id.clone(),
24301                expected_revision: 1,
24302                target: SessionTaskTargetV1::Existing {
24303                    task_id: minted_task_id.clone(),
24304                },
24305            },
24306        )
24307        .await
24308        .unwrap();
24309        assert_eq!(repeated, NodeResponse::SessionRecordUpdated { record: minted.clone() });
24310        assert_eq!(shared.current_sequence(), event_count);
24311
24312        let stale = process_request_inner(
24313            &shared,
24314            77,
24315            ClientRole::Operator,
24316            NodeRequest::SetSessionTask {
24317                record_id: record_id.clone(),
24318                expected_revision: 0,
24319                target: SessionTaskTargetV1::Existing {
24320                    task_id: minted_task_id,
24321                },
24322            },
24323        )
24324        .await
24325        .unwrap_err();
24326        assert_eq!(stale.code, NodeFailureCode::SessionRecordConflict);
24327
24328        let assigned_id: TaskId = "task-ffeeddccbbaa998877665544".parse().unwrap();
24329        let assigned = process_request_inner(
24330            &shared,
24331            77,
24332            ClientRole::Operator,
24333            NodeRequest::SetSessionTask {
24334                record_id: record_id.clone(),
24335                expected_revision: 1,
24336                target: SessionTaskTargetV1::Existing {
24337                    task_id: assigned_id.clone(),
24338                },
24339            },
24340        )
24341        .await
24342        .unwrap();
24343        let NodeResponse::SessionRecordUpdated { record: assigned } = assigned else {
24344            panic!("session task assignment returned another response");
24345        };
24346        assert_eq!(assigned.task_binding.as_ref().unwrap().revision, 2);
24347        assert_eq!(assigned.task_binding.as_ref().unwrap().task_id.as_ref(), Some(&assigned_id));
24348
24349        let cleared = process_request_inner(
24350            &shared,
24351            77,
24352            ClientRole::Operator,
24353            NodeRequest::SetSessionTask {
24354                record_id: record_id.clone(),
24355                expected_revision: 2,
24356                target: SessionTaskTargetV1::Clear,
24357            },
24358        )
24359        .await
24360        .unwrap();
24361        let NodeResponse::SessionRecordUpdated { record: cleared } = cleared else {
24362            panic!("session task clear returned another response");
24363        };
24364        assert_eq!(cleared.task_binding.as_ref().unwrap().revision, 3);
24365        assert!(cleared.task_binding.as_ref().unwrap().task_id.is_none());
24366        assert!(cleared.task_binding.as_ref().unwrap().changed_at_unix_ms > 0);
24367        assert_eq!(
24368            cleared.task_binding.as_ref().unwrap().changed_at_unix_ms,
24369            cleared.updated_at_unix_ms,
24370        );
24371
24372        let persisted = std::fs::read(&state_path).unwrap();
24373        assert_eq!(serde_json::from_slice::<serde_json::Value>(&persisted).unwrap()["version"], NODE_STATE_SCHEMA_V10);
24374        let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
24375        assert_eq!(loaded.records[0].task_binding, cleared.task_binding);
24376        std::fs::remove_dir_all(root).unwrap();
24377    }
24378
24379    #[test]
24380    fn session_task_capability_strips_snapshots_events_and_replies() {
24381        let mut bound = record("claude", "sr-task-projection");
24382        bound.updated_at_unix_ms = 2;
24383        bound.task_binding = Some(SessionTaskBindingV1 {
24384            revision: 1,
24385            task_id: Some("task-00112233445566778899aaff".parse().unwrap()),
24386            changed_at_unix_ms: 2,
24387        });
24388        let request = NodeRequest::SetSessionTask {
24389            record_id: bound.record_id.clone(),
24390            expected_revision: 1,
24391            target: SessionTaskTargetV1::Clear,
24392        };
24393        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
24394            capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
24395        }));
24396        assert!(!request_uses_unnegotiated_capability(
24397            &request,
24398            &baseline_capabilities().unwrap(),
24399        ));
24400        assert!(request_uses_unnegotiated_capability(&request, &[]));
24401
24402        let mut snapshot = terminal_test_shared().snapshot();
24403        snapshot.session_records.push(bound.clone());
24404        let mut reply = ResponseEnvelope {
24405            request_id: 1,
24406            result: Ok(NodeResponse::Resync {
24407                event_sequence: 1,
24408                oldest_available_sequence: 1,
24409                snapshot,
24410                events: vec![NodeEventEnvelope {
24411                    sequence: 1,
24412                    event: NodeEvent::SessionRecordUpserted {
24413                        record: bound.clone(),
24414                    },
24415                }],
24416            }),
24417        };
24418        project_response_without_session_task_binding(&mut reply);
24419        let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
24420            panic!("task projection changed the response kind");
24421        };
24422        assert!(snapshot.session_records[0].task_binding.is_none());
24423        let NodeEvent::SessionRecordUpserted { record } = &events[0].event else {
24424            panic!("task projection changed the event kind");
24425        };
24426        assert!(record.task_binding.is_none());
24427
24428        let projected = project_event_without_session_task_binding(NodeEventEnvelope {
24429            sequence: 2,
24430            event: NodeEvent::SessionRecordUpserted { record: bound.clone() },
24431        });
24432        let NodeEvent::SessionRecordUpserted { record } = projected.event else {
24433            panic!("task event projection changed the event kind");
24434        };
24435        assert!(record.task_binding.is_none());
24436
24437        let mut direct = ResponseEnvelope {
24438            request_id: 2,
24439            result: Ok(NodeResponse::SessionRecordUpdated { record: bound }),
24440        };
24441        project_response_without_session_task_binding(&mut direct);
24442        let Ok(NodeResponse::SessionRecordUpdated { record }) = direct.result else {
24443            panic!("task reply projection changed the response kind");
24444        };
24445        assert!(record.task_binding.is_none());
24446    }
24447
24448    #[test]
24449    fn windows_runtime_default_node_endpoint_is_exact_and_valid() {
24450        assert_eq!(DEFAULT_NODE_ENDPOINT, r"\\.\pipe\gate4agent-node");
24451        let workspace = WorkspaceConfig::new(
24452            WorkspaceId::new("primary").unwrap(),
24453            std::env::current_dir().unwrap(),
24454        )
24455        .unwrap();
24456        let config = NodeServerConfig::new(
24457            DEFAULT_NODE_ENDPOINT,
24458            "fixture-token",
24459            NodeId::new("node-1").unwrap(),
24460            [workspace],
24461        )
24462        .unwrap();
24463        assert_eq!(config.endpoint, DEFAULT_NODE_ENDPOINT);
24464    }
24465
24466    #[cfg(all(feature = "fixture", windows))]
24467    #[test]
24468    fn provider_bundle_argv_hold_fixture_is_parent_bounded_and_waits_after_proof() {
24469        let root = temporary_workspace_root("provider-bundle-argv-hold");
24470        let outside = temporary_workspace_root("provider-bundle-argv-hold-outside");
24471        std::fs::create_dir_all(&root).unwrap();
24472        std::fs::create_dir_all(&outside).unwrap();
24473        let proof_path = root.join("provider.proof");
24474        let release_signal = root.join("release.signal");
24475        let config = NodeServerConfig::new(
24476            format!(
24477                r"\\.\pipe\gate4agent-provider-bundle-hold-{}",
24478                std::process::id(),
24479            ),
24480            "fixture-token",
24481            NodeId::new("provider-bundle-hold-node").unwrap(),
24482            [WorkspaceConfig::new(
24483                WorkspaceId::new("primary").unwrap(),
24484                std::env::current_dir().unwrap(),
24485            )
24486            .unwrap()],
24487        )
24488        .unwrap()
24489        .with_spawn_profiles(SpawnProfileRegistry::new([
24490            crate::protocol::SpawnProfileDefaults {
24491                profile_id: crate::protocol::SpawnProfileId::new("kimi-bundle-hold").unwrap(),
24492                revision: crate::protocol::SpawnProfileRevision::new("kimi-bundle-hold-r1")
24493                    .unwrap(),
24494                provider: agent("kimi"),
24495                mode: SessionMode::Pty,
24496                terminal_size: gate4agent_types::TerminalSize {
24497                    rows: 24,
24498                    columns: 80,
24499                },
24500                prompt: None,
24501                bundle_id: None,
24502                context_id: None,
24503                environment_profile_id: None,
24504            },
24505        ])
24506        .unwrap());
24507        let server = NodeServer::new_provider_bundle_argv_hold_fixture(
24508            config,
24509            agent("kimi"),
24510            proof_path.clone(),
24511            release_signal.clone(),
24512        )
24513        .unwrap();
24514        assert_eq!(server.shared.enabled_providers, [agent("kimi")]);
24515
24516        let spec = NodeServer::provider_bundle_argv_fixture_spec(
24517            agent("kimi"),
24518            proof_path.clone(),
24519            Some(release_signal.clone()),
24520        )
24521        .unwrap();
24522        let wrapper = &spec.launch.fixed_args[spec.launch.fixed_args.len() - 3];
24523        let proof_write = wrapper.find("WriteAllLines($proofPath").unwrap();
24524        let release_wait = wrapper
24525            .find("while (-not (Test-Path -LiteralPath $releaseSignal -PathType Leaf))")
24526            .unwrap();
24527        let fixture_ready = wrapper.find("fixture-ready>").unwrap();
24528        assert!(proof_write < release_wait);
24529        assert!(release_wait < fixture_ready);
24530        assert!(wrapper.contains("[DateTime]::UtcNow.AddSeconds(45)"));
24531        assert!(wrapper.contains("Start-Sleep -Milliseconds 20"));
24532        assert_eq!(
24533            &spec.launch.fixed_args[spec.launch.fixed_args.len() - 2..],
24534            &[
24535                proof_path.to_string_lossy().into_owned(),
24536                release_signal.to_string_lossy().into_owned(),
24537            ],
24538        );
24539
24540        let error = NodeServer::provider_bundle_argv_fixture_spec(
24541            agent("kimi"),
24542            proof_path,
24543            Some(outside.join("release.signal")),
24544        )
24545        .unwrap_err();
24546        assert!(matches!(
24547            error,
24548            NodeServerError::Registry(message)
24549                if message == "provider bundle argv release path must share the proof parent"
24550        ));
24551        drop(server);
24552        std::fs::remove_dir_all(root).unwrap();
24553        std::fs::remove_dir_all(outside).unwrap();
24554    }
24555
24556    #[cfg(feature = "fixture")]
24557    #[test]
24558    fn four_provider_context_fixture_registry_is_unambiguous_and_history_bound() {
24559        let root = temporary_workspace_root("context-fixture-registry");
24560        std::fs::create_dir_all(&root).unwrap();
24561        let proof_path = root.join("context.proof");
24562        let config = NodeServerConfig::new(
24563            format!(
24564                r"\\.\pipe\gate4agent-context-fixture-registry-{}",
24565                std::process::id(),
24566            ),
24567            "fixture-token",
24568            NodeId::new("context-fixture-node").unwrap(),
24569            [WorkspaceConfig::new(
24570                WorkspaceId::new("primary").unwrap(),
24571                std::env::current_dir().unwrap(),
24572            )
24573            .unwrap()],
24574        )
24575        .unwrap();
24576        let server = NodeServer::new_context_pack_fixture(config, proof_path).unwrap();
24577        let mut enabled = server
24578            .shared
24579            .enabled_providers
24580            .iter()
24581            .map(AgentId::as_str)
24582            .collect::<Vec<_>>();
24583        enabled.sort_unstable();
24584        assert_eq!(enabled, ["claude", "codex", "grok", "kimi"]);
24585        for provider in &server.shared.enabled_providers {
24586            assert!(server
24587                .shared
24588                .provider_adapter_contracts
24589                .iter()
24590                .any(|contract| {
24591                    &contract.provider == provider
24592                        && contract.family == AdapterFamily::History
24593                }));
24594        }
24595        drop(server);
24596        std::fs::remove_dir_all(root).unwrap();
24597    }
24598
24599    #[cfg(feature = "fixture")]
24600    #[test]
24601    fn monitoring_context_fixture_combines_claude_and_codex_hooks_with_history() {
24602        let root = temporary_workspace_root("monitoring-context-fixture");
24603        std::fs::create_dir_all(&root).unwrap();
24604        let proof_path = root.join("context.proof");
24605        let standard = NodeServer::context_pack_fixture_catalog(
24606            proof_path.clone(),
24607            None,
24608            false,
24609        ).unwrap();
24610        let monitoring = NodeServer::context_pack_fixture_catalog(
24611            proof_path.clone(),
24612            None,
24613            true,
24614        ).unwrap();
24615        let claude = AgentId::new("claude").unwrap();
24616        let standard_claude = standard.get(&claude).unwrap();
24617        let monitoring_claude = monitoring.get(&claude).unwrap();
24618        // Lifecycle hooks are retired (owner ruling 2026-09-25): neither the
24619        // standard nor the monitoring fixture declares a hook adapter
24620        // anymore.
24621        assert!(monitoring_claude.capabilities.adapters.hook.is_none());
24622        assert_eq!(
24623            monitoring_claude.capabilities.adapters.history,
24624            standard_claude.capabilities.adapters.history,
24625        );
24626        assert_ne!(monitoring_claude.launch, standard_claude.launch);
24627        let codex = AgentId::new("codex").unwrap();
24628        let standard_codex = standard.get(&codex).unwrap();
24629        let monitoring_codex = monitoring.get(&codex).unwrap();
24630        assert!(monitoring_codex.capabilities.adapters.hook.is_none());
24631        assert_eq!(
24632            monitoring_codex.capabilities.adapters.history,
24633            standard_codex.capabilities.adapters.history,
24634        );
24635        assert_ne!(monitoring_codex.launch, standard_codex.launch);
24636        let codex_validation = monitoring_codex.launch.fixed_args.iter()
24637            .find(|argument| argument.contains("F7_CODEX_BUNDLE_CONTEXT_VALIDATED"))
24638            .unwrap();
24639        for required in [
24640            "$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')",
24641            "$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')",
24642            "@('role', 'text')",
24643            "g4a-private-provider-session-canary",
24644            "private-provider-login-identity",
24645            "$rawProof",
24646            "$env:GATE4AGENT_HOOK_URL",
24647            "g4a-private-provider-session-canary",
24648        ] {
24649            assert!(codex_validation.contains(required), "missing privacy validation: {required}");
24650        }
24651        for provider in ["grok", "kimi"] {
24652            let provider = AgentId::new(provider).unwrap();
24653            assert_eq!(monitoring.get(&provider), standard.get(&provider));
24654        }
24655
24656        let config = NodeServerConfig::new(
24657            format!(
24658                r"\\.\pipe\gate4agent-monitoring-context-fixture-{}",
24659                std::process::id(),
24660            ),
24661            "fixture-token",
24662            NodeId::new("monitoring-context-fixture-node").unwrap(),
24663            [WorkspaceConfig::new(
24664                WorkspaceId::new("primary").unwrap(),
24665                std::env::current_dir().unwrap(),
24666            )
24667            .unwrap()],
24668        )
24669        .unwrap();
24670        let server = NodeServer::new_monitoring_context_pack_fixture(config, proof_path).unwrap();
24671        assert!(server.shared.fixture_semantic_hook_policy);
24672        drop(server);
24673        std::fs::remove_dir_all(root).unwrap();
24674    }
24675
24676    #[cfg(feature = "fixture")]
24677    #[test]
24678    fn kimi_context_only_fixture_is_bounded_and_preserves_codex_validation() {
24679        let root = temporary_workspace_root("kimi-context-only-fixture");
24680        std::fs::create_dir_all(&root).unwrap();
24681        let proof_path = root.join("context.proof");
24682        let standard = NodeServer::context_pack_fixture_catalog(
24683            proof_path.clone(),
24684            None,
24685            false,
24686        ).unwrap();
24687        let kimi = AgentId::new("kimi").unwrap();
24688        let context_only = NodeServer::context_pack_fixture_catalog(
24689            proof_path,
24690            Some(&kimi),
24691            false,
24692        )
24693        .unwrap();
24694
24695        let codex = AgentId::new("codex").unwrap();
24696        assert_eq!(
24697            standard.get(&codex).unwrap().launch.fixed_args,
24698            context_only.get(&codex).unwrap().launch.fixed_args,
24699        );
24700        let script = context_only
24701            .get(&kimi)
24702            .unwrap()
24703            .launch
24704            .fixed_args
24705            .iter()
24706            .find(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED"))
24707            .expect("Kimi context-only validation script is missing");
24708        for required in [
24709            "$bundleArgs.Count -ne 0",
24710            "GATE4AGENT_CONTEXT_ROOT",
24711            "context-pack.json",
24712            "$entries.Count -ne 1",
24713            "g4a-context-pack-v1",
24714            "$document.PSObject.Properties['cwd']",
24715            "'claude', 'codex', 'grok', 'kimi'",
24716            "$hasUser",
24717            "$hasAssistant",
24718            "SHA256",
24719            "context-only",
24720        ] {
24721            assert!(script.contains(required), "missing validation: {required}");
24722        }
24723        for forbidden in ["CODEX_HOME", "USERPROFILE", "auth", "token", "global config"] {
24724            assert!(!script.contains(forbidden), "forbidden home/auth access: {forbidden}");
24725        }
24726        assert!(!standard
24727            .get(&kimi)
24728            .unwrap()
24729            .launch
24730            .fixed_args
24731            .iter()
24732            .any(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED")));
24733
24734        std::fs::remove_dir_all(root).unwrap();
24735    }
24736
24737    #[cfg(all(feature = "fixture", windows))]
24738    #[tokio::test]
24739    async fn kimi_context_only_fixture_spawns_catalogued_context_pack_through_spawn_spec() {
24740        let root = temporary_workspace_root("kimi-context-only-spawn");
24741        let workspace_root = root.join("workspace");
24742        std::fs::create_dir_all(&workspace_root).unwrap();
24743        let materialization_root = root.join("private-materializations");
24744        let proof_path = root.join("context.proof");
24745        let node_id = NodeId::new("node-kimi-context-only-spawn").unwrap();
24746        let workspace_id = WorkspaceId::new("primary").unwrap();
24747        let profile_id = crate::protocol::SpawnProfileId::new("target-kimi").unwrap();
24748        let endpoint = format!(
24749            r"\\.\pipe\gate4agent-kimi-context-only-spawn-{}-{}",
24750            std::process::id(),
24751            unix_time_ms(),
24752        );
24753        let spawn_profiles = SpawnProfileRegistry::new([
24754            crate::protocol::SpawnProfileDefaults {
24755                profile_id: profile_id.clone(),
24756                revision: crate::protocol::SpawnProfileRevision::new("target-kimi-r1").unwrap(),
24757                provider: agent("kimi"),
24758                mode: SessionMode::Pty,
24759                terminal_size: gate4agent_types::TerminalSize {
24760                    rows: 30,
24761                    columns: 120,
24762                },
24763                prompt: None,
24764                bundle_id: None,
24765                context_id: None,
24766                environment_profile_id: None,
24767            },
24768        ])
24769        .unwrap();
24770        let mut config = NodeServerConfig::new(
24771            &endpoint,
24772            "fixture-token",
24773            node_id.clone(),
24774            [WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap()],
24775        )
24776        .unwrap()
24777        .with_spawn_profiles(spawn_profiles)
24778        .with_session_environment_materialization(
24779            materialization_root,
24780            Arc::new(FixtureSecretResolver {
24781                deny: Arc::new(AtomicBool::new(false)),
24782            }),
24783        )
24784        .unwrap();
24785        config.fixture_raw_pty_runtime = true;
24786        let server = NodeServer::new_context_only_proof_fixture(
24787            config,
24788            agent("kimi"),
24789            proof_path.clone(),
24790        )
24791        .unwrap();
24792        let shared = Arc::clone(&server.shared);
24793        let shutdown = server.shutdown_handle();
24794
24795        let history = HistorySessionRecord {
24796            session_id: "context-only-source-history".to_owned(),
24797            title: None,
24798            cwd: None,
24799            model: None,
24800            message_count: 2,
24801            completed_turn_count: None,
24802            total_tokens: 0,
24803            messages: vec![
24804                gate4agent_types::HistoryMessageRecord {
24805                    role: gate4agent_types::HistoryMessageRole::User,
24806                    text: "continue the bounded parent run through native C2".to_owned(),
24807                },
24808                gate4agent_types::HistoryMessageRecord {
24809                    role: gate4agent_types::HistoryMessageRole::Assistant,
24810                    text: "the exact parent context is ready for the child run".to_owned(),
24811                },
24812            ],
24813        };
24814        let pack = NodeContextPack::export(
24815            ContextPackLineageReceipt {
24816                source_node_id: node_id.clone(),
24817                source_session: SessionAddress {
24818                    workspace_id: workspace_id.clone(),
24819                    session: SessionKey {
24820                        instance_id: AgentInstanceId(71),
24821                        generation: SessionGeneration(1),
24822                    },
24823                },
24824                source_provider: agent("codex"),
24825            },
24826            &history,
24827        )
24828        .unwrap();
24829        let expected_context_hash = digest(&SHA256, pack.bytes())
24830            .as_ref()
24831            .iter()
24832            .map(|byte| format!("{byte:02x}"))
24833            .collect::<String>();
24834        let context = shared
24835            .context_catalog
24836            .write()
24837            .unwrap_or_else(|poisoned| poisoned.into_inner())
24838            .insert(pack)
24839            .unwrap();
24840        let spec = SpawnSpec {
24841            target: crate::protocol::SpawnTarget {
24842                node_id,
24843                workspace_id: workspace_id.clone(),
24844                worktree_id: None,
24845            },
24846            profile_id,
24847            expected_profile_revision: crate::protocol::SpawnProfileRevision::new(
24848                "target-kimi-r1",
24849            ).unwrap(),
24850            overrides: crate::protocol::SpawnOverrides {
24851                context_id: crate::protocol::SpawnOverride::Set {
24852                    value: context.id.clone(),
24853                },
24854                ..crate::protocol::SpawnOverrides::default()
24855            },
24856            deadline_ms: crate::protocol::SpawnDeadlineMs::new(10_000).unwrap(),
24857            idempotency_key: SpawnIdempotencyKey::new("kimi-context-only-spawn").unwrap(),
24858            required_capabilities: SpawnRequiredCapabilities::default(),
24859        };
24860        let resolved = match shared.resolve_spawn_spec(&spec) {
24861            Ok(resolved) => resolved,
24862            Err(error) => panic!(
24863                "Kimi context-only resolve rejected: exact_code={:?} message={}",
24864                error.code,
24865                error.message,
24866            ),
24867        };
24868        let resolved_context = match shared.resolve_context(&resolved) {
24869            Ok(Some(resolved_context)) => resolved_context,
24870            Ok(None) => panic!("Kimi context-only resolve omitted the exact ContextPack"),
24871            Err(error) => panic!(
24872                "Kimi context catalog resolve rejected: exact_code={:?} message={}",
24873                error.code,
24874                error.message,
24875            ),
24876        };
24877        assert_eq!(resolved_context, context);
24878        let runtime_policy = match shared
24879            .admit_provider_runtime(&agent("kimi"), ProviderRuntimeRequirement::RawPty)
24880            .await
24881        {
24882            Ok(runtime_policy) => runtime_policy,
24883            Err(error) => panic!(
24884                "Kimi RawPty admission rejected: exact_code={:?} message={}",
24885                error.code,
24886                error.message,
24887            ),
24888        };
24889        assert!(runtime_policy.raw_pty_lifecycle);
24890
24891        let launch_control = shared.native_launch_profile_control.as_ref().unwrap();
24892        let generic_overlay = NativeLaunchEnvironmentOverlay::new(
24893            agent("kimi"),
24894            TransportKind::Pty,
24895            vec![EnvMutation {
24896                key: OsString::from("GATE4AGENT_UNPROFILED_GENERIC"),
24897                value: Some(OsString::from("must-remain-rejected")),
24898            }],
24899        )
24900        .unwrap();
24901        assert_eq!(
24902            launch_control
24903                .install_native_launch_environment_overlay(
24904                    AgentInstanceId(72),
24905                    generic_overlay,
24906                )
24907                .unwrap_err(),
24908            gate4agent_runtime_native::NativeLaunchProfileError::EnvironmentOverlaySelectionMissing,
24909        );
24910
24911        let diagnostic_address = SessionAddress {
24912            workspace_id: workspace_id.clone(),
24913            session: SessionKey {
24914                instance_id: AgentInstanceId(73),
24915                generation: SessionGeneration(1),
24916            },
24917        };
24918        let (diagnostic_overlay, diagnostic_materialization) = match shared
24919            .prepare_session_materialization(
24920                &diagnostic_address,
24921                &agent("kimi"),
24922                SessionMode::Pty,
24923                None,
24924                None,
24925                Some(&resolved_context),
24926                None,
24927            )
24928        {
24929            Ok(Some(prepared)) => prepared,
24930            Ok(None) => panic!("Kimi context-only materialization produced no launch overlay"),
24931            Err(error) => panic!(
24932                "Kimi context-only materialization rejected: exact_code={:?} message={}",
24933                error.code,
24934                error.message,
24935            ),
24936        };
24937        let diagnostic_overlay = diagnostic_overlay
24938            .expect("Kimi context-only materialization omitted its environment overlay");
24939        assert!(matches!(
24940            &diagnostic_overlay,
24941            PreparedNativeLaunchOverlay::Environment(_),
24942        ));
24943        match shared.install_prepared_launch_overlay(
24944            diagnostic_address.session.instance_id,
24945            diagnostic_overlay,
24946        ) {
24947            Ok(None) => {}
24948            Ok(Some(_)) => panic!("Kimi context-only overlay unexpectedly retained argv authority"),
24949            Err(error) => panic!(
24950                "Kimi context-only overlay installation rejected: exact_code={:?} message={}",
24951                error.code,
24952                error.message,
24953            ),
24954        }
24955        assert!(launch_control.clear_native_instance_launch_overlay(
24956            diagnostic_address.session.instance_id,
24957        ));
24958        drop(diagnostic_materialization);
24959        assert!(shared
24960            .materializations
24961            .lock()
24962            .unwrap_or_else(|poisoned| poisoned.into_inner())
24963            .is_empty());
24964
24965        let node_task = tokio::spawn(server.run());
24966        let ready_client = timeout(Duration::from_secs(5), async {
24967            loop {
24968                match gate4agent_node_wire::LocalNodeClient::connect(
24969                    &endpoint,
24970                    &shared.node_id,
24971                    ClientRole::Observer,
24972                    "fixture-token",
24973                )
24974                .await
24975                {
24976                    Ok(client) => break client,
24977                    Err(_) if !node_task.is_finished() => sleep(Duration::from_millis(10)).await,
24978                    Err(error) => panic!("Kimi context-only Node stopped before readiness: {error}"),
24979                }
24980            }
24981        })
24982        .await
24983        .expect("Kimi context-only Node did not become ready");
24984        assert_eq!(
24985            ready_client
24986                .hello()
24987                .snapshot
24988                .provider_runtime_statuses
24989                .iter()
24990                .find(|status| status.provider().as_str() == "kimi")
24991                .expect("Kimi runtime status is missing")
24992                .mode(),
24993            crate::protocol::ProviderRuntimeMode::RawPassthrough,
24994        );
24995        drop(ready_client);
24996
24997        let receipt = match shared.spawn_from_spec(spec).await {
24998            Ok(receipt) => receipt,
24999            Err(error) => panic!(
25000                "Kimi context-only SpawnSpec rejected: exact_code={:?} message={}",
25001                error.code,
25002                error.message,
25003            ),
25004        };
25005        assert_eq!(receipt.provider, agent("kimi"));
25006        assert_eq!(receipt.context.as_ref(), Some(&context));
25007        assert!(receipt.context_binding_is_valid());
25008        timeout(Duration::from_secs(5), async {
25009            while !proof_path.is_file() {
25010                sleep(Duration::from_millis(10)).await;
25011            }
25012        })
25013        .await
25014        .expect("Kimi context-only fixture did not write its proof");
25015
25016        let proof = std::fs::read_to_string(&proof_path).unwrap();
25017        let lines = proof.lines().collect::<Vec<_>>();
25018        assert_eq!(lines.len(), 7);
25019        assert_eq!(lines[0], "context-only");
25020        assert!(Path::new(lines[1]).is_absolute());
25021        assert_ne!(
25022            std::fs::canonicalize(Path::new(lines[1])).unwrap(),
25023            std::fs::canonicalize(&workspace_root).unwrap(),
25024        );
25025        assert_eq!(
25026            std::fs::canonicalize(Path::new(lines[2])).unwrap(),
25027            std::fs::canonicalize(&workspace_root).unwrap(),
25028        );
25029        assert_eq!(lines[3], expected_context_hash);
25030        assert_eq!(lines[4], "g4a-context-pack-v1");
25031        assert_eq!(lines[5], "codex");
25032        assert_eq!(lines[6], "2");
25033        assert!(shared.handle.snapshot().sessions.iter().any(|session| {
25034            session.instance_id == receipt.session.session.instance_id
25035                && session.agent_id.as_str() == "kimi"
25036        }));
25037
25038        shutdown.request_shutdown().await.unwrap();
25039        timeout(Duration::from_secs(5), node_task)
25040            .await
25041            .unwrap()
25042            .unwrap()
25043            .unwrap();
25044        drop(shutdown);
25045        drop(shared);
25046        std::fs::remove_dir_all(root).unwrap();
25047    }
25048
25049    #[cfg(feature = "fixture")]
25050    #[test]
25051    fn clean_exit_fixture_constructor_is_fixture_scoped_and_path_bounded() {
25052        let root = temporary_workspace_root("clean-exit-constructor");
25053        std::fs::create_dir_all(&root).unwrap();
25054        let config = construction_test_config("clean-exit-constructor")
25055            .with_state_path(root.join("node-state.json"))
25056            .unwrap();
25057        let server = NodeServer::new_clean_exit_fixture(
25058            config,
25059            root.clone(),
25060            root.join("started.marker"),
25061            root.join("release.signal"),
25062        )
25063        .unwrap();
25064        assert_eq!(
25065            server
25066                .shared
25067                .enabled_providers
25068                .iter()
25069                .map(AgentId::as_str)
25070                .collect::<Vec<_>>(),
25071            ["claude"],
25072        );
25073        drop(server);
25074        std::fs::remove_dir_all(root).unwrap();
25075    }
25076
25077    #[test]
25078    fn production_registry_provider_contract_manifest_is_exact() {
25079        let registry = active_registry().unwrap();
25080        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
25081        assert_eq!(
25082            providers
25083                .iter()
25084                .map(|contract| (contract.provider.as_str(), contract.revision.as_str()))
25085                .collect::<Vec<_>>(),
25086            vec![
25087                ("claude", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25088                ("codex", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25089                ("grok", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25090                ("kimi", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25091            ]
25092        );
25093        assert_eq!(
25094            adapters
25095                .iter()
25096                .map(|contract| (
25097                    contract.provider.as_str(),
25098                    contract.family,
25099                    contract.adapter_id.as_str(),
25100                    contract.revision.as_str(),
25101                ))
25102                .collect::<Vec<_>>(),
25103            vec![
25104                ("claude", AdapterFamily::PtySemantic, "claude-code", "gate4agent-adapter/v1"),
25105                ("claude", AdapterFamily::Pipe, "claude-code", "gate4agent-adapter/v1"),
25106                ("claude", AdapterFamily::Acp, "claude-code", "gate4agent-adapter/v1"),
25107                ("claude", AdapterFamily::OneShot, "claude", "gate4agent-inline/claude-code-2.1/v1"),
25108                ("claude", AdapterFamily::History, "claude-code", "gate4agent-adapter/v1"),
25109                ("claude", AdapterFamily::Resume, "claude-code", "gate4agent-adapter/v1"),
25110                ("claude", AdapterFamily::SessionOptions, "claude-code", "gate4agent-session-options/orca-d8629c4/v1"),
25111                ("codex", AdapterFamily::PtySemantic, "codex", "gate4agent-adapter/v1"),
25112                ("codex", AdapterFamily::Pipe, "codex", "gate4agent-adapter/v1"),
25113                ("codex", AdapterFamily::Acp, "codex", "gate4agent-adapter/v1"),
25114                ("codex", AdapterFamily::OneShot, "codex", "gate4agent-inline/codex-cli-0.144/v1"),
25115                ("codex", AdapterFamily::History, "codex", "gate4agent-adapter/v1"),
25116                ("codex", AdapterFamily::Resume, "codex", "gate4agent-adapter/v1"),
25117                ("codex", AdapterFamily::SessionOptions, "codex", "gate4agent-session-options/orca-d8629c4/v1"),
25118                ("grok", AdapterFamily::Acp, "grok", "gate4agent-adapter/v1"),
25119                ("grok", AdapterFamily::History, "grok", "gate4agent-adapter/v1"),
25120                ("grok", AdapterFamily::Resume, "grok", "gate4agent-adapter/v1"),
25121                ("kimi", AdapterFamily::PtySemantic, "kimi", "gate4agent-adapter/v1"),
25122                ("kimi", AdapterFamily::Pipe, "kimi", "gate4agent-adapter/v1"),
25123                ("kimi", AdapterFamily::Acp, "kimi", "gate4agent-adapter/v1"),
25124                ("kimi", AdapterFamily::OneShot, "kimi", "gate4agent-inline/kimi-code-0.31/v1"),
25125                ("kimi", AdapterFamily::History, "kimi", "gate4agent-adapter/v1"),
25126                ("kimi", AdapterFamily::Resume, "kimi", "gate4agent-adapter/v1"),
25127            ]
25128        );
25129    }
25130
25131    fn construction_test_config(name: &str) -> NodeServerConfig {
25132        let workspace = WorkspaceConfig::new(
25133            WorkspaceId::new("primary").unwrap(),
25134            std::env::current_dir().unwrap(),
25135        )
25136        .unwrap();
25137        NodeServerConfig::new(
25138            format!(r"\\.\pipe\gate4agent-{name}"),
25139            "fixture-token",
25140            NodeId::new(name).unwrap(),
25141            [workspace],
25142        )
25143        .unwrap()
25144    }
25145
25146    fn maximum_size_provider_manifest_registry() -> AgentRegistry {
25147        let adapter = AdapterBinding::new(
25148            gate4agent_types::AdapterId::new("a".repeat(96)).unwrap(),
25149            "r".repeat(crate::protocol::MAX_ADAPTER_CONTRACT_REVISION_BYTES),
25150            gate4agent_types::AdapterVerification::SyntheticFixture,
25151        )
25152        .unwrap();
25153        let agent_ids = ["claude", "codex", "kimi"]
25154            .into_iter()
25155            .map(|agent_id| AgentId::new(agent_id).unwrap())
25156            .collect::<Vec<_>>();
25157        let adapters = gate4agent_catalog::AdapterRegistry::new(
25158            [
25159                AdapterFamily::PtySemantic,
25160                AdapterFamily::Pipe,
25161                AdapterFamily::OneShot,
25162                AdapterFamily::Acp,
25163                AdapterFamily::Hook,
25164                AdapterFamily::ManagedHook,
25165                AdapterFamily::History,
25166                AdapterFamily::Resume,
25167                AdapterFamily::SessionOptions,
25168                AdapterFamily::CapabilityProbe,
25169            ]
25170            .into_iter()
25171            .map(|family| gate4agent_catalog::AdapterDescriptor {
25172                family,
25173                binding: adapter.clone(),
25174                agents: agent_ids.clone(),
25175            }),
25176        )
25177        .unwrap();
25178        let specs = agent_ids
25179            .iter()
25180            .map(|agent_id| {
25181                let agent_id = agent_id.as_str();
25182                let mut spec = builtin_registry().get_by_id(agent_id).unwrap().clone();
25183                spec.revision =
25184                    "r".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES);
25185                spec.capabilities.transports.pty_adapter = Some(adapter.clone());
25186                spec.capabilities.transports.pipe = Some(gate4agent_types::PipeTransportSpec {
25187                    adapter: adapter.clone(),
25188                    protocol: gate4agent_types::PipeProtocol::SemanticNdjson,
25189                    launch_override: None,
25190                    prompt_delivery: gate4agent_types::PipePromptDelivery::None,
25191                });
25192                spec.capabilities.transports.acp = Some(gate4agent_types::AcpTransportSpec {
25193                    adapter: adapter.clone(),
25194                    launch_override: None,
25195                });
25196                spec.capabilities.adapters.hook = Some(adapter.clone());
25197                spec.capabilities.adapters.managed_hook = Some(adapter.clone());
25198                spec.capabilities.adapters.one_shot = Some(adapter.clone());
25199                spec.capabilities.adapters.history = Some(adapter.clone());
25200                spec.capabilities.adapters.resume = Some(adapter.clone());
25201                spec.capabilities.adapters.session_options = Some(adapter.clone());
25202                spec.capabilities.adapters.capability_probe = Some(adapter.clone());
25203                spec
25204            })
25205            .collect::<Vec<_>>();
25206        AgentRegistry::new_with_adapters(specs, &adapters).unwrap()
25207    }
25208
25209    /// The real fleet's manifest is accepted by a real server construction.
25210    ///
25211    /// The count is asserted against `active_registry` itself rather than a
25212    /// literal, so this stays a test that construction succeeds for whatever
25213    /// the fleet is -- which is the property worth guarding -- instead of a
25214    /// second place the fleet size is written down and has to be kept in
25215    /// step with the first.
25216    #[test]
25217    fn node_server_construction_accepts_the_production_manifest() {
25218        let registry = active_registry().unwrap();
25219        let (providers, _) = provider_contract_manifest(&registry).unwrap();
25220        assert_eq!(providers.len(), registry.iter().count());
25221        assert!(!providers.is_empty());
25222        let server = NodeServer::new_with_registry(
25223            construction_test_config("manifest-production-bound"),
25224            registry,
25225        )
25226        .unwrap();
25227        drop(server);
25228    }
25229
25230    #[test]
25231    fn node_server_construction_rejects_count_valid_manifest_over_handshake_capacity() {
25232        let registry = maximum_size_provider_manifest_registry();
25233        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
25234        assert_eq!(providers.len(), 3);
25235        assert_eq!(adapters.len(), 30);
25236        assert!(adapters.len() <= crate::protocol::MAX_PROVIDER_ADAPTER_CONTRACTS);
25237        let error = match NodeServer::new_with_registry(
25238            construction_test_config("manifest-handshake-overflow"),
25239            registry,
25240        ) {
25241            Ok(_) => panic!("oversized negotiated manifest was accepted"),
25242            Err(error) => error,
25243        };
25244        assert!(matches!(
25245            error,
25246            NodeServerError::ProviderContractManifest(message)
25247                if message.contains("authentication binding length")
25248                    && message.contains("8192-byte limit")
25249        ));
25250    }
25251
25252    #[test]
25253    fn provider_contract_manifest_rejects_wire_revision_overflow_before_startup() {
25254        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
25255        spec.revision = "x".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES + 1);
25256        let registry = AgentRegistry::new([spec]).unwrap();
25257        assert!(matches!(
25258            provider_contract_manifest(&registry),
25259            Err(NodeServerError::ProviderContractManifest(message))
25260                if message.contains("exceeds the 128-byte limit")
25261        ));
25262    }
25263
25264    #[test]
25265    fn provider_contract_manifest_rejects_invalid_wire_revision_before_startup() {
25266        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
25267        spec.revision = "invalid revision".to_owned();
25268        let registry = AgentRegistry::new([spec]).unwrap();
25269        assert!(matches!(
25270            provider_contract_manifest(&registry),
25271            Err(NodeServerError::ProviderContractManifest(message))
25272                if message.contains("bounded lowercase ASCII")
25273        ));
25274    }
25275
25276    #[test]
25277    fn provider_contract_manifest_rejects_conflicting_duplicate_family_before_startup() {
25278        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
25279        let transport_binding = spec.capabilities.adapters.one_shot.clone().unwrap();
25280        let conflicting_binding = builtin_registry()
25281            .get_by_id("codex")
25282            .unwrap()
25283            .capabilities
25284            .adapters
25285            .one_shot
25286            .clone()
25287            .unwrap();
25288        let pipe = spec.capabilities.transports.pipe.as_mut().unwrap();
25289        pipe.protocol = gate4agent_types::PipeProtocol::OneShotText;
25290        pipe.adapter = transport_binding;
25291        spec.capabilities.adapters.one_shot = Some(conflicting_binding);
25292        let registry = AgentRegistry::new([spec]).unwrap();
25293        assert!(matches!(
25294            provider_contract_manifest(&registry),
25295            Err(NodeServerError::ProviderContractManifest(message))
25296                if message.contains("conflicting OneShot adapter bindings")
25297        ));
25298    }
25299
25300    #[test]
25301    fn public_node_failures_expose_only_fixed_categories() {
25302        let provider = failure(
25303            NodeFailureCode::BackendOperationFailed,
25304            r"provider bearer-secret failed at C:\private\session.jsonl",
25305        );
25306        assert_eq!(provider.message, "backend-operation-failed");
25307        assert!(!provider.message.contains("bearer-secret"));
25308        assert!(!provider.message.contains(r"C:\private"));
25309        let persistence = persistence_failure(io::Error::new(
25310            io::ErrorKind::Other,
25311            r"state write failed at C:\private\state-v1.json",
25312        ));
25313        assert_eq!(persistence.message, DURABLE_STATE_COMMIT_FAILED_ERROR);
25314        assert!(!persistence.message.contains(r"C:\private"));
25315
25316        for kind in [io::ErrorKind::PermissionDenied, io::ErrorKind::Unsupported] {
25317            let generic = durable_state_load_error(io::Error::new(kind, "host I/O failure"));
25318            assert_eq!(
25319                generic.to_string(),
25320                "node durable state failed: durable-state-load-failed",
25321            );
25322        }
25323    }
25324
25325    #[test]
25326    fn terminal_byte_sequences_are_bounded_before_dispatch() {
25327        let sequence = b"\x1b[1;5D".to_vec();
25328        assert_eq!(
25329            terminal_bytes_action(sequence.clone()).unwrap(),
25330            InputAction::TerminalBytes(sequence),
25331        );
25332        assert_eq!(
25333            terminal_bytes_action(Vec::new()).unwrap_err().code,
25334            NodeFailureCode::InvalidRequest,
25335        );
25336        assert_eq!(
25337            terminal_bytes_action(vec![0; MAX_NODE_TERMINAL_BYTES + 1])
25338                .unwrap_err()
25339                .code,
25340            NodeFailureCode::InvalidRequest,
25341        );
25342    }
25343
25344    #[test]
25345    fn node_server_rejects_a_second_owner_of_the_same_durable_state_path() {
25346        let root = temporary_workspace_root("exclusive-state-owner");
25347        std::fs::create_dir_all(&root).unwrap();
25348        let workspace = WorkspaceConfig::new(
25349            WorkspaceId::new("primary").unwrap(),
25350            &root,
25351        )
25352        .unwrap();
25353        let state_path = root.join("state-v1.json");
25354        let endpoint = format!(
25355            r"\\.\pipe\gate4agent-node-state-lock-{}-{}",
25356            std::process::id(),
25357            std::time::SystemTime::now()
25358                .duration_since(std::time::UNIX_EPOCH)
25359                .unwrap()
25360                .as_nanos(),
25361        );
25362        let config = NodeServerConfig::new(
25363            endpoint,
25364            "fixture-token",
25365            NodeId::new("node-1").unwrap(),
25366            [workspace],
25367        )
25368        .unwrap()
25369        .with_state_path(&state_path)
25370        .unwrap();
25371
25372        let first = NodeServer::new(config.clone()).unwrap();
25373        let error = match NodeServer::new(config.clone()) {
25374            Ok(_) => panic!("a second node server acquired the same durable state path"),
25375            Err(error) => error,
25376        };
25377        assert!(matches!(
25378            &error,
25379            NodeServerError::DurableState(error)
25380                if error.kind() == io::ErrorKind::WouldBlock
25381        ));
25382        assert_eq!(
25383            error.to_string(),
25384            "node durable state failed: durable-state-lock-failed",
25385        );
25386        assert!(!error.to_string().contains(&state_path.to_string_lossy().into_owned()));
25387        drop(first);
25388        let second = NodeServer::new(config).unwrap();
25389        drop(second);
25390        std::fs::remove_dir_all(root).unwrap();
25391    }
25392
25393    #[test]
25394    fn session_binding_is_workspace_scoped_and_removed_fail_closed() {
25395        let catalog = active_registry().unwrap();
25396        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25397        let workspace_id = WorkspaceId::new("primary").unwrap();
25398        let workspace = WorkspaceConfig::new(
25399            workspace_id.clone(),
25400            std::env::current_dir().unwrap(),
25401        )
25402        .unwrap();
25403        let shared = NodeShared::new(
25404            handle,
25405            "fixture-token".to_owned(),
25406            NodeId::new("node-1").unwrap(),
25407            vec![workspace.clone()],
25408            vec![agent("claude"), agent("codex"), agent("kimi")],
25409        );
25410        let instance_id = AgentInstanceId(17);
25411        let generation = SessionGeneration(3);
25412        assert!(shared.address_for(instance_id, generation).is_none());
25413        shared.bind_session(instance_id, workspace_id.clone(), generation);
25414        let address = SessionAddress {
25415            workspace_id,
25416            session: SessionKey { instance_id, generation },
25417        };
25418        assert_eq!(
25419            shared.workspace_root(&address.workspace_id).unwrap(),
25420            workspace.canonical_root,
25421        );
25422        assert_eq!(shared.address_for(instance_id, generation), Some(address.clone()));
25423        shared.remove_binding(&address);
25424        assert!(shared.address_for(instance_id, generation).is_none());
25425    }
25426
25427    #[test]
25428    fn open_provider_request_gate_is_state_aware_and_fail_closed() {
25429        let address = SessionAddress {
25430            workspace_id: WorkspaceId::new("primary").unwrap(),
25431            session: SessionKey {
25432                instance_id: AgentInstanceId(7),
25433                generation: SessionGeneration(2),
25434            },
25435        };
25436        let record_id = SessionRecordId::new("record-a").unwrap();
25437        let size = gate4agent_types::TerminalSize {
25438            rows: 24,
25439            columns: 80,
25440        };
25441        let address_requests = [
25442            NodeRequest::Resume {
25443                session: address.clone(),
25444                terminal_size: size,
25445                initial_prompt: None,
25446            },
25447            NodeRequest::Prompt { session: address.clone(), text: "p".to_owned() },
25448            NodeRequest::Paste { session: address.clone(), text: "p".to_owned() },
25449            NodeRequest::Input { session: address.clone(), text: "p".to_owned() },
25450            NodeRequest::TerminalBytes { session: address.clone(), bytes: vec![1] },
25451            NodeRequest::TerminalControl {
25452                session: address.clone(),
25453                control: TerminalControl::Enter,
25454            },
25455            NodeRequest::Resize { session: address.clone(), size },
25456            NodeRequest::Interrupt { session: address.clone() },
25457            NodeRequest::Stop { session: address.clone(), force: false },
25458            NodeRequest::Remove { session: address },
25459        ];
25460        for request in &address_requests {
25461            assert!(request_requires_open_provider_ids_with(
25462                request,
25463                |_| Some(agent("third-party-agent")),
25464                |_| Some(agent("claude")),
25465            ));
25466            assert!(!request_requires_open_provider_ids_with(
25467                request,
25468                |_| Some(agent("claude")),
25469                |_| Some(agent("claude")),
25470            ));
25471            assert!(request_requires_open_provider_ids_with(
25472                request,
25473                |_| None,
25474                |_| Some(agent("claude")),
25475            ));
25476        }
25477
25478        let record_requests = [
25479            NodeRequest::RenameSessionRecord {
25480                record_id: record_id.clone(),
25481                display_name: "renamed".to_owned(),
25482            },
25483            NodeRequest::ResumeSessionRecord {
25484                record_id: record_id.clone(),
25485                terminal_size: size,
25486                initial_prompt: None,
25487            },
25488            NodeRequest::ForgetSessionRecord { record_id },
25489        ];
25490        for request in &record_requests {
25491            assert!(request_requires_open_provider_ids_with(
25492                request,
25493                |_| Some(agent("claude")),
25494                |_| Some(agent("grok")),
25495            ));
25496            assert!(!request_requires_open_provider_ids_with(
25497                request,
25498                |_| Some(agent("claude")),
25499                |_| Some(agent("kimi")),
25500            ));
25501            assert!(request_requires_open_provider_ids_with(
25502                request,
25503                |_| Some(agent("claude")),
25504                |_| None,
25505            ));
25506        }
25507
25508        assert!(request_requires_open_provider_ids_with(
25509            &NodeRequest::Spawn {
25510                workspace_id: WorkspaceId::new("primary").unwrap(),
25511                provider: agent("third-party-agent"),
25512                mode: SessionMode::Pty,
25513                terminal_size: size,
25514                initial_prompt: None,
25515            },
25516            |_| None,
25517            |_| None,
25518        ));
25519        assert!(!request_requires_open_provider_ids_with(
25520            &NodeRequest::Snapshot,
25521            |_| None,
25522            |_| None,
25523        ));
25524        assert!(request_requires_open_provider_ids_with(
25525            &NodeRequest::ForgetContextPack {
25526                context_id: SpawnContextId::new("ctx-forget").unwrap(),
25527            },
25528            |_| None,
25529            |_| None,
25530        ));
25531    }
25532
25533    #[test]
25534    fn legacy_projection_filters_open_record_replies_and_event_history() {
25535        let catalog = active_registry().unwrap();
25536        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25537        let workspace = WorkspaceConfig::new(
25538            WorkspaceId::new("primary").unwrap(),
25539            std::env::current_dir().unwrap(),
25540        )
25541        .unwrap();
25542        let shared = NodeShared::new(
25543            handle,
25544            "fixture-token".to_owned(),
25545            NodeId::new("node-1").unwrap(),
25546            vec![workspace],
25547            vec![agent("claude"), agent("grok")],
25548        );
25549        let legacy = record("claude", "legacy-record");
25550        let open = record("grok", "open-record");
25551        let legacy_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
25552            record: legacy.clone(),
25553        });
25554        let open_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
25555            record: open.clone(),
25556        });
25557        let legacy_removed = shared.publish(NodeEvent::SessionRecordRemoved {
25558            record_id: legacy.record_id.clone(),
25559        });
25560        let open_removed = shared.publish(NodeEvent::SessionRecordRemoved {
25561            record_id: open.record_id.clone(),
25562        });
25563        assert!(project_event_legacy_provider_ids(&shared, legacy_upsert.clone()).is_some());
25564        assert!(project_event_legacy_provider_ids(&shared, open_upsert.clone()).is_none());
25565        assert!(project_event_legacy_provider_ids(&shared, legacy_removed.clone()).is_some());
25566        assert!(project_event_legacy_provider_ids(&shared, open_removed.clone()).is_none());
25567
25568        let mut record_reply = ResponseEnvelope {
25569            request_id: 1,
25570            result: Ok(NodeResponse::SessionRecordUpdated { record: open }),
25571        };
25572        project_response_legacy_provider_ids(&shared, &mut record_reply);
25573        assert!(matches!(
25574            record_reply.result,
25575            Err(NodeFailure { code: NodeFailureCode::UnsupportedCapability, .. })
25576        ));
25577
25578        let mut resync = ResponseEnvelope {
25579            request_id: 2,
25580            result: Ok(NodeResponse::Resync {
25581                event_sequence: open_removed.sequence,
25582                oldest_available_sequence: 1,
25583                snapshot: shared.snapshot(),
25584                events: vec![legacy_upsert, open_upsert, legacy_removed, open_removed],
25585            }),
25586        };
25587        project_response_legacy_provider_ids(&shared, &mut resync);
25588        let Ok(NodeResponse::Resync { snapshot, events, .. }) = resync.result else {
25589            panic!("legacy projection changed the resync response kind");
25590        };
25591        assert!(snapshot.session_records.iter().all(|item| {
25592            provider_id_is_legacy(&item.provider)
25593        }));
25594        assert_eq!(events.len(), 2);
25595        assert!(events.iter().all(|event| matches!(
25596            &event.event,
25597            NodeEvent::SessionRecordUpserted { record }
25598                if provider_id_is_legacy(&record.provider)
25599        ) || matches!(&event.event, NodeEvent::SessionRecordRemoved { .. })));
25600    }
25601
25602    #[test]
25603    fn session_binding_capacity_is_bounded_by_the_control_session_limit() {
25604        let catalog = active_registry().unwrap();
25605        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25606        let workspace_id = WorkspaceId::new("primary").unwrap();
25607        let workspace = WorkspaceConfig::new(
25608            workspace_id.clone(),
25609            std::env::current_dir().unwrap(),
25610        )
25611        .unwrap();
25612        let shared = NodeShared::new(
25613            handle,
25614            "fixture-token".to_owned(),
25615            NodeId::new("node-1").unwrap(),
25616            vec![workspace],
25617            vec![agent("claude")],
25618        );
25619        for index in 0..CONTROL_SESSIONS_MAX {
25620            shared.bind_session(
25621                AgentInstanceId((index + 1) as u64),
25622                workspace_id.clone(),
25623                SessionGeneration(1),
25624            );
25625        }
25626        let error = shared.ensure_binding_capacity().unwrap_err();
25627        assert_eq!(error.code, NodeFailureCode::BackendBusy);
25628    }
25629
25630    #[tokio::test]
25631    async fn provider_spawn_admission_rejects_before_session_mutation() {
25632        let catalog = active_registry().unwrap();
25633        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25634        let workspace = WorkspaceConfig::new(
25635            WorkspaceId::new("primary").unwrap(),
25636            std::env::current_dir().unwrap(),
25637        )
25638        .unwrap();
25639        let mut shared = NodeShared::new(
25640            handle,
25641            "fixture-token".to_owned(),
25642            NodeId::new("node-provider-admission").unwrap(),
25643            vec![workspace],
25644            vec![agent("claude")],
25645        );
25646        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25647            crate::protocol::ProviderRuntimeStatus::unavailable(agent("claude")),
25648        ])
25649        .unwrap();
25650        let terminal_size = gate4agent_types::TerminalSize {
25651            rows: 24,
25652            columns: 80,
25653        };
25654
25655        let unavailable = shared
25656            .spawn_session(
25657                WorkspaceId::new("primary").unwrap(),
25658                agent("claude"),
25659                SessionMode::Pty,
25660                terminal_size,
25661                None,
25662            )
25663            .await
25664            .unwrap_err();
25665        assert_eq!(unavailable.code, NodeFailureCode::BackendOperationFailed);
25666        assert_eq!(unavailable.message, "backend-operation-failed");
25667
25668        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25669            crate::protocol::ProviderRuntimeStatus::raw_passthrough(
25670                agent("claude"),
25671                Some(crate::protocol::ProviderRuntimeVersion::new("999.0.0").unwrap()),
25672            ),
25673        ])
25674        .unwrap();
25675        let semantic = shared
25676            .spawn_session(
25677                WorkspaceId::new("primary").unwrap(),
25678                agent("claude"),
25679                SessionMode::Inline,
25680                terminal_size,
25681                None,
25682            )
25683            .await
25684            .unwrap_err();
25685        assert_eq!(semantic.code, NodeFailureCode::UnsupportedCapability);
25686        assert_eq!(semantic.message, "unsupported-capability");
25687
25688        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
25689        assert!(shared
25690            .session_bindings
25691            .lock()
25692            .unwrap_or_else(|poisoned| poisoned.into_inner())
25693            .is_empty());
25694        assert!(shared
25695            .session_records
25696            .lock()
25697            .unwrap_or_else(|poisoned| poisoned.into_inner())
25698            .records
25699            .is_empty());
25700        assert!(shared.handle.snapshot().sessions.is_empty());
25701    }
25702
25703    #[tokio::test]
25704    async fn raw_spawn_binding_is_visible_with_a_durable_live_record() {
25705        let catalog = active_registry().unwrap();
25706        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25707        let workspace_id = WorkspaceId::new("primary").unwrap();
25708        let workspace = WorkspaceConfig::new(
25709            workspace_id.clone(),
25710            std::env::current_dir().unwrap(),
25711        )
25712        .unwrap();
25713        let shared = NodeShared::new(
25714            handle,
25715            "fixture-token".to_owned(),
25716            NodeId::new("node-raw-spawn-record").unwrap(),
25717            vec![workspace],
25718            vec![agent("claude")],
25719        );
25720        let address = SessionAddress {
25721            workspace_id,
25722            session: SessionKey {
25723                instance_id: AgentInstanceId(71),
25724                generation: SessionGeneration::default(),
25725            },
25726        };
25727
25728        let record_id = shared
25729            .bind_spawn_session(
25730                &address,
25731                agent("claude"),
25732                SessionMode::Pty,
25733                ProviderRuntimePolicy::raw_pty(),
25734                SpawnRecordPolicy::Always,
25735                None,
25736            )
25737            .unwrap()
25738            .unwrap();
25739        shared
25740            .handle
25741            .dispatch(shared.prepare_command(ControlCommand::Register {
25742                instance_id: address.session.instance_id,
25743                agent_id: agent("claude"),
25744                transport: TransportKind::Pty,
25745            }))
25746            .unwrap();
25747        runtime.tick().await;
25748
25749        assert!(shared.handle.snapshot().sessions.iter().any(|session| {
25750            session.instance_id == address.session.instance_id
25751                && session.generation == address.session.generation
25752        }));
25753        let record = shared.record(&record_id).unwrap();
25754        assert_eq!(record.display_name, "claude #1");
25755        assert_eq!(record.state, ManagedSessionState::Live);
25756        assert_eq!(record.active_session.as_ref(), Some(&address));
25757        assert!(record.provider_session.is_none());
25758        let binding_record_id = shared
25759            .session_bindings
25760            .lock()
25761            .unwrap_or_else(|poisoned| poisoned.into_inner())
25762            .get(&address.session.instance_id)
25763            .and_then(|binding| binding.record_id.clone());
25764        assert_eq!(binding_record_id.as_ref(), Some(&record_id));
25765
25766        let renamed = shared
25767            .rename_session_record(&record_id, "raw review".to_owned())
25768            .unwrap();
25769        assert_eq!(renamed.record_id, record_id);
25770        assert_eq!(renamed.display_name, "raw review");
25771        shared.reconcile_managed_record(
25772            &record_id,
25773            &address,
25774            &ControlEvent {
25775                sequence: 1,
25776                command_id: None,
25777                instance_id: address.session.instance_id,
25778                generation: address.session.generation,
25779                event: ControlEventKind::Running { process_id: None },
25780            },
25781            false,
25782            false,
25783        );
25784        let running = shared.record(&record_id).unwrap();
25785        assert_eq!(running.state, ManagedSessionState::Live);
25786        assert_eq!(running.active_session.as_ref(), Some(&address));
25787        assert_eq!(running.display_name, "raw review");
25788
25789        shared.reconcile_managed_record(
25790            &record_id,
25791            &address,
25792            &ControlEvent {
25793                sequence: 2,
25794                command_id: None,
25795                instance_id: address.session.instance_id,
25796                generation: address.session.generation,
25797                event: ControlEventKind::Exited {
25798                    exit_code: Some(0),
25799                    forced: false,
25800                },
25801            },
25802            false,
25803            false,
25804        );
25805        let detached = shared.record(&record_id).unwrap();
25806        assert_eq!(detached.state, ManagedSessionState::Unavailable);
25807        assert!(detached.active_session.is_none());
25808        assert_eq!(detached.display_name, "raw review");
25809        let renamed_detached = shared
25810            .rename_session_record(&record_id, "raw review detached".to_owned())
25811            .unwrap();
25812        assert_eq!(renamed_detached.record_id, record_id);
25813        assert_eq!(renamed_detached.state, ManagedSessionState::Unavailable);
25814        assert!(renamed_detached.active_session.is_none());
25815        assert_eq!(renamed_detached.display_name, "raw review detached");
25816        assert_eq!(shared.snapshot().session_records, vec![renamed_detached]);
25817    }
25818
25819    #[test]
25820    fn legacy_raw_spawn_binding_remains_untracked() {
25821        let catalog = active_registry().unwrap();
25822        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25823        let workspace_id = WorkspaceId::new("primary").unwrap();
25824        let workspace = WorkspaceConfig::new(
25825            workspace_id.clone(),
25826            std::env::current_dir().unwrap(),
25827        )
25828        .unwrap();
25829        let shared = NodeShared::new(
25830            handle,
25831            "fixture-token".to_owned(),
25832            NodeId::new("node-legacy-raw-spawn").unwrap(),
25833            vec![workspace],
25834            vec![agent("claude")],
25835        );
25836        let address = SessionAddress {
25837            workspace_id,
25838            session: SessionKey {
25839                instance_id: AgentInstanceId(72),
25840                generation: SessionGeneration::default(),
25841            },
25842        };
25843
25844        let record_id = shared
25845            .bind_spawn_session(
25846                &address,
25847                agent("claude"),
25848                SessionMode::Pty,
25849                ProviderRuntimePolicy::raw_pty(),
25850                SpawnRecordPolicy::ProviderIdentityOnly,
25851                None,
25852            )
25853            .unwrap();
25854
25855        assert!(record_id.is_none());
25856        let binding_record_id = shared
25857            .session_bindings
25858            .lock()
25859            .unwrap_or_else(|poisoned| poisoned.into_inner())
25860            .get(&address.session.instance_id)
25861            .and_then(|binding| binding.record_id.clone());
25862        assert!(binding_record_id.is_none());
25863        assert!(shared.snapshot().session_records.is_empty());
25864    }
25865
25866    #[tokio::test]
25867    async fn raw_runtime_policy_rejects_prompt_paste_and_resume_before_mutation() {
25868        let catalog = active_registry().unwrap();
25869        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25870        let workspace_id = WorkspaceId::new("primary").unwrap();
25871        let workspace = WorkspaceConfig::new(
25872            workspace_id.clone(),
25873            std::env::current_dir().unwrap(),
25874        )
25875        .unwrap();
25876        let mut shared = NodeShared::new(
25877            handle,
25878            "fixture-token".to_owned(),
25879            NodeId::new("node-raw-semantic-gate").unwrap(),
25880            vec![workspace],
25881            vec![agent("claude")],
25882        );
25883        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25884            crate::protocol::ProviderRuntimeStatus::raw_passthrough(agent("claude"), None),
25885        ])
25886        .unwrap();
25887        shared
25888            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25889            .unwrap();
25890        let address = SessionAddress {
25891            workspace_id,
25892            session: SessionKey {
25893                instance_id: AgentInstanceId(72),
25894                generation: SessionGeneration::default(),
25895            },
25896        };
25897        shared.bind_session_with_policy(
25898            &address,
25899            ProviderRuntimePolicy::raw_pty(),
25900            None,
25901        );
25902        shared
25903            .handle
25904            .dispatch(shared.prepare_command(ControlCommand::Register {
25905                instance_id: address.session.instance_id,
25906                agent_id: agent("claude"),
25907                transport: TransportKind::Pty,
25908            }))
25909            .unwrap();
25910        runtime.tick().await;
25911        let command_id_before = shared.next_command_id.load(Ordering::Acquire);
25912        let revision_before = shared.handle.snapshot().revision;
25913        let history_sequence_before = shared.current_sequence();
25914        let terminal_size = gate4agent_types::TerminalSize {
25915            rows: 24,
25916            columns: 80,
25917        };
25918
25919        for request in [
25920            NodeRequest::Prompt {
25921                session: address.clone(),
25922                text: "prompt".to_owned(),
25923            },
25924            NodeRequest::Paste {
25925                session: address.clone(),
25926                text: "paste".to_owned(),
25927            },
25928            // `initial_prompt: Some(_)` selects `ResumeWithPrompt`, which needs
25929            // the same semantic capabilities as `Prompt`/`Paste`. A bare
25930            // (promptless) resume is a raw PTY relaunch and IS admitted under
25931            // `raw_pty()` — see `raw_pty_admits_provider_native_resume_without_prompt_only`
25932            // in `provider_runtime.rs`; it is deliberately not exercised here.
25933            NodeRequest::Resume {
25934                session: address.clone(),
25935                terminal_size,
25936                initial_prompt: Some("resume".to_owned()),
25937            },
25938        ] {
25939            let error = process_request_inner(
25940                &shared,
25941                77,
25942                ClientRole::Operator,
25943                request,
25944            )
25945            .await
25946            .unwrap_err();
25947            assert_eq!(error.code, NodeFailureCode::UnsupportedCapability);
25948        }
25949
25950        assert_eq!(shared.next_command_id.load(Ordering::Acquire), command_id_before);
25951        assert_eq!(shared.handle.snapshot().revision, revision_before);
25952        assert_eq!(shared.current_sequence(), history_sequence_before);
25953        let bindings = shared
25954            .session_bindings
25955            .lock()
25956            .unwrap_or_else(|poisoned| poisoned.into_inner());
25957        let binding = bindings.get(&address.session.instance_id).unwrap();
25958        assert_eq!(binding.generation, address.session.generation);
25959        assert!(binding.pending_resume.is_none());
25960        assert!(binding.record_id.is_none());
25961    }
25962
25963    #[tokio::test]
25964    async fn windows_rejects_unix_bytes_workspace_path_before_filesystem_access() {
25965        let catalog = active_registry().unwrap();
25966        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25967        let primary = WorkspaceConfig::new(
25968            WorkspaceId::new("primary").unwrap(),
25969            std::env::current_dir().unwrap(),
25970        )
25971        .unwrap();
25972        let shared = NodeShared::new(
25973            handle,
25974            "fixture-token".to_owned(),
25975            NodeId::new("node-1").unwrap(),
25976            vec![primary],
25977            vec![agent("claude")],
25978        );
25979        shared
25980            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25981            .unwrap();
25982        let before = shared.snapshot();
25983        let error = process_request_inner(
25984            &shared,
25985            77,
25986            ClientRole::Operator,
25987            NodeRequest::RegisterWorkspace {
25988                workspace_id: WorkspaceId::new("foreign").unwrap(),
25989                root: OpaqueHostPath::unix_bytes(b"/srv/repo".to_vec()).unwrap(),
25990            },
25991        )
25992        .await
25993        .unwrap_err();
25994        assert_eq!(error.code, NodeFailureCode::InvalidRequest);
25995        assert_eq!(error.message, "invalid-request");
25996        assert_eq!(shared.snapshot().workspaces, before.workspaces);
25997    }
25998
25999    #[tokio::test]
26000    async fn dynamic_workspaces_reject_duplicates_busy_and_last_then_publish_ordered_events() {
26001        let catalog = active_registry().unwrap();
26002        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26003        let primary_id = WorkspaceId::new("primary").unwrap();
26004        let primary = WorkspaceConfig::new(
26005            primary_id.clone(),
26006            std::env::current_dir().unwrap(),
26007        )
26008        .unwrap();
26009        let shared = NodeShared::new(
26010            handle,
26011            "fixture-token".to_owned(),
26012            NodeId::new("node-1").unwrap(),
26013            vec![primary.clone()],
26014            vec![agent("claude")],
26015        );
26016        let secondary_id = WorkspaceId::new("secondary").unwrap();
26017        let secondary = shared
26018            .register_workspace(
26019                secondary_id.clone(),
26020                std::env::temp_dir().to_string_lossy().into_owned(),
26021            )
26022            .await
26023            .unwrap();
26024        assert_eq!(secondary.workspace_id, secondary_id);
26025        assert!(secondary.sessions.is_empty());
26026        assert_eq!(shared.snapshot().workspaces.len(), 2);
26027
26028        let duplicate_id = shared
26029            .register_workspace(
26030                secondary_id.clone(),
26031                primary.canonical_root().to_owned(),
26032            )
26033            .await
26034            .unwrap_err();
26035        assert_eq!(duplicate_id.code, NodeFailureCode::DuplicateWorkspaceId);
26036        let duplicate_root = shared
26037            .register_workspace(
26038                WorkspaceId::new("duplicate-root").unwrap(),
26039                windows_path_text(&secondary.canonical_root).to_owned(),
26040            )
26041            .await
26042            .unwrap_err();
26043        assert_eq!(duplicate_root.code, NodeFailureCode::DuplicateWorkspaceRoot);
26044
26045        let address = SessionAddress {
26046            workspace_id: secondary_id.clone(),
26047            session: SessionKey {
26048                instance_id: AgentInstanceId(41),
26049                generation: SessionGeneration(1),
26050            },
26051        };
26052        shared.bind_session(
26053            address.session.instance_id,
26054            secondary_id.clone(),
26055            address.session.generation,
26056        );
26057        let busy = shared.unregister_workspace(&secondary_id).unwrap_err();
26058        assert_eq!(busy.code, NodeFailureCode::WorkspaceBusy);
26059        shared.remove_binding(&address);
26060        shared.unregister_workspace(&secondary_id).unwrap();
26061        assert_eq!(shared.snapshot().workspaces.len(), 1);
26062
26063        let last = shared.unregister_workspace(&primary_id).unwrap_err();
26064        assert_eq!(last.code, NodeFailureCode::LastWorkspace);
26065        let history = shared
26066            .history
26067            .lock()
26068            .unwrap_or_else(|poisoned| poisoned.into_inner());
26069        assert!(matches!(
26070            &history.events[0],
26071            NodeEventEnvelope {
26072                sequence: 1,
26073                event: NodeEvent::WorkspaceAdded { workspace },
26074            } if workspace.workspace_id == secondary_id
26075        ));
26076        assert!(matches!(
26077            &history.events[1],
26078            NodeEventEnvelope {
26079                sequence: 2,
26080                event: NodeEvent::WorkspaceRemoved { workspace_id },
26081            } if workspace_id == &secondary_id
26082        ));
26083    }
26084
26085    #[tokio::test]
26086    async fn provider_session_reference_index_is_controller_gated_and_idempotent() {
26087        let secondary_root = temporary_workspace_root("provider-session-reference-index");
26088        std::fs::create_dir_all(&secondary_root).unwrap();
26089        let catalog = active_registry().unwrap();
26090        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26091        let primary_id = WorkspaceId::new("primary").unwrap();
26092        let secondary_id = WorkspaceId::new("secondary").unwrap();
26093        let shared = NodeShared::new(
26094            handle,
26095            "fixture-token".to_owned(),
26096            NodeId::new("node-session-reference-index").unwrap(),
26097            vec![
26098                WorkspaceConfig::new(primary_id.clone(), std::env::current_dir().unwrap())
26099                    .unwrap(),
26100                WorkspaceConfig::new(secondary_id.clone(), &secondary_root).unwrap(),
26101            ],
26102            vec![agent("claude"), agent("codex")],
26103        );
26104        let identity = ProviderSessionIdentity {
26105            key: gate4agent_types::ProviderSessionKey::SessionId,
26106            id: "provider-session-42".to_owned(),
26107            transcript_path: None,
26108        };
26109        let index_request = |workspace_id: WorkspaceId,
26110                             provider: AgentId,
26111                             identity: ProviderSessionIdentity,
26112                             display_name: &str| {
26113            NodeRequest::IndexProviderSession {
26114                workspace_id,
26115                provider,
26116                identity,
26117                display_name: display_name.to_owned(),
26118            }
26119        };
26120        let request = index_request(
26121            primary_id.clone(),
26122            agent("claude"),
26123            identity.clone(),
26124            "release shepherd",
26125        );
26126        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
26127            capability.as_str() == NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY
26128        }));
26129        let observer = process_request_inner(
26130            &shared,
26131            77,
26132            ClientRole::Observer,
26133            request.clone(),
26134        )
26135        .await
26136        .unwrap_err();
26137        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26138        let uncontrolled = process_request_inner(
26139            &shared,
26140            77,
26141            ClientRole::Operator,
26142            request.clone(),
26143        )
26144        .await
26145        .unwrap_err();
26146        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
26147        shared
26148            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
26149            .unwrap();
26150
26151        let indexed = process_request_inner(
26152            &shared,
26153            77,
26154            ClientRole::Operator,
26155            request.clone(),
26156        )
26157        .await
26158        .unwrap();
26159        let NodeResponse::ProviderSessionIndexed { record } = indexed else {
26160            panic!("provider session reference index returned another response");
26161        };
26162        assert_eq!(record.state, ManagedSessionState::Dormant);
26163        assert_eq!(record.mode, SessionMode::Pty);
26164        assert_eq!(record.workspace_id, primary_id);
26165        assert_eq!(record.provider_session.as_ref(), Some(&identity));
26166        assert!(record.active_session.is_none());
26167        assert!(record.environment_profile.is_none());
26168        assert!(record.bundle.is_none());
26169        assert!(record.context.is_none());
26170
26171        let repeated = process_request_inner(
26172            &shared,
26173            77,
26174            ClientRole::Operator,
26175            index_request(
26176                primary_id.clone(),
26177                agent("claude"),
26178                identity.clone(),
26179                "ignored replacement",
26180            ),
26181        )
26182        .await
26183        .unwrap();
26184        assert!(matches!(
26185            repeated,
26186            NodeResponse::ProviderSessionIndexed { record: ref existing }
26187                if existing.record_id == record.record_id
26188                    && existing.display_name == "release shepherd"
26189        ));
26190        let conflicting_workspace = process_request_inner(
26191            &shared,
26192            77,
26193            ClientRole::Operator,
26194            index_request(
26195                secondary_id,
26196                agent("claude"),
26197                identity.clone(),
26198                "release shepherd",
26199            ),
26200        )
26201        .await
26202        .unwrap_err();
26203        assert_eq!(conflicting_workspace.code, NodeFailureCode::SessionRecordConflict);
26204        let conflicting_provider = process_request_inner(
26205            &shared,
26206            77,
26207            ClientRole::Operator,
26208            index_request(
26209                primary_id.clone(),
26210                agent("codex"),
26211                identity.clone(),
26212                "release shepherd",
26213            ),
26214        )
26215        .await
26216        .unwrap_err();
26217        assert_eq!(conflicting_provider.code, NodeFailureCode::SessionRecordConflict);
26218        let transcript_rejected = process_request_inner(
26219            &shared,
26220            77,
26221            ClientRole::Operator,
26222            index_request(
26223                primary_id,
26224                agent("claude"),
26225                ProviderSessionIdentity {
26226                    transcript_path: Some("C:/provider/transcript.jsonl".to_owned()),
26227                    ..identity
26228                },
26229                "release shepherd",
26230            ),
26231        )
26232        .await
26233        .unwrap_err();
26234        assert_eq!(transcript_rejected.code, NodeFailureCode::InvalidRequest);
26235        let upserts = shared
26236            .history
26237            .lock()
26238            .unwrap_or_else(|poisoned| poisoned.into_inner())
26239            .events
26240            .iter()
26241            .filter(|event| matches!(
26242                &event.event,
26243                NodeEvent::SessionRecordUpserted { record: published }
26244                    if published.record_id == record.record_id
26245            ))
26246            .count();
26247        assert_eq!(upserts, 1);
26248        std::fs::remove_dir_all(secondary_root).unwrap();
26249    }
26250
26251    #[tokio::test]
26252    async fn standalone_workspace_is_independent_registered_and_controller_gated() {
26253        let fixture_root = temporary_workspace_root("standalone-independent");
26254        let target = fixture_root.join("independent");
26255        std::fs::create_dir_all(&fixture_root).unwrap();
26256        let catalog = active_registry().unwrap();
26257        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26258        let selected = WorkspaceConfig::new(
26259            WorkspaceId::new("primary").unwrap(),
26260            std::env::current_dir().unwrap(),
26261        )
26262        .unwrap();
26263        let selected_root = selected.canonical_root().to_owned();
26264        let shared = NodeShared::new(
26265            handle,
26266            "fixture-token".to_owned(),
26267            NodeId::new("node-standalone").unwrap(),
26268            vec![selected],
26269            vec![agent("claude")],
26270        );
26271        let workspace_id = WorkspaceId::new("independent").unwrap();
26272        let request = NodeRequest::CreateStandaloneWorkspace {
26273            workspace_id: workspace_id.clone(),
26274            root: opaque_windows_path(target.to_string_lossy().into_owned()),
26275            initial_branch: Some("main".to_owned()),
26276        };
26277        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
26278            capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
26279        }));
26280        assert!(!request_uses_unnegotiated_capability(
26281            &request,
26282            &baseline_capabilities().unwrap(),
26283        ));
26284        assert!(request_uses_unnegotiated_capability(&request, &[]));
26285
26286        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
26287            .await
26288            .unwrap_err();
26289        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26290        assert!(!target.exists());
26291        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
26292            .await
26293            .unwrap_err();
26294        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
26295        assert!(!target.exists());
26296        shared
26297            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
26298            .unwrap();
26299
26300        let response = process_request_inner(&shared, 77, ClientRole::Operator, request)
26301            .await
26302            .unwrap();
26303        let NodeResponse::StandaloneWorkspaceCreated { workspace } = response else {
26304            panic!("standalone workspace creation returned another response");
26305        };
26306        assert_eq!(workspace.workspace_id, workspace_id);
26307        assert!(target.join(".git").is_dir());
26308        let branch = run_git_bounded(
26309            target.to_str().unwrap(),
26310            &["branch", "--show-current"],
26311            GIT_OUTPUT_MAX_BYTES,
26312        )
26313        .await
26314        .unwrap();
26315        assert!(branch.success);
26316        assert_eq!(String::from_utf8(branch.stdout).unwrap().trim(), "main");
26317        let worktrees = list_git_worktrees(target.to_str().unwrap()).await.unwrap();
26318        assert_eq!(worktrees.len(), 1);
26319        let listed_root = WorkspaceConfig::new(
26320            WorkspaceId::new("listed-independent").unwrap(),
26321            &worktrees[0].path,
26322        )
26323        .unwrap();
26324        assert!(platform::roots_equal(
26325            listed_root.canonical_root(),
26326            windows_path_text(&workspace.canonical_root),
26327        ));
26328        assert!(!platform::roots_equal(listed_root.canonical_root(), &selected_root));
26329        assert!(shared.snapshot().workspaces.iter().any(|snapshot| {
26330            snapshot.workspace_id == workspace_id
26331                && snapshot.canonical_root == workspace.canonical_root
26332        }));
26333        assert!(shared
26334            .history
26335            .lock()
26336            .unwrap_or_else(|poisoned| poisoned.into_inner())
26337            .events
26338            .iter()
26339            .any(|event| matches!(
26340                &event.event,
26341                NodeEvent::WorkspaceAdded { workspace }
26342                    if workspace.workspace_id == workspace_id
26343            )));
26344        std::fs::remove_dir_all(fixture_root).unwrap();
26345    }
26346
26347    #[tokio::test]
26348    async fn standalone_workspace_registration_failure_preserves_guarded_repository_for_recovery() {
26349        let fixture_root = temporary_workspace_root("standalone-compensation");
26350        let created_target = fixture_root.join("created-target");
26351        let existing_target = fixture_root.join("existing-target");
26352        let invalid_state_parent = fixture_root.join("state-parent-is-a-file");
26353        let invalid_state_path = invalid_state_parent.join("state.json");
26354        std::fs::create_dir_all(&existing_target).unwrap();
26355        std::fs::write(&invalid_state_parent, "not a directory").unwrap();
26356        let catalog = active_registry().unwrap();
26357        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26358        let primary = WorkspaceConfig::new(
26359            WorkspaceId::new("primary").unwrap(),
26360            std::env::current_dir().unwrap(),
26361        )
26362        .unwrap();
26363        let mut shared = NodeShared::new(
26364            handle,
26365            "fixture-token".to_owned(),
26366            NodeId::new("node-standalone-compensation").unwrap(),
26367            vec![primary],
26368            vec![agent("claude")],
26369        );
26370        shared.state_path = Some(invalid_state_path);
26371
26372        let created_recovery = shared
26373            .create_standalone_workspace(
26374                WorkspaceId::new("compensated").unwrap(),
26375                created_target.to_string_lossy().into_owned(),
26376                Some("main".to_owned()),
26377            )
26378            .await
26379            .unwrap_err();
26380        assert_eq!(
26381            created_recovery.code,
26382            NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
26383        );
26384        assert!(created_target.join(".git").is_dir());
26385        assert!(shared.snapshot().workspaces.iter().all(|workspace| {
26386            workspace.workspace_id.as_str() != "compensated"
26387        }));
26388
26389        let existing_recovery = shared
26390            .create_standalone_workspace(
26391                WorkspaceId::new("restored").unwrap(),
26392                existing_target.to_string_lossy().into_owned(),
26393                None,
26394            )
26395            .await
26396            .unwrap_err();
26397        assert_eq!(
26398            existing_recovery.code,
26399            NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
26400        );
26401        assert!(existing_target.is_dir());
26402        assert!(existing_target.join(".git").is_dir());
26403        assert!(shared.snapshot().workspaces.iter().all(|workspace| {
26404            workspace.workspace_id.as_str() != "restored"
26405        }));
26406
26407        let registered_empty = fixture_root.join("registered-empty");
26408        std::fs::create_dir(&registered_empty).unwrap();
26409        let catalog = active_registry().unwrap();
26410        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26411        let registered = WorkspaceConfig::new(
26412            WorkspaceId::new("registered-empty").unwrap(),
26413            &registered_empty,
26414        )
26415        .unwrap();
26416        let duplicate_shared = NodeShared::new(
26417            handle,
26418            "fixture-token".to_owned(),
26419            NodeId::new("node-standalone-duplicate-root").unwrap(),
26420            vec![registered],
26421            vec![agent("claude")],
26422        );
26423        let duplicate = duplicate_shared
26424            .create_standalone_workspace(
26425                WorkspaceId::new("duplicate-root").unwrap(),
26426                registered_empty.to_string_lossy().into_owned(),
26427                None,
26428            )
26429            .await
26430            .unwrap_err();
26431        assert_eq!(duplicate.code, NodeFailureCode::DuplicateWorkspaceRoot);
26432        assert!(std::fs::read_dir(&registered_empty).unwrap().next().is_none());
26433        std::fs::remove_dir_all(fixture_root).unwrap();
26434    }
26435
26436    #[test]
26437    fn managed_record_id_collision_preserves_the_existing_record() {
26438        let catalog = active_registry().unwrap();
26439        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26440        let workspace = WorkspaceConfig::new(
26441            WorkspaceId::new("primary").unwrap(),
26442            std::env::current_dir().unwrap(),
26443        )
26444        .unwrap();
26445        let shared = NodeShared::new(
26446            handle,
26447            "fixture-token".to_owned(),
26448            NodeId::new("node-1").unwrap(),
26449            vec![workspace.clone()],
26450            vec![agent("claude")],
26451        );
26452        let record_id = SessionRecordId::new("sr-collision").unwrap();
26453        let original = ManagedSessionRecord {
26454            record_id: record_id.clone(),
26455            display_name: "original".to_owned(),
26456            provider: agent("claude"),
26457            mode: SessionMode::Pty,
26458            state: ManagedSessionState::Dormant,
26459            workspace_id: workspace.workspace_id,
26460            canonical_root: opaque_windows_path(workspace.canonical_root),
26461            provider_session: None,
26462            active_session: None,
26463            environment_profile: None,
26464            bundle: None,
26465            context_id: None,
26466            context: None,
26467            exported_context: None,
26468            task_binding: None,
26469            created_at_unix_ms: 1,
26470            updated_at_unix_ms: 1,
26471            last_error: None,
26472        };
26473        shared.insert_record(original.clone()).unwrap();
26474        let mut collision = original.clone();
26475        collision.display_name = "replacement".to_owned();
26476        let error = shared.insert_record(collision).unwrap_err();
26477        assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
26478        assert_eq!(shared.record(&record_id).unwrap(), original);
26479    }
26480
26481    #[test]
26482    fn managed_record_runtime_errors_are_exposed_only_as_allowlisted_categories() {
26483        let catalog = active_registry().unwrap();
26484        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26485        let workspace = WorkspaceConfig::new(
26486            WorkspaceId::new("primary").unwrap(),
26487            std::env::current_dir().unwrap(),
26488        )
26489        .unwrap();
26490        let shared = NodeShared::new(
26491            handle,
26492            "fixture-token".to_owned(),
26493            NodeId::new("node-1").unwrap(),
26494            vec![workspace.clone()],
26495            vec![agent("claude")],
26496        );
26497        let record_id = SessionRecordId::new("sr-runtime-error").unwrap();
26498        shared
26499            .insert_record(ManagedSessionRecord {
26500                record_id: record_id.clone(),
26501                display_name: "runtime error".to_owned(),
26502                provider: agent("claude"),
26503                mode: SessionMode::Pty,
26504                state: ManagedSessionState::IdentityPending,
26505                workspace_id: workspace.workspace_id,
26506                canonical_root: opaque_windows_path(workspace.canonical_root),
26507                provider_session: None,
26508                active_session: None,
26509                environment_profile: None,
26510                bundle: None,
26511                context_id: None,
26512                context: None,
26513                exported_context: None,
26514                task_binding: None,
26515                created_at_unix_ms: 1,
26516                updated_at_unix_ms: 1,
26517                last_error: None,
26518            })
26519            .unwrap();
26520
26521        shared
26522            .mark_record_error(
26523                &record_id,
26524                r"provider failed with bearer-secret-123 at C:\private\session.jsonl",
26525            )
26526            .unwrap();
26527        let record = shared.record(&record_id).unwrap();
26528        assert_eq!(record.state, ManagedSessionState::Unavailable);
26529        assert_eq!(
26530            record.last_error.as_deref(),
26531            Some("provider-runtime-failed"),
26532        );
26533        let snapshot = shared.snapshot();
26534        assert_eq!(snapshot.session_records, vec![record]);
26535    }
26536
26537    /// Regression coverage for the "operator sees `deadline` instead of the
26538    /// kernel's own named rejection" defect: a spawn-dispatch waiter must be
26539    /// able to learn a command's asynchronous kernel rejection (here,
26540    /// specifically classified as `UnsupportedTransport`) exactly once, and
26541    /// must not still find it after that -- proving the fast path this
26542    /// mechanism exists for (`spawn_session_with_deadline`'s commit-wait
26543    /// loop) drains the reason instead of leaving it to be rediscovered (or
26544    /// missed) on a later poll.
26545    #[test]
26546    fn rejected_command_reason_is_classified_and_consumed_exactly_once() {
26547        let catalog = active_registry().unwrap();
26548        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26549        let workspace = WorkspaceConfig::new(
26550            WorkspaceId::new("primary").unwrap(),
26551            std::env::current_dir().unwrap(),
26552        )
26553        .unwrap();
26554        let shared = NodeShared::new(
26555            handle,
26556            "fixture-token".to_owned(),
26557            NodeId::new("node-1").unwrap(),
26558            vec![workspace],
26559            vec![agent("claude")],
26560        );
26561
26562        assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
26563
26564        shared.record_rejected_command(
26565            CommandId(1),
26566            "agent 'claude' does not support transport Acp".to_owned(),
26567            true,
26568        );
26569        // An unrelated command_id must not see another command's reason.
26570        assert!(shared.take_rejected_command_reason(CommandId(2)).is_none());
26571
26572        let reason = shared.take_rejected_command_reason(CommandId(1)).unwrap();
26573        assert_eq!(reason.message, "agent 'claude' does not support transport Acp");
26574        assert!(reason.unsupported_transport);
26575
26576        // Consumed: a second read for the same command_id finds nothing.
26577        assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
26578
26579        // A rejection unrelated to transport support keeps its own
26580        // classification distinct, so the reader can choose the generic
26581        // `BackendOperationFailed` code instead of misreporting
26582        // `UnsupportedTransport`.
26583        shared.record_rejected_command(
26584            CommandId(3),
26585            "backend busy".to_owned(),
26586            false,
26587        );
26588        let unclassified = shared.take_rejected_command_reason(CommandId(3)).unwrap();
26589        assert!(!unclassified.unsupported_transport);
26590    }
26591
26592    #[test]
26593    fn provider_identity_update_is_ignored_without_exact_session_policy() {
26594        let catalog = active_registry().unwrap();
26595        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26596        let workspace = WorkspaceConfig::new(
26597            WorkspaceId::new("primary").unwrap(),
26598            std::env::current_dir().unwrap(),
26599        )
26600        .unwrap();
26601        let shared = NodeShared::new(
26602            handle,
26603            "fixture-token".to_owned(),
26604            NodeId::new("node-identity-policy").unwrap(),
26605            vec![workspace.clone()],
26606            vec![agent("claude")],
26607        );
26608        let address = SessionAddress {
26609            workspace_id: workspace.workspace_id.clone(),
26610            session: SessionKey {
26611                instance_id: AgentInstanceId(90),
26612                generation: SessionGeneration(1),
26613            },
26614        };
26615        let record_id = SessionRecordId::new("sr-policy-denied").unwrap();
26616        shared
26617            .insert_record(ManagedSessionRecord {
26618                record_id: record_id.clone(),
26619                display_name: "policy denied".to_owned(),
26620                provider: agent("claude"),
26621                mode: SessionMode::Pty,
26622                state: ManagedSessionState::IdentityPending,
26623                workspace_id: workspace.workspace_id,
26624                canonical_root: opaque_windows_path(workspace.canonical_root),
26625                provider_session: None,
26626                active_session: Some(address.clone()),
26627                environment_profile: None,
26628                bundle: None,
26629                context_id: None,
26630                context: None,
26631                exported_context: None,
26632                task_binding: None,
26633                created_at_unix_ms: 1,
26634                updated_at_unix_ms: 1,
26635                last_error: None,
26636            })
26637            .unwrap();
26638        let event = ControlEvent {
26639            sequence: 1,
26640            command_id: None,
26641            instance_id: address.session.instance_id,
26642            generation: address.session.generation,
26643            event: ControlEventKind::ProviderEvent {
26644                sequence: 1,
26645                source: gate4agent_types::ProviderSource {
26646                    family: AdapterFamily::PtySemantic,
26647                    binding: AdapterBinding::new(
26648                        gate4agent_types::AdapterId::new("claude").unwrap(),
26649                        "fixture/v1",
26650                        gate4agent_types::AdapterVerification::SyntheticFixture,
26651                    )
26652                    .unwrap(),
26653                },
26654                source_sequence: 1,
26655                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26656                    identity: gate4agent_types::ProviderSessionIdentity {
26657                        key: gate4agent_types::ProviderSessionKey::SessionId,
26658                        id: "provider-session".to_owned(),
26659                        transcript_path: None,
26660                    },
26661                },
26662            },
26663        };
26664
26665        shared.reconcile_managed_record(&record_id, &address, &event, false, false);
26666
26667        let record = shared.record(&record_id).unwrap();
26668        assert!(record.provider_session.is_none());
26669        assert_eq!(record.state, ManagedSessionState::IdentityPending);
26670        assert_eq!(record.active_session.as_ref(), Some(&address));
26671    }
26672
26673    #[test]
26674    fn provider_identity_cannot_rebind_a_record_across_workspaces() {
26675        let secondary_root = temporary_workspace_root("identity-scope");
26676        std::fs::create_dir_all(&secondary_root).unwrap();
26677        let catalog = active_registry().unwrap();
26678        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26679        let primary = WorkspaceConfig::new(
26680            WorkspaceId::new("primary").unwrap(),
26681            std::env::current_dir().unwrap(),
26682        )
26683        .unwrap();
26684        let secondary = WorkspaceConfig::new(
26685            WorkspaceId::new("secondary").unwrap(),
26686            &secondary_root,
26687        )
26688        .unwrap();
26689        let shared = NodeShared::new(
26690            handle,
26691            "fixture-token".to_owned(),
26692            NodeId::new("node-1").unwrap(),
26693            vec![primary.clone(), secondary.clone()],
26694            vec![agent("claude")],
26695        );
26696        let identity = gate4agent_types::ProviderSessionIdentity {
26697            key: gate4agent_types::ProviderSessionKey::SessionId,
26698            id: "provider-session".to_owned(),
26699            transcript_path: None,
26700        };
26701        let dormant_id = SessionRecordId::new("sr-dormant").unwrap();
26702        shared
26703            .insert_record(ManagedSessionRecord {
26704                record_id: dormant_id.clone(),
26705                display_name: "dormant".to_owned(),
26706                provider: agent("claude"),
26707                mode: SessionMode::Pty,
26708                state: ManagedSessionState::Dormant,
26709                workspace_id: primary.workspace_id,
26710                canonical_root: opaque_windows_path(primary.canonical_root),
26711                provider_session: Some(identity.clone()),
26712                active_session: None,
26713                environment_profile: None,
26714                bundle: None,
26715                context_id: None,
26716                context: None,
26717                exported_context: None,
26718                task_binding: None,
26719                created_at_unix_ms: 1,
26720                updated_at_unix_ms: 1,
26721                last_error: None,
26722            })
26723            .unwrap();
26724        let address = SessionAddress {
26725            workspace_id: secondary.workspace_id.clone(),
26726            session: SessionKey {
26727                instance_id: AgentInstanceId(91),
26728                generation: SessionGeneration(1),
26729            },
26730        };
26731        let current_id = SessionRecordId::new("sr-current").unwrap();
26732        shared
26733            .insert_record(ManagedSessionRecord {
26734                record_id: current_id.clone(),
26735                display_name: "current".to_owned(),
26736                provider: agent("claude"),
26737                mode: SessionMode::Pty,
26738                state: ManagedSessionState::IdentityPending,
26739                workspace_id: secondary.workspace_id,
26740                canonical_root: opaque_windows_path(secondary.canonical_root),
26741                provider_session: None,
26742                active_session: Some(address.clone()),
26743                environment_profile: None,
26744                bundle: None,
26745                context_id: None,
26746                context: None,
26747                exported_context: None,
26748                task_binding: None,
26749                created_at_unix_ms: 2,
26750                updated_at_unix_ms: 2,
26751                last_error: None,
26752            })
26753            .unwrap();
26754        shared.bind_managed_session(
26755            &address,
26756            current_id.clone(),
26757            ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26758            None,
26759        );
26760        let event = ControlEvent {
26761            sequence: 1,
26762            command_id: None,
26763            instance_id: address.session.instance_id,
26764            generation: address.session.generation,
26765            event: ControlEventKind::ProviderEvent {
26766                sequence: 1,
26767                source: gate4agent_types::ProviderSource {
26768                    family: gate4agent_types::AdapterFamily::PtySemantic,
26769                    binding: gate4agent_types::AdapterBinding::new(
26770                        gate4agent_types::AdapterId::new("claude").unwrap(),
26771                        "fixture/v1",
26772                        gate4agent_types::AdapterVerification::SyntheticFixture,
26773                    )
26774                    .unwrap(),
26775                },
26776                source_sequence: 1,
26777                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26778                    identity,
26779                },
26780            },
26781        };
26782        shared.reconcile_managed_record(&current_id, &address, &event, true, false);
26783
26784        let binding = shared
26785            .session_bindings
26786            .lock()
26787            .unwrap_or_else(|poisoned| poisoned.into_inner())
26788            .get(&address.session.instance_id)
26789            .cloned()
26790            .unwrap();
26791        assert_eq!(binding.record_id.as_ref(), Some(&current_id));
26792        assert_eq!(shared.record(&dormant_id).unwrap().state, ManagedSessionState::Dormant);
26793        let current = shared.record(&current_id).unwrap();
26794        assert_eq!(current.state, ManagedSessionState::Unavailable);
26795        assert_eq!(current.active_session.as_ref(), Some(&address));
26796        assert_eq!(
26797            current.last_error.as_deref(),
26798            Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR),
26799        );
26800        std::fs::remove_dir_all(secondary_root).unwrap();
26801    }
26802
26803    #[tokio::test]
26804    async fn semantic_identity_with_transcript_path_reuses_record_and_survives_reload() {
26805        let root = temporary_workspace_root("semantic-provider-session");
26806        std::fs::create_dir_all(&root).unwrap();
26807        let state_path = root.join("state-v1.json");
26808        let catalog = active_registry().unwrap();
26809        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26810        let workspace = WorkspaceConfig::new(
26811            WorkspaceId::new("primary").unwrap(),
26812            &root,
26813        )
26814        .unwrap();
26815        let node_id = NodeId::new("node-semantic-identity").unwrap();
26816        let shared = NodeShared::new_with_incarnation(
26817            handle,
26818            "fixture-token".to_owned(),
26819            node_id.clone(),
26820            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
26821            vec![workspace.clone()],
26822            vec![agent("claude")],
26823            ProviderRuntimeStatuses::default(),
26824            None,
26825            Vec::new(),
26826            Vec::new(),
26827            SpawnProfileRegistry::default(),
26828            None,
26829            None,
26830            None,
26831            Some(state_path.clone()),
26832            Vec::new(),
26833            Vec::new(),
26834            Vec::new(),
26835            Vec::new(),
26836            None,
26837            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
26838        );
26839        let persisted_identity = gate4agent_types::ProviderSessionIdentity {
26840            key: gate4agent_types::ProviderSessionKey::SessionId,
26841            id: "provider-session".to_owned(),
26842            transcript_path: None,
26843        };
26844        let durable_id = SessionRecordId::new("sr-durable").unwrap();
26845        shared
26846            .insert_record(ManagedSessionRecord {
26847                record_id: durable_id.clone(),
26848                display_name: "durable".to_owned(),
26849                provider: agent("claude"),
26850                mode: SessionMode::Pty,
26851                state: ManagedSessionState::Dormant,
26852                workspace_id: workspace.workspace_id.clone(),
26853                canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26854                provider_session: Some(persisted_identity.clone()),
26855                active_session: None,
26856                environment_profile: None,
26857                bundle: None,
26858                context_id: None,
26859                context: None,
26860                exported_context: None,
26861                task_binding: None,
26862                created_at_unix_ms: 1,
26863                updated_at_unix_ms: 1,
26864                last_error: None,
26865            })
26866            .unwrap();
26867        let address = SessionAddress {
26868            workspace_id: workspace.workspace_id.clone(),
26869            session: SessionKey {
26870                instance_id: AgentInstanceId(92),
26871                generation: SessionGeneration(2),
26872            },
26873        };
26874        let pending_id = SessionRecordId::new("sr-pending").unwrap();
26875        shared
26876            .insert_record(ManagedSessionRecord {
26877                record_id: pending_id.clone(),
26878                display_name: "pending".to_owned(),
26879                provider: agent("claude"),
26880                mode: SessionMode::Pty,
26881                state: ManagedSessionState::IdentityPending,
26882                workspace_id: workspace.workspace_id.clone(),
26883                canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26884                provider_session: None,
26885                active_session: Some(address.clone()),
26886                environment_profile: None,
26887                bundle: None,
26888                context_id: None,
26889                context: None,
26890                exported_context: None,
26891                task_binding: None,
26892                created_at_unix_ms: 2,
26893                updated_at_unix_ms: 2,
26894                last_error: None,
26895            })
26896            .unwrap();
26897        shared.bind_managed_session(
26898            &address,
26899            pending_id.clone(),
26900            ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26901            None,
26902        );
26903        let observed_identity = gate4agent_types::ProviderSessionIdentity {
26904            transcript_path: Some(r"C:\private\provider-transcript.jsonl".to_owned()),
26905            ..persisted_identity
26906        };
26907        let event = ControlEvent {
26908            sequence: 1,
26909            command_id: None,
26910            instance_id: address.session.instance_id,
26911            generation: address.session.generation,
26912            event: ControlEventKind::ProviderEvent {
26913                sequence: 1,
26914                source: gate4agent_types::ProviderSource {
26915                    family: gate4agent_types::AdapterFamily::PtySemantic,
26916                    binding: gate4agent_types::AdapterBinding::new(
26917                        gate4agent_types::AdapterId::new("claude").unwrap(),
26918                        "fixture/v1",
26919                        gate4agent_types::AdapterVerification::SyntheticFixture,
26920                    )
26921                    .unwrap(),
26922                },
26923                source_sequence: 1,
26924                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26925                    identity: observed_identity.clone(),
26926                },
26927            },
26928        };
26929        shared.reconcile_managed_record(&pending_id, &address, &event, true, false);
26930
26931        let snapshot = shared.snapshot();
26932        assert_eq!(snapshot.session_records.len(), 1);
26933        let live = &snapshot.session_records[0];
26934        assert_eq!(live.record_id, durable_id);
26935        assert_eq!(live.state, ManagedSessionState::Live);
26936        assert_eq!(live.active_session.as_ref(), Some(&address));
26937        assert_eq!(live.provider_session.as_ref(), Some(&observed_identity));
26938        let binding = shared
26939            .session_bindings
26940            .lock()
26941            .unwrap_or_else(|poisoned| poisoned.into_inner())
26942            .get(&address.session.instance_id)
26943            .cloned()
26944            .unwrap();
26945        assert_eq!(binding.record_id.as_ref(), Some(&durable_id));
26946
26947        let mut duplicate = live.clone();
26948        duplicate.record_id = SessionRecordId::new("sr-duplicate").unwrap();
26949        duplicate.display_name = "duplicate".to_owned();
26950        duplicate.state = ManagedSessionState::Dormant;
26951        duplicate.active_session = None;
26952        duplicate.provider_session.as_mut().unwrap().transcript_path = None;
26953        let duplicate_error = shared.insert_record(duplicate).unwrap_err();
26954        assert_eq!(duplicate_error.code, NodeFailureCode::SessionRecordConflict);
26955        assert_eq!(shared.snapshot().session_records.len(), 1);
26956
26957        let terminal_size = gate4agent_types::TerminalSize {
26958            rows: 24,
26959            columns: 80,
26960        };
26961        let (first_busy, second_busy) = tokio::join!(
26962            shared.resume_session_record(&durable_id, terminal_size, None),
26963            shared.resume_session_record(&durable_id, terminal_size, None),
26964        );
26965        for busy in [first_busy.unwrap_err(), second_busy.unwrap_err()] {
26966            assert_eq!(busy.code, NodeFailureCode::SessionRecordBusy);
26967            assert_eq!(busy.message, "session-record-busy");
26968        }
26969
26970        let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
26971        assert_eq!(loaded.records.len(), 1);
26972        assert_eq!(loaded.records[0].record_id, durable_id);
26973        assert_eq!(loaded.records[0].state, ManagedSessionState::Dormant);
26974        assert_eq!(
26975            loaded.records[0]
26976                .provider_session
26977                .as_ref()
26978                .unwrap()
26979                .transcript_path,
26980            None,
26981        );
26982        let catalog = active_registry().unwrap();
26983        let (reloaded_handle, _reloaded_runtime) =
26984            NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26985        let reloaded = NodeShared::new_with_incarnation(
26986            reloaded_handle,
26987            "fixture-token".to_owned(),
26988            node_id,
26989            NodeIncarnationId::from_bytes([1; crate::protocol::NODE_INCARNATION_ID_BYTES]),
26990            vec![workspace],
26991            vec![agent("claude")],
26992            ProviderRuntimeStatuses::default(),
26993            None,
26994            Vec::new(),
26995            Vec::new(),
26996            SpawnProfileRegistry::default(),
26997            None,
26998            None,
26999            None,
27000            None,
27001            loaded.records,
27002            loaded.managed_worktrees,
27003            loaded.managed_worktree_tombstones,
27004            loaded.materializations,
27005            loaded.warning,
27006            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
27007        );
27008        assert_eq!(reloaded.snapshot().session_records.len(), 1);
27009        assert_eq!(reloaded.snapshot().session_records[0].record_id, durable_id);
27010        std::fs::remove_dir_all(root).unwrap();
27011    }
27012
27013    #[tokio::test]
27014    async fn workspace_reregistration_requires_the_recorded_root_and_restores_records() {
27015        let secondary_root = temporary_workspace_root("workspace-restore");
27016        let other_root = temporary_workspace_root("workspace-conflict");
27017        std::fs::create_dir_all(&secondary_root).unwrap();
27018        std::fs::create_dir_all(&other_root).unwrap();
27019        let catalog = active_registry().unwrap();
27020        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27021        let primary = WorkspaceConfig::new(
27022            WorkspaceId::new("primary").unwrap(),
27023            std::env::current_dir().unwrap(),
27024        )
27025        .unwrap();
27026        let secondary = WorkspaceConfig::new(
27027            WorkspaceId::new("secondary").unwrap(),
27028            &secondary_root,
27029        )
27030        .unwrap();
27031        let shared = NodeShared::new(
27032            handle,
27033            "fixture-token".to_owned(),
27034            NodeId::new("node-1").unwrap(),
27035            vec![primary, secondary.clone()],
27036            vec![agent("claude")],
27037        );
27038        let record_id = SessionRecordId::new("sr-workspace").unwrap();
27039        shared
27040            .insert_record(ManagedSessionRecord {
27041                record_id: record_id.clone(),
27042                display_name: "workspace record".to_owned(),
27043                provider: agent("claude"),
27044                mode: SessionMode::Pty,
27045                state: ManagedSessionState::Dormant,
27046                workspace_id: secondary.workspace_id.clone(),
27047                canonical_root: opaque_windows_path(secondary.canonical_root.clone()),
27048                provider_session: Some(gate4agent_types::ProviderSessionIdentity {
27049                    key: gate4agent_types::ProviderSessionKey::SessionId,
27050                    id: "provider-session".to_owned(),
27051                    transcript_path: None,
27052                }),
27053                active_session: None,
27054                environment_profile: None,
27055                bundle: None,
27056                context_id: None,
27057                context: None,
27058                exported_context: None,
27059                task_binding: None,
27060                created_at_unix_ms: 1,
27061                updated_at_unix_ms: 1,
27062                last_error: None,
27063            })
27064            .unwrap();
27065        shared.unregister_workspace(&secondary.workspace_id).unwrap();
27066        let unavailable = shared.record(&record_id).unwrap();
27067        assert_eq!(unavailable.state, ManagedSessionState::Unavailable);
27068        assert_eq!(windows_path_text(&unavailable.canonical_root), secondary.canonical_root);
27069
27070        let conflict = shared
27071            .register_workspace(
27072                secondary.workspace_id.clone(),
27073                other_root.to_string_lossy().into_owned(),
27074            )
27075            .await
27076            .unwrap_err();
27077        assert_eq!(conflict.code, NodeFailureCode::SessionRecordConflict);
27078        shared
27079            .register_workspace(
27080                secondary.workspace_id,
27081                secondary_root.to_string_lossy().into_owned(),
27082            )
27083            .await
27084            .unwrap();
27085        let restored = shared.record(&record_id).unwrap();
27086        assert_eq!(restored.state, ManagedSessionState::Dormant);
27087        assert!(restored.last_error.is_none());
27088        std::fs::remove_dir_all(secondary_root).unwrap();
27089        std::fs::remove_dir_all(other_root).unwrap();
27090    }
27091
27092    #[test]
27093    fn dynamic_workspace_root_is_rejected_before_filesystem_canonicalization() {
27094        let error = validate_workspace_request_root(
27095            &"x".repeat(MAX_WORKSPACE_ROOT_BYTES + 1),
27096        )
27097        .unwrap_err();
27098        assert_eq!(error.code, NodeFailureCode::InvalidWorkspaceRoot);
27099        let control = validate_workspace_request_root("C:\\repo\nchild").unwrap_err();
27100        assert_eq!(control.code, NodeFailureCode::InvalidWorkspaceRoot);
27101    }
27102
27103    #[test]
27104    fn concurrent_publish_and_resync_never_acknowledge_an_unappended_event() {
27105        let catalog = active_registry().unwrap();
27106        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27107        let workspace = WorkspaceConfig::new(
27108            WorkspaceId::new("primary").unwrap(),
27109            std::env::current_dir().unwrap(),
27110        )
27111        .unwrap();
27112        let shared = NodeShared::new(
27113            handle,
27114            "fixture-token".to_owned(),
27115            NodeId::new("node-1").unwrap(),
27116            vec![workspace],
27117            vec![agent("claude")],
27118        );
27119        let publishers = 4_u64;
27120        let events_per_publisher = 256_u64;
27121        let start = std::sync::Barrier::new(publishers as usize + 1);
27122        let finished = AtomicU64::new(0);
27123
27124        std::thread::scope(|scope| {
27125            for _ in 0..publishers {
27126                scope.spawn(|| {
27127                    start.wait();
27128                    for _ in 0..events_per_publisher {
27129                        shared.publish(NodeEvent::ControllerChanged { controller: None });
27130                    }
27131                    finished.fetch_add(1, Ordering::Release);
27132                });
27133            }
27134            start.wait();
27135            while finished.load(Ordering::Acquire) != publishers {
27136                assert_resync_history_is_linearized(shared.resync(0));
27137                std::thread::yield_now();
27138            }
27139        });
27140        let response = shared.resync(0);
27141        assert_resync_history_is_linearized(response.clone());
27142        let NodeResponse::Resync { event_sequence, events, .. } = response else {
27143            unreachable!("resync helper returned another response");
27144        };
27145        assert_eq!(event_sequence, publishers * events_per_publisher);
27146        assert_eq!(events.len() as u64, event_sequence);
27147    }
27148
27149    fn assert_resync_history_is_linearized(response: NodeResponse) {
27150        let NodeResponse::Resync { event_sequence, events, .. } = response else {
27151            unreachable!("resync helper returned another response");
27152        };
27153        assert!(events.windows(2).all(|pair| {
27154            pair[0].sequence.checked_add(1) == Some(pair[1].sequence)
27155        }));
27156        assert_eq!(events.last().map_or(0, |event| event.sequence), event_sequence);
27157    }
27158
27159    #[test]
27160    fn canonical_workspace_roots_are_safe_for_cmd_exe() {
27161        assert_eq!(
27162            platform::normalize_canonical_root(r"\\?\C:\repo\worktree".to_owned()),
27163            r"C:\repo\worktree",
27164        );
27165        assert_eq!(
27166            platform::normalize_canonical_root(r"\\?\UNC\server\share\repo".to_owned()),
27167            r"\\server\share\repo",
27168        );
27169        assert!(validate_workspace_root(
27170            &WorkspaceId::new("network").unwrap(),
27171            r"\\server\share\repo",
27172        )
27173        .is_err());
27174        let workspace = WorkspaceConfig::new(
27175            WorkspaceId::new("primary").unwrap(),
27176            std::env::current_dir().unwrap(),
27177        )
27178        .unwrap();
27179        assert!(!workspace.canonical_root.starts_with(r"\\?\"));
27180    }
27181
27182    #[test]
27183    fn resume_rebinds_only_on_the_expected_authorized_generation() {
27184        let catalog = active_registry().unwrap();
27185        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27186        let workspace_id = WorkspaceId::new("primary").unwrap();
27187        let workspace = WorkspaceConfig::new(
27188            workspace_id.clone(),
27189            std::env::current_dir().unwrap(),
27190        )
27191        .unwrap();
27192        let shared = NodeShared::new(
27193            handle,
27194            "fixture-token".to_owned(),
27195            NodeId::new("node-1").unwrap(),
27196            vec![workspace],
27197            vec![agent("claude")],
27198        );
27199        let instance_id = AgentInstanceId(17);
27200        let original = SessionAddress {
27201            workspace_id,
27202            session: SessionKey {
27203                instance_id,
27204                generation: SessionGeneration(3),
27205            },
27206        };
27207        shared.bind_session(
27208            instance_id,
27209            original.workspace_id.clone(),
27210            original.session.generation,
27211        );
27212        shared
27213            .arm_resume(
27214                &original,
27215                CommandId(41),
27216                ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
27217            )
27218            .unwrap();
27219
27220        shared.publish_control(ControlEvent {
27221            sequence: 1,
27222            command_id: None,
27223            instance_id,
27224            generation: SessionGeneration(4),
27225            event: ControlEventKind::Running { process_id: None },
27226        });
27227        assert_eq!(
27228            shared.address_for(instance_id, SessionGeneration(3)),
27229            Some(original.clone()),
27230        );
27231        assert!(shared.address_for(instance_id, SessionGeneration(4)).is_none());
27232
27233        shared.publish_control(ControlEvent {
27234            sequence: 2,
27235            command_id: Some(CommandId(42)),
27236            instance_id,
27237            generation: SessionGeneration(3),
27238            event: ControlEventKind::CommandRejected {
27239                message: "unrelated command".to_owned(),
27240            },
27241        });
27242
27243        shared.publish_control(ControlEvent {
27244            sequence: 3,
27245            command_id: None,
27246            instance_id,
27247            generation: SessionGeneration(4),
27248            event: ControlEventKind::ResumeAuthorized {
27249                session: gate4agent_types::ResumeSessionSummary {
27250                    key: gate4agent_types::ProviderSessionKey::SessionId,
27251                    id: "fixture-session".to_owned(),
27252                },
27253            },
27254        });
27255        assert!(shared.address_for(instance_id, SessionGeneration(3)).is_none());
27256        let rebound = shared.address_for(instance_id, SessionGeneration(4)).unwrap();
27257        assert_eq!(rebound.workspace_id, original.workspace_id);
27258        let history = shared
27259            .history
27260            .lock()
27261            .unwrap_or_else(|poisoned| poisoned.into_inner());
27262        assert!(history.events.iter().any(|envelope| matches!(
27263            &envelope.event,
27264            NodeEvent::Control { address, event }
27265                if address == &rebound
27266                    && matches!(&event.event, ControlEventKind::ResumeAuthorized { .. })
27267        )));
27268    }
27269
27270    #[test]
27271    fn all_node_pty_prompts_use_bracketed_paste_framing() {
27272        for agent in ["claude", "codex", "kimi"] {
27273            assert_eq!(
27274                prompt_framing(&AgentId::new(agent).unwrap()),
27275                PromptFraming::BracketedPaste
27276            );
27277        }
27278    }
27279
27280    fn generous_walk_deadline() -> Instant {
27281        Instant::now() + Duration::from_secs(30)
27282    }
27283
27284    #[test]
27285    fn workspace_tree_is_relative_and_skips_heavy_directories() {
27286        let root = temporary_workspace_root("tree");
27287        std::fs::create_dir_all(root.join("src")).unwrap();
27288        std::fs::create_dir_all(root.join(".git/objects")).unwrap();
27289        std::fs::create_dir_all(root.join("target/debug")).unwrap();
27290        std::fs::create_dir_all(root.join("node_modules/package")).unwrap();
27291        std::fs::write(root.join("src/lib.rs"), b"pub fn fixture() {}\n").unwrap();
27292        std::fs::write(root.join("README.md"), b"fixture\n").unwrap();
27293        // A renamed/agent-scoped build directory (`target-a2`-style, per the
27294        // real repository's own build layout) that carries the Cache
27295        // Directory Tagging Specification marker cargo writes into every
27296        // `--target-dir` it creates: must be skipped even though its name
27297        // does not exactly match `target`.
27298        std::fs::create_dir_all(root.join("target-a2/debug")).unwrap();
27299        std::fs::write(root.join("target-a2/CACHEDIR.TAG"), b"Signature: 8a477f597d28d172789f06886806bc55\n").unwrap();
27300        // A directory that merely *starts with* `target` but carries no
27301        // CACHEDIR.TAG: the new prefix-adjacent check must not false-match
27302        // on the name alone, so this stays in the response.
27303        std::fs::create_dir_all(root.join("target-notes")).unwrap();
27304        std::fs::write(root.join("target-notes/plan.md"), b"not a build directory\n").unwrap();
27305
27306        let (entries, truncated, _budget) =
27307            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
27308        assert!(!truncated);
27309        assert!(entries.iter().any(|entry| {
27310            entry.relative_path.as_utf8() == Some("src")
27311                && entry.kind == WorkspaceEntryKind::Directory
27312        }));
27313        assert!(entries.iter().any(|entry| {
27314            entry.relative_path.as_utf8() == Some("src/lib.rs")
27315                && entry.kind == WorkspaceEntryKind::File
27316        }));
27317        assert!(entries.iter().any(|entry| {
27318            entry.relative_path.as_utf8() == Some("target-notes")
27319                && entry.kind == WorkspaceEntryKind::Directory
27320        }));
27321        assert!(entries.iter().any(|entry| {
27322            entry.relative_path.as_utf8() == Some("target-notes/plan.md")
27323                && entry.kind == WorkspaceEntryKind::File
27324        }));
27325        assert!(entries.iter().all(|entry| {
27326            let path = entry.relative_path.as_utf8().unwrap();
27327            !path.starts_with(".git")
27328                && !(path == "target" || path.starts_with("target/"))
27329                && !(path == "target-a2" || path.starts_with("target-a2/"))
27330                && !path.starts_with("node_modules")
27331        }));
27332
27333        std::fs::remove_dir_all(&root).unwrap();
27334    }
27335
27336    #[test]
27337    fn workspace_walk_stops_at_its_entry_cap_and_marks_a_partial_result() {
27338        let root = temporary_workspace_root("tree-entry-cap");
27339        std::fs::create_dir_all(&root).unwrap();
27340        for index in 0..20 {
27341            std::fs::write(root.join(format!("file-{index:02}.txt")), b"fixture\n").unwrap();
27342        }
27343
27344        let (entries, truncated, budget) =
27345            collect_workspace_entries(&root, generous_walk_deadline(), 5);
27346        assert!(truncated);
27347        assert!(budget.entry_cap_exceeded);
27348        assert!(!budget.time_budget_exceeded);
27349        assert!(entries.len() <= 5);
27350
27351        let inspection_truncation = workspace_inspection_truncation(&budget, false, Duration::from_millis(1))
27352            .expect("an entry-cap-truncated walk must report a truncation marker");
27353        assert!(inspection_truncation.walk_entry_cap_exceeded);
27354        assert!(!inspection_truncation.walk_time_budget_exceeded);
27355        assert!(!inspection_truncation.git_time_budget_exceeded);
27356        assert!(inspection_truncation.entries_visited >= 5);
27357
27358        std::fs::remove_dir_all(&root).unwrap();
27359    }
27360
27361    #[test]
27362    fn workspace_walk_within_budget_reports_no_truncation() {
27363        let root = temporary_workspace_root("tree-no-truncation");
27364        std::fs::create_dir_all(&root).unwrap();
27365        std::fs::write(root.join("only-file.txt"), b"fixture\n").unwrap();
27366
27367        let (_entries, truncated, budget) =
27368            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
27369        assert!(!truncated);
27370        assert!(workspace_inspection_truncation(&budget, false, Duration::from_millis(1)).is_none());
27371
27372        std::fs::remove_dir_all(&root).unwrap();
27373    }
27374
27375    #[test]
27376    fn workspace_walk_emits_strict_global_byte_order_for_prefix_named_siblings() {
27377        let root = temporary_workspace_root("tree-prefix-siblings");
27378        std::fs::create_dir_all(root.join("gate4agent-c2/src")).unwrap();
27379        std::fs::write(root.join("gate4agent-c2/src/lib.rs"), b"fixture\n").unwrap();
27380        std::fs::create_dir_all(root.join("gate4agent-c2-client")).unwrap();
27381        std::fs::write(root.join("gate4agent-c2-client/lib.rs"), b"fixture\n").unwrap();
27382
27383        let (entries, truncated, _budget) =
27384            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
27385        assert!(!truncated);
27386        let paths = entries
27387            .iter()
27388            .map(|entry| entry.relative_path.as_utf8().unwrap().to_owned())
27389            .collect::<Vec<_>>();
27390        assert!(paths.windows(2).all(|pair| pair[0] < pair[1]));
27391        assert_eq!(
27392            paths,
27393            vec![
27394                "gate4agent-c2".to_owned(),
27395                "gate4agent-c2-client".to_owned(),
27396                "gate4agent-c2-client/lib.rs".to_owned(),
27397                "gate4agent-c2/src".to_owned(),
27398                "gate4agent-c2/src/lib.rs".to_owned(),
27399            ],
27400        );
27401
27402        std::fs::remove_dir_all(&root).unwrap();
27403    }
27404
27405    #[test]
27406    fn git_parsers_cap_status_and_commit_results() {
27407        let mut status_output = Vec::new();
27408        for index in 0..=GIT_STATUS_MAX_ENTRIES {
27409            status_output.extend_from_slice(format!(" M src/file-{index}.rs\0").as_bytes());
27410        }
27411        let mut snapshot = git_snapshot_for_parser();
27412        parse_git_status(&status_output, &mut snapshot);
27413        assert_eq!(snapshot.status.len(), GIT_STATUS_MAX_ENTRIES);
27414        assert!(snapshot.truncated);
27415        assert_eq!(snapshot.status[0].index_status, " ");
27416        assert_eq!(snapshot.status[0].worktree_status, "M");
27417
27418        snapshot.truncated = false;
27419        let commit_output = (0..=GIT_COMMIT_MAX_ENTRIES)
27420            .map(|index| format!("abc{index}\u{1f}commit {index}\n"))
27421            .collect::<String>();
27422        parse_git_commits(commit_output.as_bytes(), &mut snapshot);
27423        assert_eq!(snapshot.recent_commits.len(), GIT_COMMIT_MAX_ENTRIES);
27424        assert!(snapshot.truncated);
27425    }
27426
27427    #[test]
27428    fn porcelain_v1_z_parser_preserves_current_then_previous_rename_and_copy_order() {
27429        let mut snapshot = git_snapshot_for_parser();
27430        parse_git_status(
27431            b"R  src/current name.rs\0src/previous name.rs\0 C src/copy.rs\0src/original.rs\0?? src/line\nname.rs\0",
27432            &mut snapshot,
27433        );
27434
27435        assert!(!snapshot.truncated);
27436        assert_eq!(snapshot.status.len(), 3);
27437        assert_eq!(snapshot.status[0].index_status, "R");
27438        assert_eq!(snapshot.status[0].worktree_status, " ");
27439        assert_eq!(snapshot.status[0].path.as_utf8(), Some("src/current name.rs"));
27440        assert_eq!(
27441            snapshot.status[0]
27442                .previous_path
27443                .as_ref()
27444                .and_then(RepositoryPath::as_utf8),
27445            Some("src/previous name.rs"),
27446        );
27447        assert_eq!(snapshot.status[1].index_status, " ");
27448        assert_eq!(snapshot.status[1].worktree_status, "C");
27449        assert_eq!(snapshot.status[1].path.as_utf8(), Some("src/copy.rs"));
27450        assert_eq!(
27451            snapshot.status[1]
27452                .previous_path
27453                .as_ref()
27454                .and_then(RepositoryPath::as_utf8),
27455            Some("src/original.rs"),
27456        );
27457        assert_eq!(snapshot.status[2].path.as_utf8(), Some("src/line\nname.rs"));
27458        assert!(snapshot.status[2].previous_path.is_none());
27459    }
27460
27461    #[test]
27462    fn porcelain_v1_z_parser_never_invents_invalid_or_incomplete_path_identity() {
27463        let mut snapshot = git_snapshot_for_parser();
27464        parse_git_status(
27465            b"?? valid.rs\0?? invalid-\xff.rs\0?? invalid\\windows.rs\0R  current.rs\0",
27466            &mut snapshot,
27467        );
27468
27469        assert!(snapshot.truncated);
27470        assert_eq!(snapshot.status.len(), 1);
27471        assert_eq!(snapshot.status[0].path.as_utf8(), Some("valid.rs"));
27472        assert!(snapshot.status[0].previous_path.is_none());
27473    }
27474
27475    #[tokio::test]
27476    async fn observer_can_browse_host_directories_without_controller() {
27477        let root = temporary_workspace_root("host-directory-observer");
27478        std::fs::create_dir_all(root.join("directory-b")).unwrap();
27479        std::fs::create_dir_all(root.join("directory-a")).unwrap();
27480        std::fs::write(root.join("ordinary-file"), b"not a directory").unwrap();
27481        let workspace_id = WorkspaceId::new("primary").unwrap();
27482        let workspace = WorkspaceConfig::new(workspace_id, &root).unwrap();
27483        let catalog = active_registry().unwrap();
27484        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27485        let shared = NodeShared::new(
27486            handle,
27487            "fixture-token".to_owned(),
27488            NodeId::new("node-1").unwrap(),
27489            vec![workspace],
27490            vec![agent("claude")],
27491        );
27492        let request = NodeRequest::BrowseHostDirectories {
27493            directory: Some(OpaqueHostPath::utf8(root.to_string_lossy().into_owned()).unwrap()),
27494            after: None,
27495        };
27496        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
27497            capability.as_str() == CAPABILITY_HOST_DIRECTORY_BROWSE_V1
27498        }));
27499        assert!(!request_uses_unnegotiated_capability(
27500            &request,
27501            &baseline_capabilities().unwrap(),
27502        ));
27503        assert!(request_uses_unnegotiated_capability(&request, &[]));
27504
27505        let response = process_request_inner(&shared, 77, ClientRole::Observer, request)
27506            .await
27507            .unwrap();
27508        let NodeResponse::HostDirectoriesBrowsed { listing } = response else {
27509            panic!("host directory browse returned another response");
27510        };
27511        assert_eq!(
27512            listing
27513                .entries
27514                .iter()
27515                .map(|entry| entry.display_name.as_str())
27516                .collect::<Vec<_>>(),
27517            vec!["directory-a", "directory-b"],
27518        );
27519        assert!(listing.entries.iter().all(|entry| !entry.is_link));
27520        assert!(!listing.incomplete);
27521        std::fs::remove_dir_all(root).unwrap();
27522    }
27523
27524    #[tokio::test]
27525    async fn observer_can_inspect_only_a_registered_workspace() {
27526        let root = temporary_workspace_root("observer");
27527        std::fs::create_dir_all(root.join("src")).unwrap();
27528        std::fs::write(root.join("src/main.rs"), b"fn main() {}\n").unwrap();
27529        let workspace_id = WorkspaceId::new("primary").unwrap();
27530        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27531        let catalog = active_registry().unwrap();
27532        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27533        let shared = NodeShared::new(
27534            handle,
27535            "fixture-token".to_owned(),
27536            NodeId::new("node-1").unwrap(),
27537            vec![workspace],
27538            vec![agent("claude")],
27539        );
27540
27541        let response = process_request_inner(
27542            &shared,
27543            77,
27544            ClientRole::Observer,
27545            NodeRequest::InspectWorkspace {
27546                workspace_id: workspace_id.clone(),
27547            },
27548        )
27549        .await
27550        .unwrap();
27551        let NodeResponse::WorkspaceInspected { inspection } = response else {
27552            panic!("workspace inspection returned another response");
27553        };
27554        assert_eq!(inspection.workspace_id, workspace_id);
27555        assert!(inspection.entries.iter().any(|entry| {
27556            entry.relative_path.as_utf8() == Some("src/main.rs")
27557        }));
27558
27559        let unknown = process_request_inner(
27560            &shared,
27561            77,
27562            ClientRole::Observer,
27563            NodeRequest::InspectWorkspace {
27564                workspace_id: WorkspaceId::new("unknown").unwrap(),
27565            },
27566        )
27567        .await
27568        .unwrap_err();
27569        assert_eq!(unknown.code, NodeFailureCode::UnknownWorkspace);
27570        std::fs::remove_dir_all(&root).unwrap();
27571    }
27572
27573    #[test]
27574    fn context_pack_source_accepts_only_running_or_successful_exit() {
27575        assert!(context_pack_source_status_is_usable(&SessionStatus::Running));
27576        assert!(context_pack_source_status_is_usable(&SessionStatus::Exited {
27577            exit_code: Some(0),
27578        }));
27579        assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
27580            exit_code: Some(1),
27581        }));
27582        assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
27583            exit_code: None,
27584        }));
27585        assert!(!context_pack_source_status_is_usable(&SessionStatus::Failed {
27586            message: "provider failed".to_owned(),
27587        }));
27588        assert!(!context_pack_source_status_is_usable(&SessionStatus::Registered));
27589        assert!(!context_pack_source_status_is_usable(&SessionStatus::Starting));
27590        assert!(!context_pack_source_status_is_usable(&SessionStatus::Stopping));
27591    }
27592
27593    #[test]
27594    fn context_pack_repository_rejects_changed_head_with_same_git_snapshot() {
27595        let git = git_snapshot_for_parser();
27596        let first = ContextPackRepositoryObservation {
27597            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27598            git: git.clone(),
27599            files: Vec::new(),
27600        };
27601        let second = ContextPackRepositoryObservation {
27602            head: ContextPackRepositoryHead::Commit("b".repeat(40)),
27603            git,
27604            files: Vec::new(),
27605        };
27606
27607        let error = stable_context_pack_repository(first, second).unwrap_err();
27608
27609        assert_eq!(
27610            error.code,
27611            NodeFailureCode::ContextPackMaterializationFailed,
27612        );
27613    }
27614
27615    #[test]
27616    fn context_pack_repository_rejects_changed_file_with_same_git_snapshot() {
27617        let git = git_snapshot_for_parser();
27618        let first = ContextPackRepositoryObservation {
27619            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27620            git: git.clone(),
27621            files: vec![ContextPackRepositoryFileSource::utf8(
27622                "README.md",
27623                "first\n".to_owned(),
27624                6,
27625            )],
27626        };
27627        let second = ContextPackRepositoryObservation {
27628            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27629            git,
27630            files: vec![ContextPackRepositoryFileSource::utf8(
27631                "README.md",
27632                "other\n".to_owned(),
27633                6,
27634            )],
27635        };
27636
27637        let error = stable_context_pack_repository(first, second).unwrap_err();
27638
27639        assert_eq!(
27640            error.code,
27641            NodeFailureCode::ContextPackMaterializationFailed,
27642        );
27643    }
27644
27645    #[tokio::test]
27646    async fn native_session_catalog_zero_active_session_fixture() {
27647        let workspace_root = temporary_workspace_root("native-catalog-workspace");
27648        let history_root = temporary_workspace_root("native-catalog-history");
27649        let outside_root = temporary_workspace_root("native-catalog-outside");
27650        std::fs::create_dir_all(&workspace_root).unwrap();
27651        std::fs::create_dir_all(&history_root).unwrap();
27652        std::fs::create_dir_all(&outside_root).unwrap();
27653        let transcript = |session_id: &str, cwd: &Path, title: &str| {
27654            [
27655                serde_json::json!({
27656                    "type": "user",
27657                    "sessionId": session_id,
27658                    "cwd": cwd.to_str().unwrap(),
27659                    "message": { "content": "raw user text must not cross the catalog wire" }
27660                }),
27661                serde_json::json!({
27662                    "type": "assistant",
27663                    "sessionId": session_id,
27664                    "cwd": cwd.to_str().unwrap(),
27665                    "message": { "model": "claude-sonnet", "content": "raw answer" }
27666                }),
27667                serde_json::json!({
27668                    "type": "ai-title",
27669                    "sessionId": session_id,
27670                    "aiTitle": title
27671                }),
27672            ]
27673            .into_iter()
27674            .map(|record| record.to_string())
27675            .collect::<Vec<_>>()
27676            .join("\n")
27677        };
27678        std::fs::write(
27679            history_root.join("stable-session.jsonl"),
27680            transcript("stable-session", &workspace_root, "Workspace review"),
27681        )
27682        .unwrap();
27683        std::fs::write(
27684            history_root.join("outside-session.jsonl"),
27685            transcript("outside-session", &outside_root, "Outside review"),
27686        )
27687        .unwrap();
27688        let history = NativeHistoryConfig::new(vec![
27689            NativeHistoryRoot::new(
27690                gate4agent_types::AdapterId::new("claude-code").unwrap(),
27691                HistorySourceLayout::SingleNdjson,
27692                history_root.clone(),
27693            )
27694            .unwrap(),
27695        ])
27696        .unwrap();
27697        let catalog = active_registry().unwrap();
27698        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27699        assert!(handle.snapshot().sessions.is_empty());
27700        let workspace_id = WorkspaceId::new("primary").unwrap();
27701        let workspace = WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap();
27702        let mut shared = NodeShared::new(
27703            handle,
27704            "fixture-token".to_owned(),
27705            NodeId::new("node-native-catalog").unwrap(),
27706            vec![workspace],
27707            vec![agent("claude")],
27708        );
27709        shared.native_session_catalog = Some(Arc::new(Mutex::new(
27710            NativeSessionCatalogAuthority::new(history),
27711        )));
27712        let route = NativeSessionCatalogRoute::workspace(
27713            workspace_id.clone(),
27714            agent("claude"),
27715        );
27716        let request = NodeRequest::CatalogNativeSessions {
27717            route: route.clone(),
27718            limit: 2,
27719        };
27720        let observer = process_request_inner(&shared, 7, ClientRole::Observer, request.clone())
27721            .await
27722            .unwrap_err();
27723        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27724        let response = process_request_inner(&shared, 7, ClientRole::Operator, request)
27725            .await
27726            .unwrap();
27727        let NodeResponse::NativeSessionsCataloged { entries, summary, .. } = response else {
27728            panic!("native catalog returned a different response");
27729        };
27730        let summary = summary.expect("node must emit native session catalog summary metadata");
27731        assert_eq!(entries.len(), 1);
27732        assert!(gate4agent_types::validate_candidate_id(&entries[0].selection_id).is_ok());
27733        assert_eq!(entries[0].external_group, None);
27734        assert_eq!(entries[0].record_id, None);
27735        assert_eq!(entries[0].title, None);
27736        assert_eq!(entries[0].model, None);
27737        assert_eq!(entries[0].message_count, 0);
27738        let encoded_entries = serde_json::to_string(&entries).unwrap();
27739        assert!(!encoded_entries.contains("stable-session"));
27740        assert!(!encoded_entries.contains(history_root.to_string_lossy().as_ref()));
27741        let page_request = NodeRequest::PageNativeSessions {
27742            route: route.clone(),
27743            window: crate::protocol::NativeSessionCatalogWindow::Recent,
27744            catalog_revision: summary.catalog_revision,
27745            recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27746            after_selection_id: None,
27747            limit: 2,
27748        };
27749        let observer = process_request_inner(
27750            &shared,
27751            7,
27752            ClientRole::Observer,
27753            page_request.clone(),
27754        )
27755        .await
27756        .unwrap_err();
27757        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27758        let page_response = process_request_inner(
27759            &shared,
27760            7,
27761            ClientRole::Operator,
27762            page_request.clone(),
27763        )
27764        .await
27765        .unwrap();
27766        assert!(matches!(
27767            page_response,
27768            NodeResponse::NativeSessionsPaged { page, .. }
27769                if page.entries.len() == 1 && !page.has_more && page.remaining_count == 0
27770        ));
27771        let stale = process_request_inner(
27772            &shared,
27773            7,
27774            ClientRole::Operator,
27775            NodeRequest::PageNativeSessions {
27776                route: route.clone(),
27777                window: crate::protocol::NativeSessionCatalogWindow::Recent,
27778                catalog_revision: summary.catalog_revision.wrapping_add(1),
27779                recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27780                after_selection_id: None,
27781                limit: 2,
27782            },
27783        )
27784        .await
27785        .unwrap_err();
27786        assert_eq!(stale.code, NodeFailureCode::StaleNativeSessionCatalog);
27787        let selection = NativeSessionSelection {
27788            route: route.clone(),
27789            catalog_revision: summary.catalog_revision,
27790            recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27791            selection_id: entries[0].selection_id.clone(),
27792        };
27793        let preview_response = process_request_inner(
27794            &shared,
27795            7,
27796            ClientRole::Operator,
27797            NodeRequest::PreviewNativeSession {
27798                selection: selection.clone(),
27799                message_limit: 1,
27800            },
27801        )
27802        .await
27803        .unwrap();
27804        let NodeResponse::NativeSessionPreviewed { preview, .. } = preview_response else {
27805            panic!("native preview returned a different response");
27806        };
27807        assert_eq!(preview.messages.len(), 1);
27808        assert!(preview.message_count_exact);
27809        assert_eq!(preview.messages[0].text, "raw answer");
27810        assert!(preview.truncated);
27811
27812        let index_request = NodeRequest::IndexNativeSession {
27813            selection: selection.clone(),
27814            display_name: "Saved Claude".to_owned(),
27815        };
27816        let observer = process_request_inner(
27817            &shared,
27818            7,
27819            ClientRole::Observer,
27820            index_request.clone(),
27821        )
27822        .await
27823        .unwrap_err();
27824        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27825        let without_controller = process_request_inner(
27826            &shared,
27827            7,
27828            ClientRole::Operator,
27829            index_request.clone(),
27830        )
27831        .await
27832        .unwrap_err();
27833        assert_eq!(without_controller.code, NodeFailureCode::ControllerRequired);
27834        let acquired = process_request_inner(
27835            &shared,
27836            7,
27837            ClientRole::Operator,
27838            NodeRequest::AcquireController { lease_ms: 5_000 },
27839        )
27840        .await
27841        .unwrap();
27842        assert!(matches!(acquired, NodeResponse::Controller { controller: Some(_) }));
27843        let indexed = process_request_inner(
27844            &shared,
27845            7,
27846            ClientRole::Operator,
27847            index_request,
27848        )
27849        .await
27850        .unwrap();
27851        assert!(indexed.native_session_index_contract_is_valid());
27852        let NodeResponse::NativeSessionIndexed {
27853            selection: echoed_selection,
27854            record,
27855        } = indexed else {
27856            panic!("native session index returned a different response");
27857        };
27858        assert_eq!(echoed_selection, selection);
27859        let recataloged = process_request_inner(
27860            &shared,
27861            7,
27862            ClientRole::Operator,
27863            NodeRequest::CatalogNativeSessions {
27864                route,
27865                limit: 2,
27866            },
27867        )
27868        .await
27869        .unwrap();
27870        let NodeResponse::NativeSessionsCataloged { entries, .. } = recataloged else {
27871            panic!("native catalog returned a different response after indexing");
27872        };
27873        assert_eq!(entries[0].record_id.as_ref(), Some(&record.record_id));
27874
27875        let unregistered_route = NativeSessionCatalogRoute::unregistered(agent("claude"));
27876        let external_catalog = process_request_inner(
27877            &shared,
27878            7,
27879            ClientRole::Operator,
27880            NodeRequest::CatalogNativeSessions {
27881                route: unregistered_route.clone(),
27882                limit: 2,
27883            },
27884        )
27885        .await
27886        .unwrap();
27887        let external_json = serde_json::to_string(&external_catalog).unwrap();
27888        assert!(!external_json.contains("outside-session"));
27889        assert!(!external_json.contains(outside_root.to_string_lossy().as_ref()));
27890        assert!(!external_json.contains(history_root.to_string_lossy().as_ref()));
27891        assert!(!external_json.contains("\"session_id\""));
27892        assert!(!external_json.contains("\"cwd\""));
27893        assert!(!external_json.contains("\"path\""));
27894        let NodeResponse::NativeSessionsCataloged {
27895            route: echoed_route,
27896            entries: external_entries,
27897            summary: Some(external_summary),
27898        } = external_catalog
27899        else {
27900            panic!("unregistered native catalog returned a different response");
27901        };
27902        assert_eq!(echoed_route, unregistered_route);
27903        assert_eq!(external_entries.len(), 1);
27904        assert_eq!(external_entries[0].record_id, None);
27905        let external_group = external_entries[0]
27906            .external_group
27907            .as_ref()
27908            .expect("unregistered session must have an external group");
27909        assert_eq!(
27910            external_group.kind,
27911            gate4agent_types::NativeSessionExternalGroupKind::Project,
27912        );
27913        assert!(external_group.group_id.starts_with("external-"));
27914        assert_eq!(
27915            external_group.display_name,
27916            outside_root.file_name().unwrap().to_string_lossy(),
27917        );
27918        let external_selection = NativeSessionSelection {
27919            route: unregistered_route,
27920            catalog_revision: external_summary.catalog_revision,
27921            recent_cutoff_unix_ms: external_summary.recent_cutoff_unix_ms,
27922            selection_id: external_entries[0].selection_id.clone(),
27923        };
27924        let external_preview = process_request_inner(
27925            &shared,
27926            7,
27927            ClientRole::Operator,
27928            NodeRequest::PreviewNativeSession {
27929                selection: external_selection.clone(),
27930                message_limit: 1,
27931            },
27932        )
27933        .await
27934        .unwrap();
27935        let external_preview_json = serde_json::to_string(&external_preview).unwrap();
27936        assert!(!external_preview_json.contains("outside-session"));
27937        assert!(!external_preview_json.contains(outside_root.to_string_lossy().as_ref()));
27938        assert!(!external_preview_json.contains("\"session_id\""));
27939        let NodeResponse::NativeSessionPreviewed { preview, .. } = external_preview else {
27940            panic!("unregistered native preview returned a different response");
27941        };
27942        assert_eq!(preview.messages.len(), 1);
27943        let external_index = process_request_inner(
27944            &shared,
27945            7,
27946            ClientRole::Operator,
27947            NodeRequest::IndexNativeSession {
27948                selection: external_selection,
27949                display_name: "External Claude".to_owned(),
27950            },
27951        )
27952        .await
27953        .unwrap_err();
27954        assert_eq!(
27955            external_index.code,
27956            NodeFailureCode::WorkspaceRegistrationRequired,
27957        );
27958        let record_response = process_request_inner(
27959            &shared,
27960            7,
27961            ClientRole::Operator,
27962            NodeRequest::PreviewSessionRecord {
27963                record_id: record.record_id.clone(),
27964                message_limit: 2,
27965            },
27966        )
27967        .await
27968        .unwrap();
27969        let NodeResponse::SessionRecordPreviewed { record_id, preview } = record_response else {
27970            panic!("record preview returned a different response");
27971        };
27972        assert_eq!(record_id, record.record_id);
27973        assert_eq!(preview.messages.len(), 2);
27974        let encoded = serde_json::to_string(&preview).unwrap();
27975        assert!(!encoded.contains("stable-session"));
27976        assert!(!encoded.contains(history_root.to_string_lossy().as_ref()));
27977        let NodeResponse::Resync { events, .. } = shared.resync(0) else {
27978            panic!("record preview history summary returned a different response");
27979        };
27980        let summaries = events
27981            .iter()
27982            .filter_map(|envelope| match &envelope.event {
27983                NodeEvent::SessionRecordHistorySummarized { record_id, summary }
27984                    if record_id == &record.record_id =>
27985                {
27986                    Some(*summary)
27987                }
27988                _ => None,
27989            })
27990            .collect::<Vec<_>>();
27991        assert_eq!(summaries, vec![SessionHistorySummaryV1::from(&preview)]);
27992        let summary_json = serde_json::to_string(&summaries).unwrap();
27993        for private in [
27994            "stable-session",
27995            "raw user text must not cross the catalog wire",
27996            "raw answer",
27997            "Workspace review",
27998            "claude-sonnet",
27999        ] {
28000            assert!(!summary_json.contains(private), "{private}");
28001        }
28002        let identity = record.provider_session.clone().unwrap();
28003        assert_eq!(
28004            shared.revalidate_session_record_preview(&record, &identity),
28005            Ok(())
28006        );
28007        shared.remove_record_memory(&record.record_id);
28008        assert_eq!(
28009            shared
28010                .revalidate_session_record_preview(&record, &identity)
28011                .unwrap_err()
28012                .code,
28013            NodeFailureCode::UnknownSessionRecord
28014        );
28015        let released = process_request_inner(
28016            &shared,
28017            7,
28018            ClientRole::Operator,
28019            NodeRequest::ReleaseController,
28020        )
28021        .await
28022        .unwrap();
28023        assert!(matches!(released, NodeResponse::Controller { controller: None }));
28024        assert!(shared.controller_state().is_none());
28025        std::fs::remove_dir_all(workspace_root).unwrap();
28026        std::fs::remove_dir_all(history_root).unwrap();
28027        std::fs::remove_dir_all(outside_root).unwrap();
28028    }
28029
28030    #[tokio::test]
28031    async fn native_session_catalog_concurrent_operation_fails_fast_without_waiting_on_mutex() {
28032        let history_root = temporary_workspace_root("native-catalog-concurrent-operation");
28033        std::fs::create_dir_all(&history_root).unwrap();
28034        let history = NativeHistoryConfig::new(vec![
28035            NativeHistoryRoot::new(
28036                gate4agent_types::AdapterId::new("claude-code").unwrap(),
28037                HistorySourceLayout::SingleNdjson,
28038                history_root.clone(),
28039            )
28040            .unwrap(),
28041        ])
28042        .unwrap();
28043        let catalog = Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)));
28044        let held = catalog.lock().unwrap();
28045        let result = timeout(
28046            Duration::from_secs(1),
28047            run_native_session_catalog_operation(
28048                Arc::clone(&catalog),
28049                "native session catalog",
28050                |_| Ok::<(), std::convert::Infallible>(()),
28051            ),
28052        )
28053        .await
28054        .expect("concurrent native catalog request must not wait for the operation deadline")
28055        .unwrap_err();
28056        assert_eq!(result.code, NodeFailureCode::BackendBusy);
28057        drop(held);
28058        std::fs::remove_dir_all(history_root).unwrap();
28059    }
28060
28061    fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
28062        let unique = std::time::SystemTime::now()
28063            .duration_since(std::time::UNIX_EPOCH)
28064            .unwrap()
28065            .as_nanos();
28066        std::env::temp_dir().join(format!(
28067            "gate4agent-node-{label}-{}-{unique}",
28068            std::process::id(),
28069        ))
28070    }
28071
28072    fn git_snapshot_for_parser() -> GitSnapshot {
28073        GitSnapshot {
28074            is_repository: true,
28075            branch: Some("main".to_owned()),
28076            status: Vec::new(),
28077            recent_commits: Vec::new(),
28078            worktrees: Vec::new(),
28079            managed_worktree: None,
28080            truncated: false,
28081            diagnostic: None,
28082        }
28083    }
28084
28085    #[tokio::test]
28086    async fn workspace_inspection_scopes_only_exact_active_managed_branch() {
28087        let root = temporary_workspace_root("managed-git-scope");
28088        std::fs::create_dir_all(&root).unwrap();
28089        run_test_git(&root, &["init"]);
28090        run_test_git(&root, &["config", "user.email", "gate4agent@example.invalid"]);
28091        run_test_git(&root, &["config", "user.name", "Gate4Agent Test"]);
28092        std::fs::write(root.join("seed.txt"), "seed\n").unwrap();
28093        run_test_git(&root, &["add", "seed.txt"]);
28094        run_test_git(&root, &["commit", "-m", "seed"]);
28095        run_test_git(&root, &["checkout", "-b", "gate4agent/a"]);
28096
28097        let workspace_id = WorkspaceId::new("managed-a").unwrap();
28098        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
28099        let catalog = active_registry().unwrap();
28100        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28101        let shared = NodeShared::new(
28102            handle,
28103            "fixture-token".to_owned(),
28104            NodeId::new("node-1").unwrap(),
28105            vec![workspace],
28106            vec![agent("claude")],
28107        );
28108        let mut lease = ManagedWorktreeLeaseRecord {
28109            lease_id: ManagedWorktreeLeaseId::new("mw-a").unwrap(),
28110            source_workspace_id: WorkspaceId::new("primary").unwrap(),
28111            workspace_id: workspace_id.clone(),
28112            profile_id: WorktreeProfileId::new("default").unwrap(),
28113            profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
28114            target_root: root.to_string_lossy().into_owned(),
28115            branch: "gate4agent/a".to_owned(),
28116            base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
28117            expected_head: None,
28118            retention: ManagedWorktreeRetention::Retain,
28119            state: ManagedWorktreeLeaseState::InUse,
28120            session_holders: vec![ManagedWorktreeSessionHolder {
28121                incarnation_id: shared.incarnation_id,
28122                instance_id: AgentInstanceId(7),
28123                generation: SessionGeneration(1),
28124            }],
28125            record_holders: Vec::new(),
28126            cleanup_failure: None,
28127            created_at_unix_ms: 1,
28128            updated_at_unix_ms: 1,
28129        };
28130        *shared
28131            .managed_worktrees
28132            .lock()
28133            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
28134            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
28135
28136        let inspection = shared.inspect_workspace(workspace_id.clone()).await.unwrap();
28137        let scope = inspection.git.managed_worktree.unwrap();
28138        assert_eq!(scope.lease_id, lease.lease_id);
28139        assert_eq!(scope.branch, "gate4agent/a");
28140        assert_eq!(scope.active_session_count, 1);
28141
28142        lease.branch = "gate4agent/b".to_owned();
28143        *shared
28144            .managed_worktrees
28145            .lock()
28146            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
28147            ManagedWorktreeRegistry::from_records(vec![lease], Vec::new()).unwrap();
28148        let mismatch = shared.inspect_workspace(workspace_id).await.unwrap();
28149        assert!(mismatch.git.managed_worktree.is_none());
28150        std::fs::remove_dir_all(root).unwrap();
28151    }
28152
28153    #[tokio::test]
28154    async fn workspace_file_write_requires_controller_and_rejects_stale_revision() {
28155        let root = temporary_workspace_root("workspace-file-write-controller");
28156        std::fs::create_dir_all(root.join("src")).unwrap();
28157        std::fs::write(root.join("src/lib.rs"), b"old\n").unwrap();
28158        let workspace_id = WorkspaceId::new("primary").unwrap();
28159        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
28160        let catalog = active_registry().unwrap();
28161        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28162        let shared = NodeShared::new(
28163            handle,
28164            "fixture-token".to_owned(),
28165            NodeId::new("node-1").unwrap(),
28166            vec![workspace],
28167            vec![agent("claude")],
28168        );
28169        let path = RepositoryPath::utf8("src/lib.rs".to_owned()).unwrap();
28170        let expected_revision = workspace_file_revision(b"old\n");
28171        let request = NodeRequest::WriteWorkspaceFile {
28172            workspace_id: workspace_id.clone(),
28173            path: path.clone(),
28174            expected_revision: expected_revision.clone(),
28175            text: "new\n".to_owned(),
28176        };
28177        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
28178            .await
28179            .unwrap_err();
28180        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
28181        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
28182            .await
28183            .unwrap_err();
28184        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
28185        shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
28186        let response = process_request_inner(&shared, 77, ClientRole::Operator, request).await.unwrap();
28187        let NodeResponse::WorkspaceFileWritten { file } = response else {
28188            panic!("workspace file write returned another response");
28189        };
28190        assert_eq!(file.revision, Some(workspace_file_revision(b"new\n")));
28191        assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
28192        let stale = process_request_inner(
28193            &shared,
28194            77,
28195            ClientRole::Operator,
28196            NodeRequest::WriteWorkspaceFile {
28197                workspace_id,
28198                path,
28199                expected_revision,
28200                text: "lost update\n".to_owned(),
28201            },
28202        )
28203        .await
28204        .unwrap_err();
28205        assert_eq!(stale.code, NodeFailureCode::RepositoryFileRevisionConflict);
28206        assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
28207        std::fs::remove_dir_all(root).unwrap();
28208    }
28209
28210    #[tokio::test]
28211    async fn workspace_entry_create_requires_controller_and_never_overwrites() {
28212        let root = temporary_workspace_root("workspace-entry-create-controller");
28213        std::fs::create_dir_all(root.join("src")).unwrap();
28214        let workspace_id = WorkspaceId::new("primary").unwrap();
28215        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
28216        let catalog = active_registry().unwrap();
28217        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28218        let shared = NodeShared::new(
28219            handle,
28220            "fixture-token".to_owned(),
28221            NodeId::new("node-1").unwrap(),
28222            vec![workspace],
28223            vec![agent("claude")],
28224        );
28225        let path = RepositoryPath::utf8("src/new.rs".to_owned()).unwrap();
28226        let request = NodeRequest::CreateWorkspaceFile {
28227            workspace_id: workspace_id.clone(),
28228            path: path.clone(),
28229        };
28230        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
28231            capability.as_str() == NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY
28232        }));
28233        assert!(!request_uses_unnegotiated_capability(
28234            &request,
28235            &baseline_capabilities().unwrap(),
28236        ));
28237        assert!(request_uses_unnegotiated_capability(&request, &[]));
28238        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
28239            .await
28240            .unwrap_err();
28241        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
28242        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
28243            .await
28244            .unwrap_err();
28245        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
28246        shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
28247
28248        let response = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
28249            .await
28250            .unwrap();
28251        let NodeResponse::WorkspaceFileCreated { file } = response else {
28252            panic!("workspace file create returned another response");
28253        };
28254        assert_eq!(file.workspace_id, workspace_id);
28255        assert_eq!(file.path, path);
28256        assert_eq!(file.revision, Some(workspace_file_revision(&[])));
28257        assert_eq!(std::fs::read(root.join("src/new.rs")).unwrap(), Vec::<u8>::new());
28258
28259        let duplicate = process_request_inner(&shared, 77, ClientRole::Operator, request)
28260            .await
28261            .unwrap_err();
28262        assert_eq!(duplicate.code, NodeFailureCode::RepositoryEntryAlreadyExists);
28263
28264        let directory_path = RepositoryPath::utf8("src/nested".to_owned()).unwrap();
28265        let response = process_request_inner(
28266            &shared,
28267            77,
28268            ClientRole::Operator,
28269            NodeRequest::CreateWorkspaceDirectory {
28270                workspace_id: workspace_id.clone(),
28271                path: directory_path.clone(),
28272            },
28273        )
28274        .await
28275        .unwrap();
28276        assert_eq!(
28277            response,
28278            NodeResponse::WorkspaceDirectoryCreated {
28279                workspace_id: workspace_id.clone(),
28280                entry: WorkspaceEntry {
28281                    relative_path: directory_path,
28282                    kind: WorkspaceEntryKind::Directory,
28283                },
28284            },
28285        );
28286        assert!(root.join("src/nested").is_dir());
28287
28288        let missing_parent = process_request_inner(
28289            &shared,
28290            77,
28291            ClientRole::Operator,
28292            NodeRequest::CreateWorkspaceFile {
28293                workspace_id: workspace_id.clone(),
28294                path: RepositoryPath::utf8("missing/new.rs".to_owned()).unwrap(),
28295            },
28296        )
28297        .await
28298        .unwrap_err();
28299        assert_eq!(missing_parent.code, NodeFailureCode::RepositoryParentNotFound);
28300        let non_directory_parent = process_request_inner(
28301            &shared,
28302            77,
28303            ClientRole::Operator,
28304            NodeRequest::CreateWorkspaceFile {
28305                workspace_id,
28306                path: RepositoryPath::utf8("src/new.rs/child".to_owned()).unwrap(),
28307            },
28308        )
28309        .await
28310        .unwrap_err();
28311        assert_eq!(
28312            non_directory_parent.code,
28313            NodeFailureCode::RepositoryParentNotDirectory,
28314        );
28315        std::fs::remove_dir_all(root).unwrap();
28316    }
28317
28318    #[tokio::test]
28319    async fn workspace_entry_create_timeout_cancels_before_commit_without_late_mutation() {
28320        let root = temporary_workspace_root("workspace-entry-create-timeout-cancel");
28321        std::fs::create_dir_all(root.join("src")).unwrap();
28322        #[cfg(windows)]
28323        let root = std::fs::canonicalize(root).unwrap();
28324        let path = RepositoryPath::utf8("src/late.rs".to_owned()).unwrap();
28325        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
28326        let barrier = Arc::new(std::sync::Barrier::new(2));
28327        let done = Arc::new(AtomicBool::new(false));
28328        let task_commit_state = Arc::clone(&commit_state);
28329        let task_barrier = Arc::clone(&barrier);
28330        let task_done = Arc::clone(&done);
28331        let task_root = root.clone();
28332        let task = tokio::task::spawn_blocking(move || {
28333            task_barrier.wait();
28334            let result = create_workspace_file_on_disk(
28335                &task_root,
28336                &path,
28337                &task_commit_state,
28338            );
28339            task_done.store(true, Ordering::Release);
28340            result
28341        });
28342
28343        let error = settle_workspace_entry_create(
28344            task,
28345            Arc::clone(&commit_state),
28346            Duration::from_millis(10),
28347            "fixture deadline",
28348            "fixture task failed",
28349        )
28350        .await
28351        .unwrap_err();
28352        assert_eq!(error.code, NodeFailureCode::RepositoryEntryCreateTimedOut);
28353        assert_eq!(
28354            commit_state.load(Ordering::Acquire),
28355            WORKSPACE_ENTRY_CREATE_CANCELED,
28356        );
28357
28358        barrier.wait();
28359        timeout(Duration::from_secs(1), async {
28360            while !done.load(Ordering::Acquire) {
28361                tokio::task::yield_now().await;
28362            }
28363        })
28364        .await
28365        .expect("canceled blocking create did not settle");
28366        assert!(!root.join("src/late.rs").exists());
28367        std::fs::remove_dir_all(root).unwrap();
28368    }
28369}
28370
28371#[cfg(test)]
28372mod provisional_spawn_tests {
28373    use super::*;
28374    use std::sync::Arc;
28375
28376    fn shared() -> NodeShared {
28377        let catalog = active_registry().unwrap();
28378        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28379        let workspace = WorkspaceConfig::new(
28380            WorkspaceId::new("primary").unwrap(),
28381            std::env::current_dir().unwrap(),
28382        )
28383        .unwrap()
28384        .with_worktree_service_mode(WorktreeServiceMode::Manual);
28385        NodeShared::new(
28386            handle,
28387            "fixture-token".to_owned(),
28388            NodeId::new("node-provisional-test").unwrap(),
28389            vec![workspace],
28390            vec![AgentId::new("claude").unwrap()],
28391        )
28392    }
28393
28394    /// Provisional draft map: sessionize by instance id only; 50 sequential
28395    /// mark/claim cycles never collide, and a claimed draft is never reaped.
28396    #[test]
28397    fn provisional_spawn_keys_are_unique_and_sessionize_prevents_reap() {
28398        let shared = shared();
28399        let ws = WorkspaceId::new("primary").unwrap();
28400        let mut ids = Vec::with_capacity(50);
28401        for i in 1u64..=50 {
28402            let id = AgentInstanceId(i);
28403            ids.push(id);
28404            let address = SessionAddress {
28405                workspace_id: ws.clone(),
28406                session: SessionKey {
28407                    instance_id: id,
28408                    generation: SessionGeneration(1),
28409                },
28410            };
28411            shared.mark_spawn_provisional(address);
28412        }
28413        assert_eq!(
28414            shared
28415                .provisional_spawns
28416                .lock()
28417                .unwrap_or_else(|p| p.into_inner())
28418                .len(),
28419            50
28420        );
28421        for id in ids.iter().step_by(2) {
28422            shared.sessionize_spawn(*id);
28423        }
28424        assert_eq!(
28425            shared
28426                .provisional_spawns
28427                .lock()
28428                .unwrap_or_else(|p| p.into_inner())
28429                .len(),
28430            25
28431        );
28432        assert!(shared.take_due_provisional_spawns().is_empty());
28433        {
28434            let mut map = shared
28435                .provisional_spawns
28436                .lock()
28437                .unwrap_or_else(|p| p.into_inner());
28438            let past = Instant::now() - Duration::from_secs(91);
28439            for (_, (_, at)) in map.iter_mut() {
28440                *at = past;
28441            }
28442        }
28443        let due = shared.take_due_provisional_spawns();
28444        assert_eq!(due.len(), 25);
28445        let due_ids: std::collections::BTreeSet<_> =
28446            due.iter().map(|a| a.session.instance_id.0).collect();
28447        let expected: std::collections::BTreeSet<_> =
28448            ids.iter().skip(1).step_by(2).map(|id| id.0).collect();
28449        assert_eq!(due_ids, expected);
28450        assert!(shared
28451            .provisional_spawns
28452            .lock()
28453            .unwrap_or_else(|p| p.into_inner())
28454            .is_empty());
28455    }
28456
28457    /// Twenty parallel provisional marks must each get their own instance key;
28458    /// killing/sessionizing one id must not touch the others.
28459    #[test]
28460    fn provisional_spawn_parallel_marks_isolate_kills_by_instance_id() {
28461        let shared = Arc::new(shared());
28462        let ws = WorkspaceId::new("primary").unwrap();
28463        let mut handles = Vec::new();
28464        for i in 1u64..=20 {
28465            let shared = Arc::clone(&shared);
28466            let ws = ws.clone();
28467            handles.push(std::thread::spawn(move || {
28468                let id = AgentInstanceId(i);
28469                let address = SessionAddress {
28470                    workspace_id: ws,
28471                    session: SessionKey {
28472                        instance_id: id,
28473                        generation: SessionGeneration(1),
28474                    },
28475                };
28476                shared.mark_spawn_provisional(address);
28477                id
28478            }));
28479        }
28480        let mut ids = Vec::new();
28481        for h in handles {
28482            ids.push(h.join().unwrap());
28483        }
28484        ids.sort_by_key(|id| id.0);
28485        assert_eq!(ids.len(), 20);
28486        ids.dedup();
28487        assert_eq!(ids.len(), 20, "parallel marks must not share instance keys");
28488        assert_eq!(
28489            shared
28490                .provisional_spawns
28491                .lock()
28492                .unwrap_or_else(|p| p.into_inner())
28493                .len(),
28494            20
28495        );
28496        shared.sessionize_spawn(AgentInstanceId(7));
28497        let left: Vec<_> = shared
28498            .provisional_spawns
28499            .lock()
28500            .unwrap_or_else(|p| p.into_inner())
28501            .keys()
28502            .map(|id| id.0)
28503            .collect();
28504        assert!(!left.contains(&7));
28505        assert_eq!(left.len(), 19);
28506    }
28507}
28508
28509
28510#[cfg(unix)]
28511extern "C" {
28512    #[link_name = "geteuid"]
28513    fn libc_geteuid() -> u32;
28514}
28515
28516#[cfg(test)]
28517mod call_home_link_tests {
28518    use super::*;
28519
28520    #[test]
28521    fn call_home_backoff_is_capped_and_monotonic() {
28522        let mut last = Duration::ZERO;
28523        for attempt in 1..20 {
28524            let next = call_home_backoff(attempt);
28525            assert!(next >= last);
28526            assert!(next <= Duration::from_secs(8));
28527            last = next;
28528        }
28529        assert_eq!(call_home_backoff(0), Duration::from_millis(500));
28530    }
28531
28532    #[tokio::test]
28533    async fn call_home_socket_gets_keepalive() {
28534        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
28535        let addr = listener.local_addr().unwrap();
28536        let (client, _server) = tokio::join!(tokio::net::TcpStream::connect(addr), listener.accept());
28537        let client = client.unwrap();
28538        enable_call_home_keepalive(&client).unwrap();
28539        let sock = socket2::SockRef::from(&client);
28540        assert!(sock.keepalive().unwrap());
28541        #[cfg(any(target_os = "linux", target_os = "macos"))]
28542        assert_eq!(sock.tcp_keepalive_time().unwrap(), CALL_HOME_KEEPALIVE_IDLE);
28543    }
28544}
28545
28546/// A provider transcript for this session id exists in a known provider home.
28547fn provider_session_exists(provider: &str, id: &str) -> bool {
28548    if provider != "claude" { return false; }
28549    let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
28550    if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") { homes.extend(std::env::split_paths(&extra)); }
28551    let name = format!("{id}.jsonl");
28552    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())))
28553}
28554
28555/// Append launch args to a prepared overlay (or create an instance overlay for them).
28556fn overlay_with_extra_args(
28557    provider: &AgentId,
28558    mode: SessionMode,
28559    overlay: Option<PreparedNativeLaunchOverlay>,
28560    args: Vec<OsString>,
28561) -> Result<Option<PreparedNativeLaunchOverlay>, NodeFailure> {
28562    let bad = || failure(NodeFailureCode::BackendOperationFailed, "resume launch args overlay is invalid");
28563    Ok(match (overlay, args) {
28564        (prepared, args) if args.is_empty() => prepared,
28565        (Some(PreparedNativeLaunchOverlay::Instance(mut o)), args) => { o.append_extra_args(args).map_err(|_| bad())?; Some(PreparedNativeLaunchOverlay::Instance(o)) }
28566        (Some(PreparedNativeLaunchOverlay::Environment(o)), args) => Some(PreparedNativeLaunchOverlay::Instance(o.into_instance_with_extra_args(args).map_err(|_| bad())?)),
28567        (None, args) => Some(PreparedNativeLaunchOverlay::Instance(NativeInstanceLaunchOverlay::new(
28568            provider.clone(),
28569            match mode { SessionMode::Pty => TransportKind::Pty, SessionMode::Acp => TransportKind::Acp, SessionMode::Inline => TransportKind::Pipe },
28570            Vec::new(),
28571            args,
28572        ).map_err(|_| bad())?)),
28573    })
28574}