Skip to main content

hatchery_node/
server.rs

1use crate::git_worktree::{
2    create_worktree as create_git_worktree,
3    create_worktree_with_timeout as create_git_worktree_with_timeout,
4    list_worktrees as list_git_worktrees,
5    list_worktrees_with_deadline as list_git_worktrees_with_deadline,
6    paths_equal as worktree_paths_equal, remove_worktree as remove_git_worktree,
7    removal_lookup_path as normalize_worktree_removal_target,
8    resolve_base_commit_with_timeout,
9    run_git_read_bounded, GitCommandOutput, GitWorktreeError, GitWorktreeErrorKind,
10    NativeGitWorktreeSnapshot,
11};
12use crate::host_directory_browser::{
13    browse_host_directories, HostDirectoryBrowseError, HostDirectoryBrowseErrorKind,
14};
15use crate::environment_profiles::{
16    EnvironmentProfileBinding, NodeEnvironmentProfile, MAX_NODE_ENVIRONMENT_PROFILES,
17};
18use crate::bundle_catalog::{BundleCatalog, NodeBundle};
19use self::bundle_delivery::{DeliveryStore, DeliveryStoreError};
20use self::context_pack_store::ContextPackStore;
21use crate::bundle_provider::{
22    bundle_launch_arguments, validate_bundle_binding, BundleProviderLayout,
23};
24use crate::context_pack::{
25    ContextPackCatalog, ContextPackRepository, ContextPackRepositoryFileSource,
26    ContextPackSelectedFileSkipReason, NodeContextPack, CONTEXT_PACK_SELECTED_FILES,
27};
28use crate::harness_mcp_proxy::{
29    HarnessMcpProxyError, HarnessMcpProxyRegistry, PreparedHarnessMcpSpawn,
30    ReviewedHarnessMcpProgram,
31};
32use crate::session_environment::{
33    MaterializationId, MaterializationOwner, MaterializationOwnershipRecord,
34    MaterializationState, NodeSecretResolver, NodeSessionMaterializationProfile,
35    SessionEnvironmentMaterializeError, SessionEnvironmentMaterializer,
36};
37use crate::worktree_service::{
38    exact_created_worktree, exact_owned_worktree, ManagedWorktreeLeaseRecord,
39    ManagedWorktreeProfile, ManagedWorktreeRegistry, ManagedWorktreeSessionHolder,
40};
41use crate::session_registry::{
42    self, validate_display_name, LoadedNodeState, ManagedWorktreeSpawnReplayRecordV10,
43    ManagedWorktreeSpawnReplayStateV10, MAX_MANAGED_SESSION_RECORDS,
44    MAX_MANAGED_WORKTREE_SPAWN_REPLAYS,
45};
46#[cfg(unix)]
47use crate::workspace_file_unix::{
48    create_workspace_directory as create_workspace_directory_on_disk,
49    create_workspace_file as create_workspace_file_on_disk,
50    read_workspace_file as read_workspace_file_from_disk,
51    write_workspace_file as write_workspace_file_to_disk, WorkspaceFileBytes,
52    WorkspaceFileReadError, WorkspaceFileReadErrorKind,
53    WORKSPACE_ENTRY_CREATE_CANCELED, WORKSPACE_ENTRY_CREATE_COMMITTING,
54    WORKSPACE_ENTRY_CREATE_PENDING,
55};
56#[cfg(windows)]
57use crate::workspace_file_windows::{
58    create_workspace_directory as create_workspace_directory_on_disk,
59    create_workspace_file as create_workspace_file_on_disk,
60    read_workspace_file as read_workspace_file_from_disk,
61    write_workspace_file as write_workspace_file_to_disk, WorkspaceFileBytes,
62    WorkspaceFileReadError, WorkspaceFileReadErrorKind,
63    WORKSPACE_ENTRY_CREATE_CANCELED, WORKSPACE_ENTRY_CREATE_COMMITTING,
64    WORKSPACE_ENTRY_CREATE_PENDING,
65};
66use crate::platform;
67use crate::provider_runtime::{
68    require_policy, ProviderRuntimeAdmissionError, ProviderRuntimeMonitor,
69    ProviderRuntimeRequirement,
70};
71use crate::spawn_spec::SpawnProfileRegistry;
72use crate::standalone_workspace::{
73    prepare_standalone_workspace, StandaloneWorkspaceError, StandaloneWorkspaceErrorKind,
74};
75use crate::protocol::{
76    read_json_frame_limited_body_timeout, write_json_frame, write_json_frame_limited,
77    validate_node_negotiated_handshake_capacity, validate_provider_contract_manifest,
78    provider_id_is_legacy, AdapterContractRevision, AgentStreamChunkKindV1, AgentStreamChunkV1,
79    AgentStreamInteractionOptionV1, AgentStreamNamedIdV1,
80    CapabilityId, ClientFrame, ClientRole,
81    ControllerState, DeliveryBlobChunkHexV1, DeliveryBlobDigestV1,
82    DeliveryBundleManifestV2, DeliveryCommitReceiptV1, DeliveryStageId,
83    AgentProgressAttentionKindV1, AgentProgressAttentionV1, AgentProgressCurrentV1,
84    AgentProgressEventKindV1, AgentProgressUsageV1, AgentProgressV1, FrameError,
85    GitCommitDetails, GitDiff, GitDiffMode, GitDiffRequest, GitHistoryPage,
86    GitObjectId, GitCommitSummary, GitSignatureStatus, GitSnapshot, GitStatusEntry,
87    GitWorktreeSnapshot, HarnessMcpActivationDigest, HarnessMcpReservationId,
88    HostDirectoryListing,
89    NodeCompatibilitySupport, NodeEvent,
90    NodeEventEnvelope, NodeFailure, NodeFailureCode, NodeHello, NodeId, NodeIncarnationId,
91    NegotiatedNodeCompatibility, NodeRequest, NodeResponse, NodeSnapshot, OpaqueHostPath,
92    ProtocolRange,
93    ProviderAdapterContractSupport,
94    ProviderContractRevision, ProviderContractSupport, RepositoryPath, RequestEnvelope,
95    ProviderRuntimeStatuses, ResolvedBundleReceipt, ResolvedEnvironmentProfileReceipt,
96    ContextPackLineageReceipt, ContextPackBytesRead, ResolvedContextPackReceipt,
97    ResolvedHarnessMcpProxyReceiptV1, ResolvedSpawnReceipt,
98    ResolvedSpawnSpec, ResponseEnvelope,
99    ServerChallenge, ServerFrame, SessionAddress, SessionAgentProgress, SessionKey, SessionMode,
100    ManagedSessionRecord,
101    ManagedSessionState, ManagedWorktreeCleanupFailure, ManagedWorktreeLeaseId,
102    SessionRecordRetentionConfig,
103    ManagedWorktreeLeaseSnapshot, ManagedWorktreeLeaseState, ManagedWorktreeRetention,
104    ManagedWorktreeSpawnReceipt, ManagedWorktreeSpawnRequest,
105    ManagedWorktreeSpawnRequestV2, SessionRecordId,
106    NativeSessionCatalogRoute, NativeSessionSelection, SessionTaskBindingV1,
107    SessionTaskTargetV1, TaskId,
108    BlockAuthorityV1, HarnessMcpLaunchV1, SessionHistorySummaryV1, MAX_ACP_BLOCKED_HELP_BYTES, MAX_ACP_BLOCKED_REASON_BYTES,
109    StateSchemaSupport, WorkspaceEntry, WorkspaceEntryKind, WorktreeProfileId,
110    SpawnContextId, SpawnEnvironmentProfileId, SpawnIdempotencyKey,
111    SpawnProfileDefaults, SpawnRequiredCapabilities, SpawnSpec, SpawnSpecResolveError,
112    WorkspaceFileContent, WorkspaceFileRevision,
113    WorkspaceFileRead, WorkspaceId, WorkspaceInspection, WorkspaceInspectionTruncationV1,
114    WorkspaceSnapshot,
115    WorktreeServiceMode,
116    LaunchInventory, ManagedWorktreeProfileSummary, SpawnProfileSummary,
117    WorktreeProfileInventory, MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
118    DEFAULT_CONTROLLER_LEASE_MS,
119    MAX_CONTROLLER_LEASE_MS, MIN_CONTROLLER_LEASE_MS, NODE_COMPATIBILITY_METADATA_CAPABILITY,
120    CAPABILITY_HOST_DIRECTORY_BROWSE_V1,
121    NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY, NODE_REPOSITORY_PATH_CAPABILITY,
122    NODE_PROVIDER_ID_OPEN_CAPABILITY,
123    NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
124    NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY, NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
125    NODE_HISTORY_CONTEXT_PACK_CAPABILITY, NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
126    NODE_NATIVE_SESSION_CATALOG_CAPABILITY, NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
127    NODE_NATIVE_SESSION_INDEX_CAPABILITY, NODE_NATIVE_SESSION_PREVIEW_CAPABILITY,
128    NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
129    NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY,
130    NODE_HARNESS_MCP_READ_PROXY_CAPABILITY,
131    NODE_SESSION_TASK_CORRELATION_CAPABILITY,
132    NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY,
133    NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
134    NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
135    NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
136    NODE_SPAWN_PROFILE_REVISION_CAPABILITY, NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
137    NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
138    NODE_AGENT_STREAM_EVENTS_CAPABILITY, NODE_ACP_CONTROL_CAPABILITY,
139    NODE_GIT_READ_CAPABILITY, NODE_WORKSPACE_FILE_READ_CAPABILITY,
140    NODE_WORKSPACE_FILE_WRITE_CAPABILITY, NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY,
141    NODE_WORKTREE_SELECTION_CAPABILITY,
142    BUILD_STAMP, MAX_NODE_CLIENT_FRAME_BYTES, MAX_NODE_HELLO_FRAME_BYTES,
143    MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS, MAX_AGENT_PROGRESS_ENTRY_BYTES,
144    MAX_NODE_TERMINAL_BYTES, MAX_NODE_TEXT_BYTES,
145    MAX_REPOSITORY_PATH_BYTES,
146    MAX_GIT_DIFF_BYTES, MAX_GIT_HISTORY_COMMITS, MAX_WORKSPACE_FILE_BYTES,
147    MAX_HARNESS_MCP_PENDING_CALLS_PER_NODE,
148    MAX_WORKSPACE_ROOT_BYTES, NODE_STATE_SCHEMA_V1, NODE_STATE_SCHEMA_V10,
149    SPAWN_RUNTIME_PROVIDER_SESSION_IDENTITY, SPAWN_RUNTIME_RAW_PTY_LIFECYCLE,
150    SPAWN_RUNTIME_SEMANTIC_READINESS, SPAWN_RUNTIME_SEMANTIC_RESUME,
151    SPAWN_RUNTIME_STRUCTURED_PROMPT,
152};
153#[cfg(test)]
154use crate::protocol::WorktreeProfileRevision;
155use ring::digest::{digest, SHA256};
156use crate::protocol::correlation::{
157    interaction_correlation, sanitize_progress_tool_label, tool_class_label, tool_correlation,
158    truncate_text,
159};
160use gate4agent_catalog::{builtin_registry, AgentRegistry};
161use gate4agent_handle::{EventSubscription, Gate4AgentHandle, PortDispatchError};
162use gate4agent_runtime_native::{
163    NativeMcpServerLaunchOverlay, NativeInstanceLaunchOverlay,
164    NativeLaunchEnvironmentOverlay,
165    NativeHistoryConfig, NativeLaunchProfileControl, NativeRuntime, NativeRuntimeConfig,
166    NativeSessionCatalogAuthority, NativeSessionCatalogError, NativeSessionPreviewError,
167    ScopedNativeSessionCatalogEntry,
168};
169use gate4agent_runtime_native::shell_efficiency::ShellEfficiencyProfile;
170use std::sync::OnceLock;
171use gate4agent_runtime_native::tick_profile::TickProfileSnapshot;
172use drive_loop_profile::DriveLoopProfiler;
173#[cfg(test)]
174use gate4agent_runtime_native::{HistorySourceLayout, NativeHistoryRoot};
175use gate4agent_node_wire::{
176    auth_proof, negotiated_auth_proof, proofs_match, random_incarnation_id, random_nonce,
177    AuthDirection, OwnerOnlyLocalListener,
178};
179use gate4agent_types::{
180    AdapterBinding, AdapterFamily, AgentAdapterCapabilities, AgentCapabilities, AgentId,
181    AgentInstanceId, AgentReadinessSpec, AgentSpec, AgentTransportCapabilities, ApprovalLevel,
182    DetectionSpec, InitialPromptMode, LaunchSpec, ProcessMatcher, PromptSpec, SpecVerification,
183    CommandEnvelope, CommandId,
184    validate_candidate_id, ControlCommand, ControlEvent, ControlEventKind,
185    HistoryCandidateSummary, HistoryOperation, HistoryQuery, HistorySessionRecord,
186    HostDecisionAuthority as ProviderHostDecisionAuthority,
187    HostRequestDecision as ProviderHostRequestDecision,
188    InputAction,
189    PromptFraming, PromptPayload, ResumeLaunchRequest,
190    ProviderActivity, ProviderEvent, ProviderInteractionKind, ProviderInteractionStatus,
191    ProviderRuntimeCapability, ProviderRuntimePolicy, ProviderSessionIdentity, ProviderSessionKey,
192    ProviderSnapshot, ProviderStopReason,
193    ResumeTarget,
194    SessionGeneration, StartRequest,
195    TerminalControl, TerminalText,
196    SessionStatus, TerminalFrame, TransportKind, CONTROL_SESSIONS_MAX,
197    HISTORY_DISCOVERY_LIMIT_MAX, WORKING_DIRECTORY_MAX_BYTES,
198};
199use std::collections::{BTreeMap, VecDeque};
200use std::ffi::OsString;
201use std::io;
202use std::path::{Path, PathBuf};
203use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, AtomicUsize, Ordering};
204use std::sync::{Arc, Mutex, RwLock};
205use std::time::{Duration, Instant};
206use thiserror::Error;
207use tokio::sync::{broadcast, mpsc, Mutex as AsyncMutex, Notify, OwnedSemaphorePermit, Semaphore};
208use tokio::task::{AbortHandle, JoinSet};
209use tokio::time::{sleep, timeout};
210
211mod http_api;
212pub mod backend_paths;
213pub mod node_wake;
214
215/// Model of the spawn in progress (for the m4a auto-nick), if the request named one.
216pub fn spawn_model() -> Option<String> {
217    SPAWN_SESSION_OPTIONS.try_with(|o| o.as_ref().map(|o| o.model.clone())).ok().flatten().filter(|m| !m.is_empty())
218        .or_else(|| SPAWN_NICK_MODEL.try_with(|m| m.clone()).ok().flatten().filter(|m| !m.is_empty()))
219}
220
221tokio::task_local! {
222    /// Model named by the spawn request (any launch path, ACP included), for the m4a auto-nick
223    /// that is registered before the ACP options are applied.
224    static SPAWN_NICK_MODEL: Option<String>;
225    /// Launch options (model / effort) of a `SpawnWithOptions` request, scoped around that
226    /// spawn so the provider's start carries them (g4a maps them to the CLI's own flags).
227    static SPAWN_SESSION_OPTIONS: Option<gate4agent_types::SessionOptionSelection>;
228    /// Provider session id to resume (node-start restore of journalled sessions).
229    static RESUME_PROVIDER_SESSION: Option<String>;
230    /// Origin (`human`/`agent`) and optional parent signature for the node session registry.
231    static SPAWN_ORIGIN: Option<String>;
232    static SPAWN_PARENT: Option<String>;
233}
234
235/// `SpawnSessionOptions` -> g4a selection. Effort needs a model (g4a options hang off a model).
236fn spawn_selection(
237    options: &gate4agent_node_protocol::SpawnSessionOptions,
238) -> Result<Option<gate4agent_types::SessionOptionSelection>, NodeFailure> {
239    options.validate().map_err(|e| failure(NodeFailureCode::InvalidRequest, e))?;
240    match (&options.model, &options.effort) {
241        (None, None) => Ok(None),
242        (None, Some(_)) => Err(failure(NodeFailureCode::InvalidRequest, "spawn effort needs a model")),
243        (Some(model), effort) => {
244            let mut sel = gate4agent_types::SessionOptionSelection::new(model.clone());
245            if let Some(e) = effort {
246                sel = sel.with_value("effort", e.clone());
247            }
248            Ok(Some(sel))
249        }
250    }
251}
252
253mod browser_bridge;
254
255use gate4agent_node_core::drive_loop_profile;
256
257#[path = "bundle_delivery.rs"]
258mod bundle_delivery;
259
260#[path = "context_pack_store.rs"]
261mod context_pack_store;
262
263#[cfg(windows)]
264pub use crate::platform::DEFAULT_NODE_ENDPOINT;
265
266const NODE_EVENT_HISTORY_MAX: usize = 4_096;
267const NODE_BROADCAST_CAPACITY: usize = 1_024;
268const NODE_TERMINAL_BROADCAST_CAPACITY: usize = 1;
269/// Unlike `NODE_TERMINAL_BROADCAST_CAPACITY`'s single replaceable slot (a
270/// terminal frame is a full-screen snapshot; only the latest ever matters),
271/// an `AgentStreamChunkV1` is one delta in an ordered sequence -- collapsing
272/// two pending sends into one would silently drop content a subscriber can
273/// never recover. This needs headroom for a burst instead.
274const NODE_AGENT_STREAM_BROADCAST_CAPACITY: usize = 256;
275/// The node never has more than `MAX_HARNESS_MCP_PENDING_CALLS_PER_NODE`
276/// `NodeEvent::HarnessMcpReadCall`s in flight at once (`begin_call`'s own
277/// admission check), and `publish_transient` sends each exactly once as a
278/// single-envelope batch -- so this many slots covers the entire pending
279/// set without ever lagging the one subscriber that matters, the harness's
280/// own connection, the way sharing the durable `event_tx`'s bus could.
281const NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY: usize = MAX_HARNESS_MCP_PENDING_CALLS_PER_NODE;
282const NODE_CONNECTION_EVENT_BURST_MAX: usize = 16;
283const CONTROL_EVENT_SUBSCRIPTION_CAPACITY: usize = 1_024;
284const MAX_PREAUTH_CONNECTIONS: usize = 32;
285const MAX_AUTHENTICATED_CONNECTIONS: usize = 16;
286const AUTH_FRAME_TIMEOUT_MS: u64 = 5_000;
287const FRAME_BODY_TIMEOUT_MS: u64 = 5_000;
288const CONNECTION_SHUTDOWN_GRACE_MS: u64 = 250;
289const SPAWN_DISPATCH_TIMEOUT_MS: u64 = 2_000;
290/// Fresh Spawn stays provisional until HQ successfully attaches (first Resize /
291/// TerminalBytes). This is an *attach-never-arrived* timeout from spawn time —
292/// not the disconnect reconnect grace (HQ owns that, starting only on a real
293/// client WS close). A provider conversation id is a session; a PTY with no
294/// attached HQ view is still a draft and is reaped here if attach never lands.
295const PROVISIONAL_SPAWN_GRACE_MS: u64 = 90_000;
296/// How often `drive_runtime_until_shutdown`'s loop re-evaluates the session
297/// record retention policy (`NodeServerConfig::session_record_retention`).
298/// A sweep cadence, not a policy value -- the policy itself lives entirely
299/// in the operator-configured age/keep-N flags; this just bounds how stale
300/// the in-memory view of dead `Unavailable` records can get between sweeps
301/// on an otherwise-idle node, mirroring the once-per-startup sweep that
302/// also runs alongside `reconcile_managed_worktrees`.
303const SESSION_RECORD_RETENTION_SWEEP_PERIOD: Duration = Duration::from_secs(5 * 60);
304/// TTL for `NodeShared::rejected_command_reasons` entries -- see that
305/// field's own doc for why a spawn waiter needs this at all. Must outlive
306/// the longest a waiter can still be polling for this exact command's
307/// outcome: `gate4agent_node_protocol::MAX_SPAWN_DEADLINE_MS` (120s) is the
308/// largest `deadline_ms` any caller may request, and the margin on top
309/// absorbs the waiter's own poll cadence plus clock skew between the drive
310/// loop (writer) and the waiter (reader).
311const REJECTED_COMMAND_REASON_TTL_MS: u64 = 150_000;
312/// Bounds `NodeShared::rejected_command_reasons` against an unbounded
313/// backlog of asynchronously-rejected commands nobody ever collects (a
314/// waiter that stops polling, or a burst of always-rejected commands from a
315/// misbehaving caller): once at capacity, the oldest entry is evicted to
316/// make room for the newest, mirroring the eviction rule already used by
317/// `spawn_idempotency`.
318const REJECTED_COMMAND_REASON_MAX_ENTRIES: usize = 256;
319/// How long `wait_until_removed` waits between two `Remove` re-dispatches
320/// for the same session. The presence poll runs far faster (2ms); this
321/// only paces the sending, so an accepted `Remove` still in flight is not
322/// piled on by a dozen more that land on an already-dropped instance.
323const REMOVE_REDISPATCH_INTERVAL_MS: u64 = 100;
324const SPAWN_IDEMPOTENCY_MAX_ENTRIES: usize = 256;
325const SPAWN_IDEMPOTENCY_TTL_MS: u64 = 15 * 60 * 1_000;
326const PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS: u64 = 2_500;
327const MUTATION_SETTLE_TIMEOUT_MS: u64 = 5_000;
328const READINESS_SETTLE_HEADROOM_MS: u64 = 2_000;
329const MANAGED_RESUME_SETTLE_TIMEOUT_MS: u64 = 30_000;
330const WORKSPACE_INSPECTION_MAX_CONCURRENCY: usize = 4;
331const WORKSPACE_FILE_READ_TIMEOUT_MS: u64 = 2_000;
332const HOST_DIRECTORY_BROWSE_TIMEOUT_MS: u64 = 2_000;
333const NATIVE_SESSION_CATALOG_TIMEOUT_MS: u64 = 30_000;
334const WORKSPACE_TREE_MAX_DEPTH: usize = 6;
335const WORKSPACE_TREE_MAX_ENTRIES: usize = 512;
336/// Default total wall-clock budget for `inspect_workspace`'s walk+git
337/// chain, overridable via `GATE4AGENT_NODE_WORKSPACE_INSPECTION_BUDGET_MS`.
338const WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT: u64 = 8_000;
339/// The harness relay enforces its own flat response deadline
340/// (`HOST_RUN_READ_RESPONSE_DEADLINE`, `hatchery-harness-service::runtime`,
341/// 12s) above this node-side budget. The two constants live in different
342/// crates with no shared definition, so clamp any override here comfortably
343/// below that external ceiling — a misconfigured value above it would
344/// reopen the exact deadline race this budget exists to close.
345const WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX: u64 = 11_000;
346/// Default cap on directory entries visited (not just returned) by one
347/// walk, overridable via `GATE4AGENT_NODE_WORKSPACE_INSPECTION_ENTRY_CAP`.
348const WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT: usize = 50_000;
349const WORKSPACE_INSPECTION_ENTRY_CAP_MAX: usize = 1_000_000;
350const GIT_STATUS_MAX_ENTRIES: usize = 128;
351const GIT_COMMIT_MAX_ENTRIES: usize = 12;
352const GIT_OUTPUT_MAX_BYTES: usize = 64 * 1_024;
353const GIT_DIAGNOSTIC_MAX_BYTES: usize = 1_024;
354const WORKSPACE_FILE_WRITE_TIMEOUT_MS: u64 = 5_000;
355const WORKSPACE_ENTRY_CREATE_TIMEOUT_MS: u64 = 5_000;
356const GIT_COMMAND_TIMEOUT_MS: u64 = 1_500;
357const WORKSPACE_UNAVAILABLE_ERROR: &str = "workspace-unavailable";
358const PROVIDER_SESSION_SCOPE_CONFLICT_ERROR: &str = "provider-session-scope-conflict";
359const PROVIDER_SESSION_LIVE_CONFLICT_ERROR: &str = "provider-session-live-conflict";
360const MANAGED_SESSION_CAPACITY_ERROR: &str = "managed-session-capacity";
361const PROVIDER_IDENTITY_ALLOCATION_ERROR: &str =
362    "provider-session-identity-allocation-failed";
363const PROVIDER_RESUME_REJECTED_ERROR: &str = "provider-resume-rejected";
364const DURABLE_STATE_COMMIT_FAILED_ERROR: &str = "durable-state-commit-failed";
365const DURABLE_STATE_LOCK_FAILED_ERROR: &str = "durable-state-lock-failed";
366const DURABLE_STATE_LOAD_FAILED_ERROR: &str = "durable-state-load-failed";
367const DURABLE_STATE_CONFLICT_ERROR: &str = "durable-state-conflict";
368const DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR: &str = "durable-state-schema-unsupported";
369const DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR: &str =
370    "durable-state-path-semantics-unsupported";
371
372fn opaque_windows_path(value: String) -> OpaqueHostPath {
373    OpaqueHostPath::utf8(value).expect("validated Windows node path must remain wire-safe")
374}
375
376fn agent_progress_from_provider_snapshot(
377    address: SessionAddress,
378    provider: &ProviderSnapshot,
379) -> Option<SessionAgentProgress> {
380    let mut truncated = false;
381    let active_tool_count = bounded_progress_count(provider.active_tools.len(), &mut truncated);
382    let mut active_tool_labels = Vec::with_capacity(
383        provider
384            .active_tools
385            .len()
386            .min(MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS),
387    );
388    for tool in &provider.active_tools {
389        if active_tool_labels.len() == MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS {
390            truncated = true;
391            break;
392        }
393        if let Some(label) = sanitize_progress_tool_label(&tool.name, &mut truncated) {
394            active_tool_labels.push(label);
395        }
396    }
397    let attention = provider
398        .interactions
399        .iter()
400        .find(|interaction| {
401            matches!(
402                interaction.status,
403                ProviderInteractionStatus::Pending | ProviderInteractionStatus::Resolving { .. }
404            )
405        })
406        .map(|interaction| AgentProgressAttentionV1 {
407            kind: match interaction.interaction_kind {
408                ProviderInteractionKind::Approval => AgentProgressAttentionKindV1::Approval,
409                ProviderInteractionKind::Question => AgentProgressAttentionKindV1::Question,
410            },
411            tool_label: sanitize_progress_tool_label(&interaction.tool_name, &mut truncated),
412        });
413    let subagent_count = bounded_progress_count(provider.subagents.len(), &mut truncated);
414    let usage = (provider.completed_turns > 0
415        || provider.usage.input_tokens > 0
416        || provider.usage.output_tokens > 0
417        || provider.usage.cache_read_tokens > 0
418        || provider.usage.cache_write_tokens > 0
419        || provider.usage.reasoning_tokens > 0)
420        .then_some(AgentProgressUsageV1 {
421            input_tokens: provider.usage.input_tokens,
422            output_tokens: provider.usage.output_tokens,
423            cache_read_tokens: provider.usage.cache_read_tokens,
424            cache_write_tokens: provider.usage.cache_write_tokens,
425            reasoning_tokens: provider.usage.reasoning_tokens,
426        });
427    let progress = AgentProgressV1 {
428        provider_sequence: provider.sequence,
429        activity: provider.activity,
430        completed_turns: provider.completed_turns,
431        usage,
432        current: AgentProgressCurrentV1::from(provider.activity),
433        active_tool_labels,
434        active_tool_count,
435        attention,
436        subagent_count,
437        last_event_kind: provider.last_event.as_ref().and_then(agent_progress_event_kind),
438        gap_count: provider.gap_count,
439        stale: provider.stale,
440        truncated,
441    };
442    let entry = SessionAgentProgress { address, progress };
443    serde_json::to_vec(&entry)
444        .ok()
445        .filter(|encoded| encoded.len() <= MAX_AGENT_PROGRESS_ENTRY_BYTES)
446        .map(|_| entry)
447}
448
449fn bounded_progress_count(count: usize, truncated: &mut bool) -> u32 {
450    match u32::try_from(count) {
451        Ok(count) => count,
452        Err(_) => {
453            *truncated = true;
454            u32::MAX
455        }
456    }
457}
458
459fn agent_progress_event_kind(event: &ProviderEvent) -> Option<AgentProgressEventKindV1> {
460    if matches!(event, ProviderEvent::ContextWindowUsage { .. }) {
461        return None;
462    }
463    Some(match event {
464        ProviderEvent::SessionStarted { .. } => AgentProgressEventKindV1::SessionStarted,
465        ProviderEvent::SessionIdentityObserved { .. } => {
466            AgentProgressEventKindV1::SessionIdentityObserved
467        }
468        ProviderEvent::TurnStarted { .. } => AgentProgressEventKindV1::TurnStarted,
469        ProviderEvent::WorkingObserved => AgentProgressEventKindV1::WorkingObserved,
470        ProviderEvent::Text { .. } => AgentProgressEventKindV1::Text,
471        ProviderEvent::Thinking { .. } => AgentProgressEventKindV1::Thinking,
472        ProviderEvent::ToolStarted { .. } => AgentProgressEventKindV1::ToolStarted,
473        ProviderEvent::ToolCompleted { .. } => AgentProgressEventKindV1::ToolCompleted,
474        ProviderEvent::TurnCompleted { .. } => AgentProgressEventKindV1::TurnCompleted,
475        ProviderEvent::TurnInterrupted => AgentProgressEventKindV1::TurnInterrupted,
476        ProviderEvent::SessionEnded { .. } => AgentProgressEventKindV1::SessionEnded,
477        ProviderEvent::Error { .. } => AgentProgressEventKindV1::Error,
478        ProviderEvent::Ready => AgentProgressEventKindV1::Ready,
479        ProviderEvent::InteractionRequested { .. } => {
480            AgentProgressEventKindV1::InteractionRequested
481        }
482        ProviderEvent::InteractionResolved { .. } => AgentProgressEventKindV1::InteractionResolved,
483        ProviderEvent::SubagentStarted { .. } => AgentProgressEventKindV1::SubagentStarted,
484        ProviderEvent::SubagentStopped { .. } => AgentProgressEventKindV1::SubagentStopped,
485        ProviderEvent::RateLimited { .. } => AgentProgressEventKindV1::RateLimited,
486        ProviderEvent::HostRequestObserved { .. } => AgentProgressEventKindV1::HostRequestObserved,
487        ProviderEvent::UnrecognizedNotification { .. } => {
488            AgentProgressEventKindV1::UnrecognizedNotification
489        }
490        ProviderEvent::ContextWindowUsage { .. } => unreachable!("handled above"),
491        // ACP session/update coverage beyond text/tool/turn streaming
492        // (`plan`, `available_commands_update`, `current_mode_update`,
493        // `session_info_update`, `usage_update`, `config_option_update`,
494        // `user_message_chunk`). `AgentProgressEventKindV1` is a versioned
495        // wire enum without a `#[serde(other)]` fallback; minting new
496        // variants for it is a deliberate wire-contract decision for
497        // whoever owns this protocol, not a side effect of parsing more of
498        // ACP's own wire -- until that decision is made, these fold into
499        // the same "we saw something, no dedicated kind for it yet" bucket
500        // as `UnrecognizedNotification`.
501        ProviderEvent::UserMessage { .. }
502        | ProviderEvent::Plan { .. }
503        | ProviderEvent::AvailableCommandsUpdated { .. }
504        | ProviderEvent::ModeChanged { .. }
505        | ProviderEvent::SessionInfoUpdated { .. }
506        | ProviderEvent::UsageUpdated { .. }
507        | ProviderEvent::ConfigOptionsUpdated { .. } => {
508            AgentProgressEventKindV1::UnrecognizedNotification
509        }
510    })
511}
512
513/// Maps this harness's own `HostDecisionAuthority` onto the wider
514/// Maps a host request decider onto the `BlockAuthorityV1` a `Blocked` stream
515/// chunk carries -- the four authorities a `Denied` decision is ever made
516/// under, one-for-one. The remaining `BlockAuthorityV1` variants
517/// (`ProviderClassifier`, `ProviderPermissionRule`, `ProviderSandbox`,
518/// `ProviderRefusal`, `UserRejected`, `ProviderQuota`, `Unknown`) describe a
519/// block the PROVIDER side of the wire reported -- this function never
520/// produces one, because `ProviderHostDecisionAuthority` has no vocabulary for
521/// them; the provider-side blocks below mint those directly from typed fields.
522/// Whether a provider source is a live session transport -- the only kind
523/// whose events reach the agent stream. History, resume, session-option and
524/// capability-probe sources are not sessions and stream nothing.
525fn provider_family_is_live(family: AdapterFamily) -> bool {
526    match family {
527        AdapterFamily::PtySemantic
528        | AdapterFamily::Hook
529        | AdapterFamily::ManagedHook
530        | AdapterFamily::Pipe
531        | AdapterFamily::OneShot
532        | AdapterFamily::Acp => true,
533        AdapterFamily::History
534        | AdapterFamily::Resume
535        | AdapterFamily::SessionOptions
536        | AdapterFamily::CapabilityProbe => false,
537    }
538}
539
540fn block_authority(by: ProviderHostDecisionAuthority) -> BlockAuthorityV1 {
541    match by {
542        ProviderHostDecisionAuthority::Gate => BlockAuthorityV1::HostGate,
543        ProviderHostDecisionAuthority::Policy => BlockAuthorityV1::HostPolicy,
544        ProviderHostDecisionAuthority::Operator => BlockAuthorityV1::Operator,
545        ProviderHostDecisionAuthority::DeadlinePolicy => BlockAuthorityV1::HostDeadline,
546    }
547}
548
549/// The `Blocked::reason` for a `Denied` decision this build's own
550/// `AgentEvent::RpcIncomingRequest::reason` did not attach text to --
551/// reachable today for `Operator`/`HostDeadline`: a deferred `session/
552/// request_permission` request resolved by `AcpSession::
553/// resolve_pending_request`/`expire_deadlines` (`src/acp/session.rs`),
554/// neither of which computes free text (an operator answers approve/reject,
555/// never a comment; an unattended deadline asks nobody anything). `reason`
556/// is a REQUIRED field on the wire (see `AgentStreamChunkKindV1::Blocked`)
557/// -- a categorical fallback naming the authority is the
558/// honest minimum, never a placeholder standing in for detail nobody
559/// reported.
560fn action_blocked_fallback_reason(authority: BlockAuthorityV1) -> &'static str {
561    match authority {
562        BlockAuthorityV1::HostGate => "denied by the dangerous-command gate",
563        BlockAuthorityV1::HostPolicy => "denied by host policy",
564        BlockAuthorityV1::HostDeadline => {
565            "denied by host policy after the operator deadline expired"
566        }
567        BlockAuthorityV1::Operator => "declined by the operator",
568        BlockAuthorityV1::ProviderClassifier => "denied by the provider's classifier",
569        BlockAuthorityV1::ProviderPermissionRule => "denied by a provider permission rule",
570        BlockAuthorityV1::ProviderSandbox => "denied by the provider's sandbox",
571        BlockAuthorityV1::ProviderRefusal => "refused by the provider",
572        BlockAuthorityV1::UserRejected => "rejected by the user",
573        BlockAuthorityV1::ProviderQuota => "the provider's quota or rate limit was exhausted",
574        BlockAuthorityV1::Unknown => "blocked; no reason was reported",
575    }
576}
577
578/// Computes `AgentStreamChunkKindV1::Blocked`'s `tool_class`/`authority`/`reason`
579/// for a `HostRequestObserved` `Denied` decision.
580fn host_request_denied_block(
581    method: &str,
582    by: ProviderHostDecisionAuthority,
583    reason: Option<&str>,
584) -> (String, BlockAuthorityV1, String) {
585    let authority = block_authority(by);
586    let reason_source = reason.unwrap_or_else(|| action_blocked_fallback_reason(authority));
587    // `AgentStreamChunkKindV1::validate` REJECTS an over-long reason outright
588    // rather than truncating -- this cut happens here, at the producer, before
589    // the validator ever sees the text.
590    let (reason_text, _) =
591        truncate_text(reason_source, MAX_ACP_BLOCKED_REASON_BYTES);
592    (tool_class_label(method), authority, reason_text)
593}
594
595// ---------------------------------------------------------------------------
596// K2 -- provider-side blocks read off ACP `tool_call_update._meta.
597// nonExecutionKind` (`ProviderEvent::ToolCompleted::non_execution_kind`) and
598// `SessionEnded::stop_reason` (`stopReason: "refusal"`, or a `session/
599// prompt` RPC error whose vendor code names a quota/limit/rate
600// exhaustion). See
601// `docs/gate4agent/plans/gate4agent-blocked-action-event-2026-09-02.md`
602// §3 slice K2.
603// ---------------------------------------------------------------------------
604
605/// Classifies ACP's `nonExecutionKind` into the typed authority a block
606/// this build mints under, or `None` when the tool truly did not run for a
607/// reason that is NOT a block -- `"interrupted"`/`"cancelled"` mean the
608/// TURN stopped (session closed, the operator cancelled), not that a
609/// policy/classifier/rule refused the call, so those (and any unrecognized
610/// future value) stay a plain `ToolCompleted { success: false }` only, per
611/// this plan's own rule that a tool failure is never inflated into a block
612/// this build cannot actually attribute.
613fn provider_tool_block_authority(kind: Option<&str>) -> Option<(BlockAuthorityV1, &'static str)> {
614    match kind? {
615        "user-rejected" => Some((BlockAuthorityV1::UserRejected, "user-rejected")),
616        "permission-rule" => Some((BlockAuthorityV1::ProviderPermissionRule, "permission-rule")),
617        _ => None,
618    }
619}
620
621/// Splits a provider's own denial sentence into `reason`/`help`, when it
622/// matches the fixed shape Claude Code's classifier/permission-rule denial
623/// uses -- verified live, the owner's sample fixture
624/// (`docs/gate4agent/research/gate4agent-blocked-action-signals-2026-09-02.md`
625/// appendix): "...denied by <authority>. Reason: <reason>. <help...>".
626/// `authority` is NEVER derived from this text (see this module's own rule
627/// that authority comes only from a typed field) -- only `reason`/`help`
628/// are. Falls back to the full text as `reason` with no `help` when the
629/// shape does not match (a provider whose text does not carry this exact
630/// wording) -- the honest minimum: the tool truly did not run, so SOME
631/// reason is always reported, never fabricated detail beyond what arrived.
632fn split_provider_denial_text(text: &str) -> (String, Option<String>) {
633    let trimmed = text.trim();
634    let Some((_, after_reason)) = trimmed.split_once("Reason:") else {
635        return (trimmed.to_owned(), None);
636    };
637    let tail = after_reason.trim_start();
638    let Some((reason, help)) = tail.split_once(". ") else {
639        // "Reason:" present but no ". " to split help off -- the whole
640        // tail is the reason, e.g. "Reason: Blocked by classifier." with
641        // no help text following on some providers/versions.
642        let reason = tail.strip_suffix('.').unwrap_or(tail).trim();
643        return (reason.to_owned(), None);
644    };
645    let help = help.trim();
646    if help.is_empty() {
647        (reason.trim().to_owned(), None)
648    } else {
649        (reason.trim().to_owned(), Some(help.to_owned()))
650    }
651}
652
653/// `tool_class` for a provider-side `ActionBlocked` this build has no typed
654/// tool name for -- `ProviderEvent::ToolCompleted` carries only `id`, never
655/// `name` (see `ProviderEvent::ToolStarted`, which does, but a tool's own
656/// name is never threaded through to its completion). Reads the class off
657/// the guidance tail's own "add a <Class> permission rule" sentence when
658/// present (the shape the owner's sample fixture uses) -- text matching
659/// fills `tool_class` here, same as `reason`/`help`, NEVER `authority` (see
660/// this module's own rule). Falls back to the same generic `"Tool"` class
661/// `ToolCompleted`'s own observation already uses when nothing names one.
662fn tool_class_from_denial_help(help: Option<&str>) -> String {
663    help.and_then(|text| text.split("add a ").nth(1))
664        .and_then(|tail| tail.split(' ').next())
665        .filter(|word| !word.is_empty())
666        .map(str::to_owned)
667        .unwrap_or_else(|| "Tool".to_owned())
668}
669
670/// Whether a `session/prompt` RPC error's vendor code (or, absent one, its
671/// message) names a quota/rate-limit exhaustion -- Codex's
672/// `data.codexErrorInfo: "usageLimitExceeded"` (measured live 2026-09-05)
673/// contains "limit", the word this build actually keys on; "quota"/"rate"
674/// are kept for whichever OTHER vendor's own code names one of those
675/// instead, never confirmed live, but the honest generalization of the
676/// one confirmed sample.
677fn provider_error_is_quota(vendor_code: Option<&str>, message: &str) -> bool {
678    fn names_quota(s: &str) -> bool {
679        let lower = s.to_ascii_lowercase();
680        lower.contains("limit") || lower.contains("quota") || lower.contains("rate")
681    }
682    vendor_code.is_some_and(names_quota) || names_quota(message)
683}
684
685/// The `Blocked` chunk this build mints when a turn stopped via a
686/// typed provider fact rather than a tool call -- `stopReason: "refusal"`
687/// (`authority: ProviderRefusal`) or a `session/prompt` RPC error whose
688/// vendor code (or message) names a quota/limit/rate exhaustion
689/// (`authority: ProviderQuota`, `reason` = the error message verbatim,
690/// `reason_kind` = the vendor code). `None` for every other stop --
691/// `end_turn`, `max_tokens`, `max_turn_requests`, `cancelled`, an untagged
692/// `Other`, or an RPC error whose vendor code/message names none of the
693/// three words: `SessionEnded` stays a plain `Exited`/turn-end fact for
694/// those, never inflated into a block this build cannot actually
695/// attribute. `ProviderRefusal`'s `reason` has no source text to read on
696/// `SessionEnded` itself (ACP's `refusal` stopReason carries none) -- the
697/// honest minimum names the fact that happened, never invented detail; a
698/// later slice that threads the turn's last agent text chunk through can
699/// replace it without changing this function's contract.
700fn provider_session_end_block(
701    stop_reason: &ProviderStopReason,
702) -> Option<(BlockAuthorityV1, String, String)> {
703    match stop_reason {
704        ProviderStopReason::Refusal => Some((
705            BlockAuthorityV1::ProviderRefusal,
706            "refusal".to_owned(),
707            "the provider ended the turn with stopReason: refusal".to_owned(),
708        )),
709        ProviderStopReason::ProviderError { message, vendor_code, .. }
710            if provider_error_is_quota(vendor_code.as_deref(), message) =>
711        {
712            let reason_kind = vendor_code.clone().unwrap_or_else(|| "provider-error".to_owned());
713            Some((BlockAuthorityV1::ProviderQuota, reason_kind, message.clone()))
714        }
715        _ => None,
716    }
717}
718
719/// Builds the `AgentStreamChunkV1` `event`'s `ProviderEvent` carries onto the
720/// `agent-stream-events-v1` channel -- see
721/// `docs/gate4agent/plans/gate4agent-acp-control-plane-on-the-wire-2026-09-02.md`
722/// §5. Two invariants keep the chunk correlatable with the `NodeEvent::Control`
723/// event it derives from, so a client joining the two sees one id and one
724/// ordering:
725///
726/// - `source_sequence` on the chunk is the `source_sequence` the provider
727///   event itself carries.
728/// - `InteractionPrompt::correlation_id` is minted by
729///   `correlation::interaction_correlation` with the
730///   `(instance_id, generation, provider_sequence)` triple -- never the
731///   provider's own `request_id`, whose opacity on the wire is pinned by a
732///   dedicated test.
733///
734/// Returns `None` for every `ControlEventKind` other than `ProviderEvent`
735/// (nothing else carries stream content), for a source that is not a live
736/// session transport, and for the `ProviderEvent` variants below with no
737/// declared chunk kind to carry them, or whose declared chunk kind this build
738/// has no source for yet:
739///
740/// - `ModelCatalog { current, available }` has no `ProviderEvent` source at
741///   all -- grok's vendor model switch is not bridged into `ProviderEvent`
742///   yet, the same gap the plan names explicitly.
743/// - `UserMessage { text, is_delta }` is an echo of the OPERATOR's own
744///   prompt, replayed on session resume -- `Text`/`Thinking` are documented
745///   as "what the agent is saying", so mapping the operator's words onto
746///   `Text` would misrepresent whose content it is. No dedicated
747///   user-message kind is declared, so this stays unproduced rather than
748///   mislabeled.
749///
750/// `HostRequestObserved { .. }` is its own arm below: a `Granted`/`Deferred`
751/// decision returns `None` (nothing for the operator to react to), a `Denied`
752/// decision returns `Some(Blocked { .. })`.
753///
754/// `ToolCompleted { .. }`/`SessionEnded { .. }` are likewise their own arms
755/// below (K2): each returns `None` unless
756/// `provider_tool_block_authority`/`provider_session_end_block` classifies the
757/// event as an actual block (ACP's `_meta.nonExecutionKind`, or `stopReason`/an
758/// RPC error naming a quota).
759fn agent_stream_chunk(event: &ControlEvent) -> Option<AgentStreamChunkV1> {
760    let ControlEventKind::ProviderEvent {
761        sequence: provider_sequence,
762        source,
763        source_sequence,
764        event: provider_event,
765    } = &event.event
766    else {
767        return None;
768    };
769    if !provider_family_is_live(source.family) {
770        return None;
771    }
772    let kind = match provider_event {
773        ProviderEvent::Text { text, is_delta } => AgentStreamChunkKindV1::Text {
774            text: text.clone(),
775            is_delta: *is_delta,
776        },
777        ProviderEvent::Thinking { text } => {
778            AgentStreamChunkKindV1::Thinking { text: text.clone() }
779        }
780        ProviderEvent::InteractionRequested {
781            interaction_kind,
782            tool_name,
783            title,
784            prompt,
785            options,
786            ..
787        } => AgentStreamChunkKindV1::InteractionPrompt {
788            correlation_id: interaction_correlation(
789                event.instance_id,
790                event.generation,
791                *provider_sequence,
792            ),
793            interaction_kind: *interaction_kind,
794            tool_name: tool_name.clone(),
795            // `title`/`options` are carried straight through from
796            // `ProviderEvent::InteractionRequested`, as the agent itself
797            // gave them on `session/request_permission`'s `PermissionTool
798            // Call.title`/`options` (see `gate4agent-shell-native`'s
799            // `provider_event`, the only ACP-transport producer). A PTY- or
800            // hook-sourced interaction carries `title: None` and
801            // `options: []` from its own producer because that source has
802            // none to offer, never because this mapping dropped one.
803            title: title.clone(),
804            prompt: prompt.clone(),
805            options: options
806                .iter()
807                .map(|option| AgentStreamInteractionOptionV1 {
808                    option_id: option.option_id.clone(),
809                    name: option.name.clone(),
810                    kind: option.kind.clone(),
811                })
812                .collect(),
813        },
814        ProviderEvent::ConfigOptionsUpdated { options } => {
815            AgentStreamChunkKindV1::ConfigOptions { options: options.clone() }
816        }
817        // `available` is the mode catalogue `AcpSession::available_modes()`
818        // read back at `session/new` and threaded through by
819        // `gate4agent-shell-native`'s `provider_event` (see that crate's
820        // `ProviderEvent::ModeChanged` arm) -- an empty list here is the
821        // agent's own honest "no modes announced", never "not read": ACP
822        // orders `session/new` before any `session/update`, and the
823        // catalogue is fixed at handshake, so a `ModeChanged` event cannot
824        // exist before the same session object's catalogue is already
825        // populated (see `gate4agent_types::ProviderEvent::ModeChanged`'s
826        // own doc comment for the ordering argument in full).
827        ProviderEvent::ModeChanged { mode_id, available } => AgentStreamChunkKindV1::ModeCatalog {
828            current: Some(mode_id.clone()),
829            available: available
830                .iter()
831                .map(|mode| AgentStreamNamedIdV1 {
832                    id: mode.id.clone(),
833                    name: mode.name.clone(),
834                    description: mode.description.clone(),
835                })
836                .collect(),
837        },
838        // A denied host request is a block -- see this function's own doc
839        // comment for why `HostRequestObserved` is not in the exclusion list
840        // below.
841        ProviderEvent::HostRequestObserved { method, decision, reason, .. } => {
842            let ProviderHostRequestDecision::Denied { by } = decision else {
843                return None;
844            };
845            let (tool_class, authority, reason_text) =
846                host_request_denied_block(method, *by, reason.as_deref());
847            AgentStreamChunkKindV1::Blocked {
848                correlation_id: None,
849                tool_class,
850                authority,
851                reason_kind: None,
852                reason: reason_text,
853                help: None,
854            }
855        }
856        // Same relationship as `HostRequestObserved` above, driven by ACP's
857        // own `_meta.nonExecutionKind` instead of the host's
858        // `HostRequestDecision`.
859        ProviderEvent::ToolCompleted { id, output, non_execution_kind, .. } => {
860            let (authority, reason_kind) =
861                provider_tool_block_authority(non_execution_kind.as_deref())?;
862            let (reason, help) = split_provider_denial_text(output);
863            let (reason, _) =
864                truncate_text(&reason, MAX_ACP_BLOCKED_REASON_BYTES);
865            let help = help.map(|help| {
866                truncate_text(&help, MAX_ACP_BLOCKED_HELP_BYTES).0
867            });
868            AgentStreamChunkKindV1::Blocked {
869                correlation_id: Some(tool_correlation(event, source, id)),
870                tool_class: tool_class_from_denial_help(help.as_deref()),
871                authority,
872                reason_kind: Some(reason_kind.to_owned()),
873                reason,
874                help,
875            }
876        }
877        // Same relationship, driven by `stopReason: "refusal"` or a
878        // `session/prompt` RPC error naming a quota/limit/rate exhaustion
879        // instead of a tool-level fact.
880        ProviderEvent::SessionEnded { stop_reason, .. } => {
881            let (authority, reason_kind, reason) =
882                stop_reason.as_ref().and_then(provider_session_end_block)?;
883            let (reason, _) =
884                truncate_text(&reason, MAX_ACP_BLOCKED_REASON_BYTES);
885            AgentStreamChunkKindV1::Blocked {
886                correlation_id: None,
887                tool_class: "Session".to_owned(),
888                authority,
889                reason_kind: Some(reason_kind),
890                reason,
891                help: None,
892            }
893        }
894        ProviderEvent::SessionStarted { .. }
895        | ProviderEvent::SessionIdentityObserved { .. }
896        | ProviderEvent::TurnStarted { .. }
897        | ProviderEvent::WorkingObserved
898        | ProviderEvent::ToolStarted { .. }
899        | ProviderEvent::TurnCompleted { .. }
900        | ProviderEvent::ContextWindowUsage { .. }
901        | ProviderEvent::TurnInterrupted
902        | ProviderEvent::Error { .. }
903        | ProviderEvent::Ready
904        | ProviderEvent::InteractionResolved { .. }
905        | ProviderEvent::SubagentStarted { .. }
906        | ProviderEvent::SubagentStopped { .. }
907        | ProviderEvent::RateLimited { .. }
908        | ProviderEvent::UnrecognizedNotification { .. }
909        | ProviderEvent::UserMessage { .. }
910        | ProviderEvent::Plan { .. }
911        | ProviderEvent::AvailableCommandsUpdated { .. }
912        | ProviderEvent::SessionInfoUpdated { .. }
913        | ProviderEvent::UsageUpdated { .. } => return None,
914    };
915    Some(AgentStreamChunkV1 { source_sequence: *source_sequence, kind })
916}
917
918async fn run_native_session_catalog_operation<T, E, F>(
919    catalog: Arc<Mutex<NativeSessionCatalogAuthority>>,
920    operation: &'static str,
921    execute: F,
922) -> Result<T, NodeFailure>
923where
924    T: Send + 'static,
925    E: std::fmt::Display + Send + 'static,
926    F: FnOnce(&mut NativeSessionCatalogAuthority) -> Result<T, E> + Send + 'static,
927{
928    let task = tokio::task::spawn_blocking(move || {
929        let mut catalog = match catalog.try_lock() {
930            Ok(catalog) => catalog,
931            Err(std::sync::TryLockError::Poisoned(poisoned)) => poisoned.into_inner(),
932            Err(std::sync::TryLockError::WouldBlock) => {
933                return Err(failure(
934                    NodeFailureCode::BackendBusy,
935                    "native session catalog operation is already running",
936                ));
937            }
938        };
939        execute(&mut catalog).map_err(|error| {
940            failure(
941                NodeFailureCode::BackendOperationFailed,
942                &format!("{operation} failed: {error}"),
943            )
944        })
945    });
946    timeout(Duration::from_millis(NATIVE_SESSION_CATALOG_TIMEOUT_MS), task)
947        .await
948        .map_err(|_| {
949            failure(
950                NodeFailureCode::BackendBusy,
951                &format!("{operation} exceeded its bounded deadline"),
952            )
953        })?
954        .map_err(|_| {
955            failure(
956                NodeFailureCode::BackendOperationFailed,
957                &format!("{operation} task failed"),
958            )
959        })?
960}
961
962fn windows_path_text(path: &OpaqueHostPath) -> &str {
963    path.as_utf8()
964        .expect("Windows node only creates UTF-8 opaque host paths")
965}
966
967fn require_windows_path(path: OpaqueHostPath) -> Result<String, NodeFailure> {
968    path.as_utf8()
969        .map(str::to_owned)
970        .ok_or_else(|| failure(
971            NodeFailureCode::InvalidRequest,
972            "this node requires UTF-8 host paths",
973        ))
974}
975
976fn protocol_worktree(mut worktree: NativeGitWorktreeSnapshot) -> GitWorktreeSnapshot {
977    GitWorktreeSnapshot {
978        path: opaque_windows_path(worktree.path),
979        head: worktree.head,
980        branch: worktree.branch,
981        is_bare: worktree.is_bare,
982        is_main: worktree.is_main,
983        locked: worktree.locked,
984        lock_reason: worktree.lock_reason,
985        prunable: worktree.prunable,
986        prunable_reason: worktree.prunable_reason,
987        workspace_id: worktree.workspace_id.take(),
988    }
989}
990
991#[derive(Clone, Debug, Eq, PartialEq)]
992pub struct WorkspaceConfig {
993    workspace_id: WorkspaceId,
994    canonical_root: String,
995    worktree_service_mode: WorktreeServiceMode,
996    managed_worktree_profiles: BTreeMap<WorktreeProfileId, ManagedWorktreeProfile>,
997}
998
999impl WorkspaceConfig {
1000    pub fn new(
1001        workspace_id: WorkspaceId,
1002        root: impl AsRef<Path>,
1003    ) -> Result<Self, NodeServerError> {
1004        let root = root.as_ref();
1005        if !root.is_absolute() {
1006            return Err(NodeServerError::InvalidWorkspaceRoot {
1007                workspace_id,
1008                path: root.to_string_lossy().into_owned(),
1009                message: "path is not absolute".to_owned(),
1010            });
1011        }
1012        let canonical = std::fs::canonicalize(root).map_err(|error| {
1013            NodeServerError::InvalidWorkspaceRoot {
1014                workspace_id: workspace_id.clone(),
1015                path: root.to_string_lossy().into_owned(),
1016                message: error.to_string(),
1017            }
1018        })?;
1019        if !canonical.is_dir() {
1020            return Err(NodeServerError::InvalidWorkspaceRoot {
1021                workspace_id,
1022                path: root.to_string_lossy().into_owned(),
1023                message: "path is not a directory".to_owned(),
1024            });
1025        }
1026        let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
1027            NodeServerError::InvalidWorkspaceRoot {
1028                workspace_id: workspace_id.clone(),
1029                path: path.to_string_lossy().into_owned(),
1030                message: "canonical path is not valid Unicode".to_owned(),
1031            }
1032        })?;
1033        let canonical_root = platform::normalize_canonical_root(canonical_root);
1034        validate_workspace_root(&workspace_id, &canonical_root)?;
1035        Ok(Self {
1036            workspace_id,
1037            canonical_root,
1038            worktree_service_mode: WorktreeServiceMode::Manual,
1039            managed_worktree_profiles: BTreeMap::new(),
1040        })
1041    }
1042
1043    pub fn workspace_id(&self) -> &WorkspaceId {
1044        &self.workspace_id
1045    }
1046
1047    pub fn canonical_root(&self) -> &str {
1048        &self.canonical_root
1049    }
1050
1051    pub fn with_worktree_service_mode(mut self, mode: WorktreeServiceMode) -> Self {
1052        self.worktree_service_mode = mode;
1053        self
1054    }
1055
1056    pub fn with_managed_worktree_profile(
1057        mut self,
1058        profile: ManagedWorktreeProfile,
1059    ) -> Result<Self, NodeServerError> {
1060        profile.validate_for_workspace(&self.canonical_root).map_err(|message| {
1061            NodeServerError::InvalidManagedWorktreeProfile {
1062                workspace_id: self.workspace_id.clone(),
1063                profile_id: profile.profile_id().clone(),
1064                message,
1065            }
1066        })?;
1067        if self.managed_worktree_profiles.contains_key(profile.profile_id()) {
1068            return Err(NodeServerError::DuplicateManagedWorktreeProfile {
1069                workspace_id: self.workspace_id,
1070                profile_id: profile.profile_id().clone(),
1071            });
1072        }
1073        if self.managed_worktree_profiles.len()
1074            == MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE
1075        {
1076            return Err(NodeServerError::ManagedWorktreeProfileCapacity {
1077                workspace_id: self.workspace_id,
1078                max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
1079            });
1080        }
1081        self.managed_worktree_profiles
1082            .insert(profile.profile_id().clone(), profile);
1083        Ok(self)
1084    }
1085}
1086
1087#[derive(Clone)]
1088pub struct NodeServerConfig {
1089    pub endpoint: String,
1090    api_listen: Option<std::net::SocketAddr>,
1091    /// Opt-in node-envelope HTTP+WS browser bridge (separate from `--api-listen`
1092    /// ops HTTP and from framed C2). Loopback only; default `None` = off.
1093    bridge_listen: Option<std::net::SocketAddr>,
1094    /// Optional bridge shared secret (`GATE4AGENT_BRIDGE_TOKEN`). Distinct from
1095    /// NODE_TOKEN; never logged. `None` = loopback bind is the only gate.
1096    bridge_token: Option<String>,
1097    /// Tip 6: optional mesh underlay UDP bind that relays to local `--bridge-listen`.
1098    /// Requires bridge_listen + bridge_token. HQ never accepts (NodePeer).
1099    bridge_underlay: Option<browser_bridge::BridgeUnderlayAccept>,
1100    /// A relay to CALL, rather than wait to be called by. `None` keeps the
1101    /// node purely a listener, which is what it has always been.
1102    call_home: Option<std::net::SocketAddr>,
1103    /// When set, call-home authenticates by signing the relay's challenge with this
1104    /// identity (enrolled node) and falls back to the token handshake against a relay
1105    /// that sends no challenge.
1106    call_home_identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
1107    pub node_id: NodeId,
1108    pub workspaces: Vec<WorkspaceConfig>,
1109    access_token: String,
1110    pub runtime: NativeRuntimeConfig,
1111    session_record_retention: SessionRecordRetentionConfig,
1112    state_path: Option<PathBuf>,
1113    spawn_profiles: SpawnProfileRegistry,
1114    session_environment: Option<NodeSessionEnvironmentConfig>,
1115    history: Option<NativeHistoryConfig>,
1116    harness_mcp_helper: Option<ReviewedHarnessMcpProgram>,
1117    /// Station network allowlist catalog (opaque ids). Empty default.
1118    /// Loaded at startup from `--network-allowlist-catalog` / env
1119    /// `GATE4AGENT_NODE_NETWORK_ALLOWLIST_CATALOG` file. Never secrets.
1120    network_allowlist_catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog,
1121    #[cfg(feature = "fixture")]
1122    fixture_raw_pty_runtime: bool,
1123}
1124
1125#[derive(Clone)]
1126struct NodeSessionEnvironmentConfig {
1127    root: PathBuf,
1128    resolver: Arc<dyn NodeSecretResolver>,
1129}
1130
1131impl NodeServerConfig {
1132    pub fn new(
1133        endpoint: impl Into<String>,
1134        access_token: impl Into<String>,
1135        node_id: NodeId,
1136        workspaces: impl IntoIterator<Item = WorkspaceConfig>,
1137    ) -> Result<Self, NodeServerError> {
1138        let endpoint = endpoint.into();
1139        let access_token = access_token.into();
1140        if !platform::validate_endpoint(&endpoint) {
1141            return Err(NodeServerError::InvalidEndpoint);
1142        }
1143        if access_token.is_empty() || access_token.len() > 4_096 {
1144            return Err(NodeServerError::InvalidAccessToken);
1145        }
1146        let workspaces = workspaces.into_iter().collect::<Vec<_>>();
1147        if workspaces.is_empty() {
1148            return Err(NodeServerError::NoWorkspaces);
1149        }
1150        let mut workspace_ids = BTreeMap::new();
1151        let mut workspace_roots = BTreeMap::new();
1152        for workspace in &workspaces {
1153            if workspace.worktree_service_mode == WorktreeServiceMode::Managed
1154                && workspace.managed_worktree_profiles.is_empty()
1155            {
1156                return Err(NodeServerError::ManagedWorktreeProfileRequired(
1157                    workspace.workspace_id.clone(),
1158                ));
1159            }
1160            if workspace.worktree_service_mode != WorktreeServiceMode::Managed
1161                && !workspace.managed_worktree_profiles.is_empty()
1162            {
1163                return Err(NodeServerError::ManagedWorktreeProfileModeMismatch(
1164                    workspace.workspace_id.clone(),
1165                ));
1166            }
1167            if workspace_ids
1168                .insert(workspace.workspace_id.clone(), ())
1169                .is_some()
1170            {
1171                return Err(NodeServerError::DuplicateWorkspaceId(
1172                    workspace.workspace_id.clone(),
1173                ));
1174            }
1175            let root_key = platform::root_identity(&workspace.canonical_root);
1176            if let Some(existing) = workspace_roots.insert(
1177                root_key,
1178                workspace.workspace_id.clone(),
1179            ) {
1180                return Err(NodeServerError::DuplicateWorkspaceRoot {
1181                    first: existing,
1182                    second: workspace.workspace_id.clone(),
1183                    root: workspace.canonical_root.clone(),
1184                });
1185            }
1186        }
1187        Ok(Self {
1188            endpoint,
1189            api_listen: None,
1190            bridge_listen: None,
1191            bridge_token: None,
1192            bridge_underlay: None,
1193            call_home: None,
1194            call_home_identity: None,
1195            node_id,
1196            workspaces,
1197            access_token,
1198            // PTY output is polled by the runtime's effect worker; the upstream
1199            // default (20ms) added ~10-20ms to every keystroke echo.
1200            runtime: NativeRuntimeConfig {
1201                worker_poll_interval_ms: 4,
1202                ..NativeRuntimeConfig::default()
1203            },
1204            session_record_retention: SessionRecordRetentionConfig::default(),
1205            state_path: None,
1206            spawn_profiles: SpawnProfileRegistry::default(),
1207            session_environment: None,
1208            history: None,
1209            harness_mcp_helper: None,
1210            network_allowlist_catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog::new(),
1211            #[cfg(feature = "fixture")]
1212            fixture_raw_pty_runtime: false,
1213        })
1214    }
1215
1216    pub fn with_api_listen(
1217        mut self,
1218        api_listen: std::net::SocketAddr,
1219    ) -> Result<Self, NodeServerError> {
1220        if !api_listen.ip().is_loopback() {
1221            return Err(NodeServerError::InvalidApiListen(api_listen));
1222        }
1223        self.api_listen = Some(api_listen);
1224        Ok(self)
1225    }
1226
1227    /// Opt-in node-envelope HTTP+WS bridge listener. Loopback only (same spirit
1228    /// as `with_api_listen`). Does not enable by default — libraries and the
1229    /// binary leave this `None` until `--bridge-listen` / `with_bridge_listen`.
1230    pub fn with_bridge_listen(
1231        mut self,
1232        bridge_listen: std::net::SocketAddr,
1233    ) -> Result<Self, NodeServerError> {
1234        if !bridge_listen.ip().is_loopback() {
1235            return Err(NodeServerError::InvalidBridgeListen(bridge_listen));
1236        }
1237        self.bridge_listen = Some(bridge_listen);
1238        Ok(self)
1239    }
1240
1241    /// Optional bridge shared secret, distinct from the C2/node access token.
1242    /// Never log the value. Empty / oversize refused.
1243    pub fn with_bridge_token(
1244        mut self,
1245        bridge_token: impl Into<String>,
1246    ) -> Result<Self, NodeServerError> {
1247        let bridge_token = bridge_token.into();
1248        if bridge_token.is_empty() || bridge_token.len() > 4_096 {
1249            return Err(NodeServerError::InvalidBridgeToken);
1250        }
1251        self.bridge_token = Some(bridge_token);
1252        Ok(self)
1253    }
1254
1255    /// Tip 6: point bridge reachability over mesh underlay (UDP+AEAD relay to
1256    /// local loopback `--bridge-listen`). Requires bridge listen + bridge token
1257    /// already configured. Transport key is 32 raw bytes (never logged).
1258    /// Underlay auth token is distinct from BRIDGE_TOKEN (crypto ≠ auth).
1259    pub fn with_bridge_underlay(
1260        mut self,
1261        bind: std::net::SocketAddr,
1262        transport_key: [u8; 32],
1263        underlay_token: impl Into<String>,
1264    ) -> Result<Self, NodeServerError> {
1265        if self.bridge_listen.is_none() {
1266            return Err(NodeServerError::BridgeUnderlayRequiresBridgeListen);
1267        }
1268        if self.bridge_token.is_none() {
1269            return Err(NodeServerError::BridgeUnderlayRequiresBridgeToken);
1270        }
1271        let underlay_token = underlay_token.into();
1272        if underlay_token.is_empty() || underlay_token.len() > 4_096 {
1273            return Err(NodeServerError::InvalidBridgeUnderlayToken);
1274        }
1275        // Port 0 is allowed (ephemeral). Role lock (no HQ accept) lives in underlay.
1276        self.bridge_underlay = Some(browser_bridge::BridgeUnderlayAccept {
1277            bind,
1278            transport_key,
1279            underlay_token,
1280        });
1281        Ok(self)
1282    }
1283
1284    /// Also dial `relay` and serve this wire over the connection, in
1285    /// addition to accepting on the local endpoint.
1286    ///
1287    /// Who opens the socket and who is the protocol's server are separate
1288    /// questions, and this is the node's half of separating them. The node
1289    /// stays the server either way -- it answers `NodeRequest`s and emits
1290    /// `NodeEvent`s exactly as it does for a relay that dialled it. All
1291    /// that changes is that a node with no address anyone can reach makes
1292    /// the connection itself, and names itself once it arrives (see
1293    /// `gate4agent_node_wire::write_call_home_announce`).
1294    ///
1295    /// Loopback only, and that is not an oversight to be flagged and
1296    /// worked around. This wire carries terminal contents, keystrokes and
1297    /// file bytes as plaintext JSON; its mutual challenge-response
1298    /// authenticates both ends and encrypts nothing. Every listener in
1299    /// this stack is loopback-guarded for that reason, and a dialler must
1300    /// hold the same line -- a node calling a public relay would put the
1301    /// whole control plane on the wire in the clear. Reaching a relay on
1302    /// another host is a transport question, and it stays unanswered here
1303    /// on purpose: this change is about direction, not distance.
1304    pub fn with_call_home(
1305        mut self,
1306        relay: std::net::SocketAddr,
1307    ) -> Result<Self, NodeServerError> {
1308        // Loopback, or the concrete C2 WireGuard tunnel address. Unspecified
1309        // and port 0 refused — plaintext wire must not dial every interface.
1310        if relay.port() == 0 || relay.ip().is_unspecified() {
1311            return Err(NodeServerError::InvalidCallHome(relay));
1312        }
1313        self.call_home = Some(relay);
1314        Ok(self)
1315    }
1316
1317    /// Authenticate call-home with a node-held identity instead of a standing token.
1318    pub fn with_call_home_identity(mut self, identity: Arc<hatchery_enroll::NodeIdentity>) -> Self {
1319        self.call_home_identity = Some(identity);
1320        self
1321    }
1322
1323    pub fn with_state_path(
1324        mut self,
1325        state_path: impl Into<PathBuf>,
1326    ) -> Result<Self, NodeServerError> {
1327        let state_path = state_path.into();
1328        if !state_path.is_absolute() || state_path.file_name().is_none() {
1329            return Err(NodeServerError::InvalidStatePath(
1330                state_path.to_string_lossy().into_owned(),
1331            ));
1332        }
1333        self.state_path = Some(state_path);
1334        Ok(self)
1335    }
1336
1337    pub fn with_spawn_profiles(mut self, spawn_profiles: SpawnProfileRegistry) -> Self {
1338        self.spawn_profiles = spawn_profiles;
1339        self
1340    }
1341
1342    /// Sets the age/keep-N policy the drive loop's retention sweep uses to
1343    /// retire dead `ManagedSessionState::Unavailable` records. Both fields
1344    /// default to `0` (disabled) via `SessionRecordRetentionConfig::default`
1345    /// when this is never called -- a node never deletes a durable record
1346    /// until an operator opts in with a real value.
1347    pub fn with_session_record_retention(
1348        mut self,
1349        retention: SessionRecordRetentionConfig,
1350    ) -> Self {
1351        self.session_record_retention = retention;
1352        self
1353    }
1354
1355    pub fn with_session_environment_materialization(
1356        mut self,
1357        root: impl Into<PathBuf>,
1358        resolver: Arc<dyn NodeSecretResolver>,
1359    ) -> Result<Self, NodeServerError> {
1360        let root = root.into();
1361        if !root.is_absolute() {
1362            return Err(NodeServerError::InvalidSessionEnvironmentRoot);
1363        }
1364        self.session_environment = Some(NodeSessionEnvironmentConfig { root, resolver });
1365        Ok(self)
1366    }
1367
1368    /// Enables bounded provider-history discovery and loading from explicit
1369    /// host-owned roots. Ambient vendor homes are never inferred here.
1370    pub fn with_history(mut self, history: NativeHistoryConfig) -> Self {
1371        self.history = Some(history);
1372        self
1373    }
1374
1375    /// Enables the H3B read proxy with one exact operator-reviewed helper file.
1376    /// Linux/macOS use owner-only UDS; Windows uses owner-only named pipes.
1377    /// Both platforms review the helper the same way (absolute regular file +
1378    /// identity hash) — the old `cfg(not(windows))` hard-refuse was a leftover
1379    /// gate after Unix listener/peer-cred support landed.
1380    pub fn with_harness_mcp_helper(
1381        mut self,
1382        helper_program: impl Into<PathBuf>,
1383    ) -> Result<Self, NodeServerError> {
1384        self.harness_mcp_helper = Some(
1385            ReviewedHarnessMcpProgram::review(helper_program.into())
1386                .map_err(|_| NodeServerError::InvalidHarnessMcpHelper)?,
1387        );
1388        Ok(self)
1389    }
1390
1391    /// Install an in-memory station network allowlist catalog (opaque ids +
1392    /// optional node-local permits / provider-native mapping). Empty remains
1393    /// the default — unknown spawn ids refuse at resolve. Never cookies /
1394    /// OAuth / proxy credentials. C2 / inventory still list ids only.
1395    pub fn with_network_allowlist_catalog(
1396        mut self,
1397        catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog,
1398    ) -> Result<Self, NodeServerError> {
1399        if catalog.len() > crate::network_allowlist_catalog::MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES {
1400            return Err(NodeServerError::NetworkAllowlistCatalogCapacity {
1401                max: crate::network_allowlist_catalog::MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES,
1402            });
1403        }
1404        self.network_allowlist_catalog = catalog;
1405        Ok(self)
1406    }
1407
1408    /// Load station network allowlist catalog from an absolute regular file
1409    /// (v1 id-list or v2 JSON schema). Unset path is not used here — callers
1410    /// that want env fallback use `resolve_network_allowlist_catalog`.
1411    pub fn with_network_allowlist_catalog_file(
1412        self,
1413        path: impl AsRef<std::path::Path>,
1414    ) -> Result<Self, NodeServerError> {
1415        let catalog = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(path)
1416            .map_err(NodeServerError::from)?;
1417        self.with_network_allowlist_catalog(catalog)
1418    }
1419}
1420
1421pub fn default_state_path(node_id: &NodeId) -> Result<PathBuf, NodeServerError> {
1422    platform::default_state_path(node_id.as_str())
1423        .ok_or(NodeServerError::LocalStateDirectoryUnavailable)
1424}
1425
1426pub fn default_node_endpoint() -> Result<PathBuf, NodeServerError> {
1427    platform::default_node_endpoint().ok_or(NodeServerError::LocalRuntimeDirectoryUnavailable)
1428}
1429
1430fn delivery_store_root_for_state_path(state_path: &Path) -> Option<PathBuf> {
1431    let parent = state_path.parent()?;
1432    let mut name = state_path.file_name()?.to_os_string();
1433    name.push(".delivery-store");
1434    Some(parent.join(name))
1435}
1436
1437fn context_pack_store_root_for_state_path(state_path: &Path) -> Option<PathBuf> {
1438    let parent = state_path.parent()?;
1439    let mut name = state_path.file_name()?.to_os_string();
1440    name.push(".context-pack-store");
1441    Some(parent.join(name))
1442}
1443
1444pub struct NodeServer {
1445    config: NodeServerConfig,
1446    runtime: NativeRuntime,
1447    shared: Arc<NodeShared>,
1448    events: EventSubscription,
1449    state_path_lock: Option<session_registry::StatePathLock>,
1450}
1451
1452#[cfg(feature = "fixture")]
1453#[derive(Clone, Default)]
1454pub struct SpawnManagedWorktreeV2FailureProbe {
1455    latest_code: Arc<Mutex<Option<NodeFailureCode>>>,
1456}
1457
1458#[cfg(feature = "fixture")]
1459impl SpawnManagedWorktreeV2FailureProbe {
1460    pub fn latest_code(&self) -> Option<NodeFailureCode> {
1461        *self
1462            .latest_code
1463            .lock()
1464            .unwrap_or_else(|poisoned| poisoned.into_inner())
1465    }
1466
1467    fn record(&self, code: Option<NodeFailureCode>) {
1468        *self
1469            .latest_code
1470            .lock()
1471            .unwrap_or_else(|poisoned| poisoned.into_inner()) = code;
1472    }
1473}
1474
1475#[cfg(all(feature = "fixture", windows))]
1476struct IsolatedCodexHomeFixtureEnvironment;
1477
1478#[cfg(all(feature = "fixture", windows))]
1479impl gate4agent_runtime_native::NativeChildEnvironmentResolver
1480    for IsolatedCodexHomeFixtureEnvironment
1481{
1482    fn resolve_child_environment(
1483        &self,
1484    ) -> Result<
1485        Vec<gate4agent_catalog::EnvMutation>,
1486        gate4agent_runtime_native::NativeChildEnvironmentResolveError,
1487    > {
1488        Ok(vec![gate4agent_catalog::EnvMutation {
1489            key: OsString::from("USERPROFILE"),
1490            value: None,
1491        }])
1492    }
1493}
1494
1495impl NodeServer {
1496    /// The letter-wake handle for the door, when m4a accounts are on. `token` is what callers
1497    /// must present.
1498    /// Turn on the m4a signer proxy for PTY spawns (replaces `M4A_SESSION_CAP`).
1499    #[cfg(unix)]
1500    pub fn set_signer_proxy(&self, proxy: Arc<crate::keystore_proxy::SignerProxy>) -> bool {
1501        self.shared.signer_proxy.set(proxy).is_ok()
1502    }
1503
1504    pub fn wake_handle(&self, token: String, policy: Arc<crate::wake_pipeline::WakePolicy>) -> Option<node_wake::NodeWake> {
1505        self.shared.m4a_accounts.as_ref()?;
1506        Some(node_wake::NodeWake::new(Arc::clone(&self.shared), token, policy))
1507    }
1508
1509    pub fn new(config: NodeServerConfig) -> Result<Self, NodeServerError> {
1510        let catalog = active_registry()?;
1511        Self::new_with_registry(config, catalog)
1512    }
1513
1514    #[cfg(feature = "fixture")]
1515    pub fn new_fixture(config: NodeServerConfig) -> Result<Self, NodeServerError> {
1516        let mut spec = gate4agent_testkit::interactive_agent_spec();
1517        spec.id = AgentId::new("claude").map_err(|error| NodeServerError::Registry(error.to_string()))?;
1518        spec.display_name = "Controlled Claude PTY fixture".to_owned();
1519        Self::new_fixture_with_spec(config, spec)
1520    }
1521
1522    #[cfg(feature = "fixture")]
1523    pub fn new_clean_exit_fixture(
1524        config: NodeServerConfig,
1525        fixture_root: PathBuf,
1526        started_marker: PathBuf,
1527        release_signal: PathBuf,
1528    ) -> Result<Self, NodeServerError> {
1529        let mut spec = gate4agent_testkit::controlled_clean_exit_agent_spec(
1530            &fixture_root,
1531            &started_marker,
1532            &release_signal,
1533        )
1534        .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1535        spec.id = AgentId::new("claude")
1536            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1537        spec.display_name = "Controlled Claude clean-exit fixture".to_owned();
1538        Self::new_fixture_with_spec(config, spec)
1539    }
1540
1541    /// A `claude` source whose session establishes a verified provider
1542    /// identity via an early `SessionStart` Hook post (so it is eligible for
1543    /// auto-export-at-exit and its native history is independently
1544    /// loadable), waits for `release_signal`, then exits cleanly — paired
1545    /// on the same Node with a plain `codex` target that never validates
1546    /// anything about what it receives. Built for A2 durable ContextPack
1547    /// E2E coverage: the test itself inspects materialized files and
1548    /// managed-session-record fields directly, rather than having the
1549    /// target script self-validate.
1550    #[cfg(all(feature = "fixture", windows))]
1551    pub fn new_durable_context_pack_clean_exit_fixture(
1552        config: NodeServerConfig,
1553        fixture_root: PathBuf,
1554        started_marker: PathBuf,
1555        release_signal: PathBuf,
1556        session_id: &str,
1557    ) -> Result<Self, NodeServerError> {
1558        let mut source_spec = gate4agent_testkit::identified_clean_exit_agent_spec(
1559            &fixture_root,
1560            &started_marker,
1561            &release_signal,
1562            session_id,
1563        )
1564        .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1565        let source_id = AgentId::new("claude")
1566            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1567        let source_provider = builtin_registry().get(&source_id).ok_or_else(|| {
1568            NodeServerError::Registry(
1569                "durable context pack clean-exit fixture source adapter is unavailable"
1570                    .to_owned(),
1571            )
1572        })?;
1573        source_spec.capabilities.adapters.history =
1574            source_provider.capabilities.adapters.history.clone();
1575        source_spec.id = source_id;
1576        source_spec.display_name =
1577            "Controlled Claude durable context-pack clean-exit fixture".to_owned();
1578        // Both specs launch via `powershell.exe`; the registry's ambiguity
1579        // check keys on `detection.command` (+ aliases), not `launch.program`
1580        // -- give each a distinct detection identity, same precedent as
1581        // `context_pack_fixture_catalog`'s per-provider
1582        // `gate4agent-{provider}-context-fixture.cmd` commands.
1583        source_spec.detection.command =
1584            "gate4agent-claude-durable-context-pack-fixture.cmd".to_owned();
1585        source_spec.detection.aliases.clear();
1586
1587        let mut target_spec = gate4agent_testkit::interactive_agent_spec();
1588        target_spec.id = AgentId::new("codex")
1589            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1590        target_spec.display_name =
1591            "Controlled Codex durable context-pack target fixture".to_owned();
1592        target_spec.detection.command =
1593            "gate4agent-codex-durable-context-pack-fixture.cmd".to_owned();
1594        target_spec.detection.aliases.clear();
1595
1596        let catalog = AgentRegistry::new([source_spec, target_spec])
1597            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1598        let mut server = Self::new_with_registry(config, catalog)?;
1599        Arc::get_mut(&mut server.shared)
1600            .expect("fixture Node shared state must remain uniquely owned before run")
1601            .fixture_semantic_hook_policy = true;
1602        Ok(server)
1603    }
1604
1605    #[cfg(feature = "fixture")]
1606    pub fn new_monitoring_hook_fixture(
1607        config: NodeServerConfig,
1608    ) -> Result<Self, NodeServerError> {
1609        let mut spec = gate4agent_testkit::monitoring_hook_agent_spec();
1610        spec.id = AgentId::new("claude")
1611            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1612        let mut server = Self::new_fixture_with_spec(config, spec)?;
1613        Arc::get_mut(&mut server.shared)
1614            .expect("fixture Node shared state must remain uniquely owned before run")
1615            .fixture_semantic_hook_policy = true;
1616        Ok(server)
1617    }
1618
1619    #[cfg(all(feature = "fixture", windows))]
1620    pub fn new_provider_bundle_argv_fixture(
1621        config: NodeServerConfig,
1622        agent_id: AgentId,
1623        proof_path: PathBuf,
1624    ) -> Result<Self, NodeServerError> {
1625        let spec = Self::provider_bundle_argv_fixture_spec(agent_id, proof_path, None)?;
1626        Self::new_fixture_with_spec(config, spec)
1627    }
1628
1629    #[cfg(all(feature = "fixture", windows))]
1630    pub fn new_provider_bundle_argv_hold_fixture(
1631        config: NodeServerConfig,
1632        agent_id: AgentId,
1633        proof_path: PathBuf,
1634        release_signal: PathBuf,
1635    ) -> Result<Self, NodeServerError> {
1636        let spec = Self::provider_bundle_argv_fixture_spec(
1637            agent_id,
1638            proof_path,
1639            Some(release_signal),
1640        )?;
1641        Self::new_fixture_with_spec(config, spec)
1642    }
1643
1644    #[cfg(all(feature = "fixture", windows))]
1645    fn provider_bundle_argv_fixture_spec(
1646        agent_id: AgentId,
1647        proof_path: PathBuf,
1648        release_signal: Option<PathBuf>,
1649    ) -> Result<gate4agent_types::AgentSpec, NodeServerError> {
1650        if !matches!(agent_id.as_str(), "claude" | "codex" | "kimi") {
1651            return Err(NodeServerError::Registry(
1652                "provider bundle argv fixture requires Claude, Codex, or Kimi".to_owned(),
1653            ));
1654        }
1655        let mut spec = gate4agent_testkit::interactive_agent_spec();
1656        spec.id = agent_id.clone();
1657        spec.display_name = format!("Controlled {agent_id} bundle argv fixture");
1658        let provider = builtin_registry()
1659            .get(&agent_id)
1660            .ok_or_else(|| {
1661                NodeServerError::Registry(
1662                    "provider bundle argv fixture adapter is unavailable".to_owned(),
1663                )
1664            })?;
1665        spec.capabilities.adapters.history = provider.capabilities.adapters.history.clone();
1666        if !proof_path.is_absolute()
1667            || !proof_path
1668                .parent()
1669                .is_some_and(|parent| parent.is_dir())
1670        {
1671            return Err(NodeServerError::Registry(
1672                "provider bundle argv proof path requires an existing absolute parent"
1673                    .to_owned(),
1674            ));
1675        }
1676        if let Some(release_signal) = release_signal.as_ref() {
1677            if !release_signal.is_absolute()
1678                || !release_signal
1679                    .parent()
1680                    .is_some_and(|parent| parent.is_dir())
1681            {
1682                return Err(NodeServerError::Registry(
1683                    "provider bundle argv release path requires an existing absolute parent"
1684                        .to_owned(),
1685                ));
1686            }
1687            if release_signal.exists() {
1688                return Err(NodeServerError::Registry(
1689                    "provider bundle argv release path must not exist".to_owned(),
1690                ));
1691            }
1692            let proof_parent = std::fs::canonicalize(
1693                proof_path
1694                    .parent()
1695                    .expect("validated provider bundle proof parent"),
1696            )
1697            .map_err(|_| {
1698                NodeServerError::Registry(
1699                    "provider bundle argv proof parent is unavailable".to_owned(),
1700                )
1701            })?;
1702            let release_parent = std::fs::canonicalize(
1703                release_signal
1704                    .parent()
1705                    .expect("validated provider bundle release parent"),
1706            )
1707            .map_err(|_| {
1708                NodeServerError::Registry(
1709                    "provider bundle argv release parent is unavailable".to_owned(),
1710                )
1711            })?;
1712            if proof_parent != release_parent {
1713                return Err(NodeServerError::Registry(
1714                    "provider bundle argv release path must share the proof parent".to_owned(),
1715                ));
1716            }
1717        }
1718        let proof_path = proof_path.into_os_string().into_string().map_err(|_| {
1719            NodeServerError::Registry(
1720                "provider bundle argv proof path is not valid Unicode".to_owned(),
1721            )
1722        })?;
1723        let release_signal = release_signal.map(|release_signal| {
1724            release_signal.into_os_string().into_string().map_err(|_| {
1725                NodeServerError::Registry(
1726                    "provider bundle argv release path is not valid Unicode".to_owned(),
1727                )
1728            })
1729        }).transpose()?;
1730        let script = spec
1731            .launch
1732            .fixed_args
1733            .pop()
1734            .ok_or_else(|| NodeServerError::Registry("PTY fixture script is unavailable".to_owned()))?;
1735        let bundle_validation = if agent_id.as_str() == "codex" {
1736            "if ($bundleArgs.Count -ne 0) { exit 91 }; $codexHome = [Environment]::GetEnvironmentVariable('CODEX_HOME', 'Process'); $cwd = (Get-Location).ProviderPath; if ([string]::IsNullOrEmpty($codexHome)) { [IO.File]::WriteAllLines($proofPath, [string[]]@('unbundled', $cwd)); [Console]::Out.WriteLine('F63_CODEX_UNBUNDLED_VALIDATED') } elseif (-not [IO.Path]::IsPathRooted($codexHome) -or -not (Test-Path -LiteralPath $codexHome -PathType Container)) { exit 92 } else { $skillPath = Join-Path $codexHome 'skills/review-code/SKILL.md'; if (-not (Test-Path -LiteralPath $skillPath -PathType Leaf)) { exit 93 }; $files = @(Get-ChildItem -LiteralPath $codexHome -Recurse -Force -File); if ($files.Count -ne 1 -or [IO.Path]::GetFullPath($files[0].FullName) -ine [IO.Path]::GetFullPath($skillPath)) { exit 94 }; $sha = [Security.Cryptography.SHA256]::Create(); try { $skillHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($skillPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }; if ($skillHash -cne 'd78fe03be106b673fcf5415c8359e99f0298b5071cd11822c58ebcb27e52a68d') { exit 95 }; [IO.File]::WriteAllLines($proofPath, [string[]]@('bundled', $codexHome, $cwd, $skillHash)); [Console]::Out.WriteLine('F63_CODEX_BUNDLE_HOME_VALIDATED') }"
1737        } else if agent_id.as_str() == "claude" {
1738            "if ($bundleArgs.Count -eq 0) { [IO.File]::WriteAllLines($proofPath, [string[]]@()); [Console]::Out.WriteLine('F62_NO_BUNDLE_ARGV') } elseif ($bundleArgs.Count -eq 2 -and $bundleArgs[0] -ceq '--plugin-dir' -and [IO.Path]::IsPathRooted($bundleArgs[1]) -and (Test-Path -LiteralPath $bundleArgs[1] -PathType Container)) { if (-not (Test-Path -LiteralPath (Join-Path $bundleArgs[1] '.claude-plugin/plugin.json') -PathType Leaf)) { exit 92 }; [IO.File]::WriteAllLines($proofPath, [string[]]@($bundleArgs[0], $bundleArgs[1])); [Console]::Out.WriteLine('F62_BUNDLE_ARGV_VALIDATED') } else { exit 91 }"
1739        } else {
1740            "if ($bundleArgs.Count -eq 0) { [IO.File]::WriteAllLines($proofPath, [string[]]@()); [Console]::Out.WriteLine('F62_NO_BUNDLE_ARGV') } elseif ($bundleArgs.Count -eq 2 -and $bundleArgs[0] -ceq '--skills-dir' -and [IO.Path]::IsPathRooted($bundleArgs[1]) -and (Test-Path -LiteralPath $bundleArgs[1] -PathType Container)) { if ((Split-Path -Leaf $bundleArgs[1]) -ne 'skills') { exit 93 }; [IO.File]::WriteAllLines($proofPath, [string[]]@($bundleArgs[0], $bundleArgs[1])); [Console]::Out.WriteLine('F62_BUNDLE_ARGV_VALIDATED') } else { exit 91 }"
1741        };
1742        if let Some(release_signal) = release_signal {
1743            spec.launch.fixed_args.push(format!(
1744                "& {{ param([string]$proofPath, [string]$releaseSignal, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {bundle_validation}; $deadline = [DateTime]::UtcNow.AddSeconds(45); while (-not (Test-Path -LiteralPath $releaseSignal -PathType Leaf)) {{ if ([DateTime]::UtcNow -ge $deadline) {{ exit 96 }}; Start-Sleep -Milliseconds 20 }}; {script} }}",
1745            ));
1746            spec.launch.fixed_args.push(proof_path);
1747            spec.launch.fixed_args.push(release_signal);
1748        } else {
1749            spec.launch.fixed_args.push(format!(
1750                "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {bundle_validation}; {script} }}",
1751            ));
1752            spec.launch.fixed_args.push(proof_path);
1753        }
1754        Ok(spec)
1755    }
1756
1757    #[cfg(all(feature = "fixture", windows))]
1758    pub fn new_context_pack_fixture(
1759        config: NodeServerConfig,
1760        proof_path: PathBuf,
1761    ) -> Result<Self, NodeServerError> {
1762        let catalog = Self::context_pack_fixture_catalog(proof_path, None, false)?;
1763        Self::new_with_registry(config, catalog)
1764    }
1765
1766    #[cfg(all(feature = "fixture", windows))]
1767    pub fn new_monitoring_context_pack_fixture(
1768        config: NodeServerConfig,
1769        proof_path: PathBuf,
1770    ) -> Result<Self, NodeServerError> {
1771        let catalog = Self::context_pack_fixture_catalog(proof_path, None, true)?;
1772        let mut server = Self::new_with_registry(config, catalog)?;
1773        Arc::get_mut(&mut server.shared)
1774            .expect("fixture Node shared state must remain uniquely owned before run")
1775            .fixture_semantic_hook_policy = true;
1776        Ok(server)
1777    }
1778
1779    #[cfg(all(feature = "fixture", windows))]
1780    pub fn install_isolated_codex_home_environment_fixture(
1781        &mut self,
1782        profile_id: SpawnEnvironmentProfileId,
1783        profile_revision: crate::protocol::SpawnEnvironmentProfileRevision,
1784    ) -> Result<(), NodeServerError> {
1785        let provider = AgentId::new("codex")
1786            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1787        let native_profile = gate4agent_runtime_native::NativeLaunchProfile::new(
1788            gate4agent_runtime_native::NativeLaunchProfileId::new(format!(
1789                "{}-pty",
1790                profile_id.as_str(),
1791            ))
1792            .map_err(|error| NodeServerError::Registry(error.to_string()))?,
1793            provider.clone(),
1794            TransportKind::Pty,
1795            vec![OsString::from("USERPROFILE")],
1796            Arc::new(IsolatedCodexHomeFixtureEnvironment),
1797        )
1798        .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1799        let materialization = NodeSessionMaterializationProfile::new(
1800            Vec::new(),
1801            vec![crate::session_environment::NodeSessionPathBinding::new(
1802                "CODEX_HOME",
1803                crate::session_environment::NodeSessionPathClass::ProviderHome,
1804            )
1805            .map_err(|error| NodeServerError::Registry(error.to_string()))?],
1806            Vec::new(),
1807        )
1808        .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1809        self.install_environment_profile(
1810            NodeEnvironmentProfile::new_with_materialization(
1811                profile_id,
1812                profile_revision,
1813                provider,
1814                [native_profile],
1815                Some(materialization),
1816            )
1817            .map_err(|error| NodeServerError::Registry(error.to_string()))?,
1818        )
1819    }
1820
1821    #[cfg(all(feature = "fixture", windows))]
1822    pub fn new_context_only_proof_fixture(
1823        config: NodeServerConfig,
1824        target_provider: AgentId,
1825        proof_path: PathBuf,
1826    ) -> Result<Self, NodeServerError> {
1827        if target_provider.as_str() != "kimi" {
1828            return Err(NodeServerError::Registry(
1829                "context-only proof fixture requires Kimi".to_owned(),
1830            ));
1831        }
1832        let catalog = Self::context_pack_fixture_catalog(
1833            proof_path,
1834            Some(&target_provider),
1835            false,
1836        )?;
1837        Self::new_with_registry(config, catalog)
1838    }
1839
1840    #[cfg(all(feature = "fixture", windows))]
1841    fn context_pack_fixture_catalog(
1842        proof_path: PathBuf,
1843        context_only_target: Option<&AgentId>,
1844        monitoring_claude_source: bool,
1845    ) -> Result<AgentRegistry, NodeServerError> {
1846        if !proof_path.is_absolute()
1847            || !proof_path
1848                .parent()
1849                .is_some_and(|parent| parent.is_dir())
1850        {
1851            return Err(NodeServerError::Registry(
1852                "context pack proof path requires an existing absolute parent".to_owned(),
1853            ));
1854        }
1855        let proof_path = proof_path.into_os_string().into_string().map_err(|_| {
1856            NodeServerError::Registry(
1857                "context pack proof path is not valid Unicode".to_owned(),
1858            )
1859        })?;
1860        let providers = ["claude", "codex", "grok", "kimi"];
1861        let builtins = builtin_registry();
1862        let mut specs = Vec::with_capacity(providers.len());
1863        for provider_id in providers {
1864            let agent_id = AgentId::new(provider_id)
1865                .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1866            let provider = builtins.get(&agent_id).ok_or_else(|| {
1867                NodeServerError::Registry(
1868                    "context pack fixture provider adapter is unavailable".to_owned(),
1869                )
1870            })?;
1871            let monitored_provider =
1872                monitoring_claude_source && matches!(provider_id, "claude" | "codex");
1873            let mut spec = if monitored_provider {
1874                gate4agent_testkit::monitoring_hook_agent_spec()
1875            } else {
1876                gate4agent_testkit::interactive_agent_spec()
1877            };
1878            spec.id = agent_id.clone();
1879            spec.display_name = format!("Controlled {agent_id} context pack fixture");
1880            spec.detection.command = format!("gate4agent-{provider_id}-context-fixture.cmd");
1881            spec.detection.aliases.clear();
1882            spec.capabilities.adapters.history = provider.capabilities.adapters.history.clone();
1883            if provider_id == "codex" {
1884                let script = spec.launch.fixed_args.pop().ok_or_else(|| {
1885                    NodeServerError::Registry("PTY fixture script is unavailable".to_owned())
1886                })?;
1887                let validation = r#"
1888$bundleArgs = @($bundleArgs)
1889if ($bundleArgs.Count -ne 0) { exit 91 }
1890$codexHome = [Environment]::GetEnvironmentVariable('CODEX_HOME', 'Process')
1891$contextRoot = [Environment]::GetEnvironmentVariable('GATE4AGENT_CONTEXT_ROOT', 'Process')
1892$cwd = (Get-Location).ProviderPath
1893if ([string]::IsNullOrEmpty($codexHome) -or -not [IO.Path]::IsPathRooted($codexHome) -or -not (Test-Path -LiteralPath $codexHome -PathType Container)) { exit 92 }
1894$skillPath = Join-Path $codexHome 'skills/review-code/SKILL.md'
1895if (-not (Test-Path -LiteralPath $skillPath -PathType Leaf)) { exit 93 }
1896$homeFiles = @(Get-ChildItem -LiteralPath $codexHome -Recurse -Force -File)
1897if ($homeFiles.Count -ne 1 -or [IO.Path]::GetFullPath($homeFiles[0].FullName) -ine [IO.Path]::GetFullPath($skillPath)) { exit 94 }
1898$sha = [Security.Cryptography.SHA256]::Create()
1899try { $skillHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($skillPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1900if ($skillHash -cne 'd78fe03be106b673fcf5415c8359e99f0298b5071cd11822c58ebcb27e52a68d') { exit 95 }
1901if ([string]::IsNullOrEmpty($contextRoot) -or -not [IO.Path]::IsPathRooted($contextRoot) -or -not (Test-Path -LiteralPath $contextRoot -PathType Container)) { exit 96 }
1902$contextPath = Join-Path $contextRoot 'context-pack.json'
1903if (-not (Test-Path -LiteralPath $contextPath -PathType Leaf)) { exit 97 }
1904$contextFiles = @(Get-ChildItem -LiteralPath $contextRoot -Recurse -Force -File)
1905if ($contextFiles.Count -ne 1 -or [IO.Path]::GetFullPath($contextFiles[0].FullName) -ine [IO.Path]::GetFullPath($contextPath)) { exit 98 }
1906$contextFull = [IO.Path]::GetFullPath($contextRoot).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1907$codexFull = [IO.Path]::GetFullPath($codexHome).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1908$cwdFull = [IO.Path]::GetFullPath($cwd).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1909$separator = [IO.Path]::DirectorySeparatorChar
1910if ($contextFull.Equals($codexFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($codexFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 99 }
1911if ($contextFull.Equals($cwdFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($cwdFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 100 }
1912$rawContext = [IO.File]::ReadAllText($contextPath)
1913if ($rawContext.Contains('g4a-private-provider-session-canary') -or $rawContext.Contains('private-provider-login-identity')) { exit 101 }
1914try { $document = $rawContext | ConvertFrom-Json -ErrorAction Stop } catch { exit 102 }
1915$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')
1916$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')
1917foreach ($property in @($document.PSObject.Properties.Name)) {
1918    if ($allowedTopLevel -cnotcontains [string]$property) { exit 103 }
1919}
1920foreach ($property in $requiredTopLevel) {
1921    if ($null -eq $document.PSObject.Properties[$property]) { exit 104 }
1922}
1923if ($document.schema -cne 'g4a-context-pack-v1' -or $null -ne $document.PSObject.Properties['cwd']) { exit 105 }
1924if (@('claude', 'codex', 'grok', 'kimi') -cnotcontains [string]$document.source_provider) { exit 106 }
1925$messages = @($document.retained_messages)
1926if ($messages.Count -eq 0) { exit 107 }
1927foreach ($message in $messages) {
1928    $messageProperties = @($message.PSObject.Properties.Name)
1929    foreach ($property in $messageProperties) {
1930        if (@('role', 'text') -cnotcontains [string]$property) { exit 108 }
1931    }
1932    if ($messageProperties.Count -ne 2 -or @('user', 'assistant') -cnotcontains [string]$message.role -or [string]::IsNullOrEmpty([string]$message.text)) { exit 109 }
1933}
1934$sha = [Security.Cryptography.SHA256]::Create()
1935try { $contextHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($contextPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1936[IO.File]::WriteAllLines($proofPath, [string[]]@('contextual', $codexHome, $cwd, $skillHash, $contextRoot, $contextHash, [string]$document.schema, [string]$document.source_provider, [string]$messages.Count, [string]$messages[0].text, [string]$messages[$messages.Count - 1].text))
1937$rawProof = [IO.File]::ReadAllText($proofPath)
1938if ($rawProof.Contains('g4a-private-provider-session-canary') -or $rawProof.Contains('private-provider-login-identity')) { exit 110 }
1939[Console]::Out.WriteLine('F7_CODEX_BUNDLE_CONTEXT_VALIDATED')
1940"#;
1941                spec.launch.fixed_args.push(format!(
1942                    "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {validation}; {script} }}",
1943                ));
1944                spec.launch.fixed_args.push(proof_path.clone());
1945            } else if context_only_target.map(AgentId::as_str) == Some(provider_id) {
1946                let script = spec.launch.fixed_args.pop().ok_or_else(|| {
1947                    NodeServerError::Registry("PTY fixture script is unavailable".to_owned())
1948                })?;
1949                let validation = r#"
1950$bundleArgs = @($bundleArgs)
1951if ($bundleArgs.Count -ne 0) { exit 91 }
1952$contextRoot = [Environment]::GetEnvironmentVariable('GATE4AGENT_CONTEXT_ROOT', 'Process')
1953$cwd = (Get-Location).ProviderPath
1954if ([string]::IsNullOrEmpty($contextRoot) -or -not [IO.Path]::IsPathRooted($contextRoot) -or -not (Test-Path -LiteralPath $contextRoot -PathType Container)) { exit 92 }
1955$contextPath = Join-Path $contextRoot 'context-pack.json'
1956if (-not (Test-Path -LiteralPath $contextPath -PathType Leaf)) { exit 93 }
1957$entries = @(Get-ChildItem -LiteralPath $contextRoot -Force)
1958if ($entries.Count -ne 1 -or $entries[0] -isnot [IO.FileInfo] -or [IO.Path]::GetFullPath($entries[0].FullName) -ine [IO.Path]::GetFullPath($contextPath)) { exit 94 }
1959$contextFull = [IO.Path]::GetFullPath($contextRoot).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1960$cwdFull = [IO.Path]::GetFullPath($cwd).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1961$separator = [IO.Path]::DirectorySeparatorChar
1962if ($contextFull.Equals($cwdFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($cwdFull + $separator, [StringComparison]::OrdinalIgnoreCase) -or $cwdFull.StartsWith($contextFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 95 }
1963try { $document = [IO.File]::ReadAllText($contextPath) | ConvertFrom-Json -ErrorAction Stop } catch { exit 96 }
1964if ($document.schema -cne 'g4a-context-pack-v1' -or $null -ne $document.PSObject.Properties['cwd']) { exit 97 }
1965if (@('claude', 'codex', 'grok', 'kimi') -cnotcontains [string]$document.source_provider) { exit 98 }
1966$messages = @($document.retained_messages)
1967if ($messages.Count -eq 0) { exit 99 }
1968$hasUser = $false
1969$hasAssistant = $false
1970foreach ($message in $messages) {
1971    if ([string]::IsNullOrWhiteSpace([string]$message.text)) { exit 100 }
1972    if ([string]$message.role -ceq 'user') { $hasUser = $true }
1973    elseif ([string]$message.role -ceq 'assistant') { $hasAssistant = $true }
1974    else { exit 101 }
1975}
1976if (-not $hasUser -or -not $hasAssistant) { exit 102 }
1977$sha = [Security.Cryptography.SHA256]::Create()
1978try { $contextHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($contextPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1979[IO.File]::WriteAllLines($proofPath, [string[]]@('context-only', $contextRoot, $cwd, $contextHash, [string]$document.schema, [string]$document.source_provider, [string]$messages.Count))
1980[Console]::Out.WriteLine('F7_KIMI_CONTEXT_ONLY_VALIDATED')
1981"#;
1982                spec.launch.fixed_args.push(format!(
1983                    "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {validation}; {script} }}",
1984                ));
1985                spec.launch.fixed_args.push(proof_path.clone());
1986            }
1987            specs.push(spec);
1988        }
1989        let catalog = AgentRegistry::new(specs)
1990            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1991        Ok(catalog)
1992    }
1993
1994    #[cfg(feature = "fixture")]
1995    pub fn new_exact_launcher_fixture(
1996        config: NodeServerConfig,
1997        agent_id: AgentId,
1998        launcher: String,
1999    ) -> Result<Self, NodeServerError> {
2000        if !matches!(agent_id.as_str(), "claude" | "codex" | "grok" | "kimi") {
2001            return Err(NodeServerError::Registry(
2002                "exact launcher fixture requires a supported provider".to_owned(),
2003            ));
2004        }
2005        if launcher.contains('\0') {
2006            return Err(NodeServerError::Registry(
2007                "exact launcher fixture path contains NUL".to_owned(),
2008            ));
2009        }
2010        let launcher_path = Path::new(&launcher);
2011        if !launcher_path.is_absolute() {
2012            return Err(NodeServerError::Registry(
2013                "exact launcher fixture path must be absolute".to_owned(),
2014            ));
2015        }
2016        let metadata = std::fs::metadata(launcher_path).map_err(|_| {
2017            NodeServerError::Registry("exact launcher fixture path is unavailable".to_owned())
2018        })?;
2019        if !metadata.is_file() {
2020            return Err(NodeServerError::Registry(
2021                "exact launcher fixture path must be a regular file".to_owned(),
2022            ));
2023        }
2024        let mut spec = builtin_registry()
2025            .get(&agent_id)
2026            .ok_or_else(|| {
2027                NodeServerError::Registry(
2028                    "exact launcher fixture provider is unavailable".to_owned(),
2029                )
2030            })?
2031            .clone();
2032        spec.launch.program = launcher;
2033        Self::new_fixture_with_spec(config, spec)
2034    }
2035
2036    /// Shared H3B validation and spec construction for
2037    /// `new_harness_mcp_proxy_fixture` and
2038    /// `new_harness_mcp_acp_launcher_fixture`: an absolute, exact-regular-
2039    /// file provider launcher plus at most 32 Unicode, NUL-free fixed
2040    /// arguments (65,536 bytes total), replacing `spec.launch` on a clone of
2041    /// the named builtin catalog entry -- detection command and expected
2042    /// process matcher rebased on the launcher's own basename, exactly the
2043    /// same shape `new_harness_mcp_proxy_fixture` always built.
2044    #[cfg(feature = "fixture")]
2045    fn h3b_provider_spec_with_program_and_args(
2046        agent_id: AgentId,
2047        provider_program: PathBuf,
2048        fixed_args: Vec<OsString>,
2049    ) -> Result<AgentSpec, NodeServerError> {
2050        if !provider_program.is_absolute() || fixed_args.len() > 32 {
2051            return Err(NodeServerError::Registry(
2052                "H3B fixture requires an absolute provider and at most 32 arguments".to_owned(),
2053            ));
2054        }
2055        let metadata = std::fs::symlink_metadata(&provider_program).map_err(|_| {
2056            NodeServerError::Registry("H3B fixture provider is unavailable".to_owned())
2057        })?;
2058        if !metadata.file_type().is_file() || metadata.file_type().is_symlink() {
2059            return Err(NodeServerError::Registry(
2060                "H3B fixture provider must be an exact regular file".to_owned(),
2061            ));
2062        }
2063        let program = provider_program.into_os_string().into_string().map_err(|_| {
2064            NodeServerError::Registry("H3B fixture provider path is not Unicode".to_owned())
2065        })?;
2066        let fixed_args = fixed_args.into_iter().map(|argument| {
2067            argument.into_string().map_err(|_| NodeServerError::Registry(
2068                "H3B fixture argument is not Unicode".to_owned(),
2069            ))
2070        }).collect::<Result<Vec<_>, _>>()?;
2071        let argument_bytes = fixed_args.iter()
2072            .try_fold(0usize, |total, argument| total.checked_add(argument.len()));
2073        if program.contains('\0')
2074            || fixed_args.iter().any(|argument| argument.contains('\0'))
2075            || !matches!(argument_bytes, Some(total) if total <= 65_536)
2076        {
2077            return Err(NodeServerError::Registry(
2078                "H3B fixture provider or arguments are invalid".to_owned(),
2079            ));
2080        }
2081        let mut spec = builtin_registry().get(&agent_id).ok_or_else(|| {
2082            NodeServerError::Registry("H3B fixture provider is unavailable".to_owned())
2083        })?.clone();
2084        let process_name = Path::new(&program).file_name().and_then(|name| name.to_str())
2085            .ok_or_else(|| NodeServerError::Registry(
2086                "H3B fixture provider basename is unavailable".to_owned(),
2087            ))?.to_owned();
2088        spec.detection.command = process_name.clone();
2089        spec.expected_processes = vec![gate4agent_types::ProcessMatcher::Exact {
2090            name: process_name,
2091        }];
2092        spec.launch.program = program;
2093        spec.launch.fixed_args = fixed_args;
2094        Ok(spec)
2095    }
2096
2097    #[cfg(feature = "fixture")]
2098    pub fn new_harness_mcp_proxy_fixture(
2099        config: NodeServerConfig,
2100        provider_program: PathBuf,
2101        fixed_args: Vec<OsString>,
2102        harness_mcp_program: PathBuf,
2103    ) -> Result<Self, NodeServerError> {
2104        let mut config = config.with_harness_mcp_helper(harness_mcp_program)?;
2105        let agent_id = AgentId::new("codex")
2106            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2107        let mut spec =
2108            Self::h3b_provider_spec_with_program_and_args(agent_id, provider_program, fixed_args)?;
2109        spec.display_name = "Controlled H3B MCP proxy fixture".to_owned();
2110        config.fixture_raw_pty_runtime = true;
2111        Self::new_fixture_with_spec(config, spec)
2112    }
2113
2114    /// The ACP-mode sibling of `new_harness_mcp_proxy_fixture`: the same
2115    /// `--harness-mcp-helper` wiring and the same absolute-program-plus-
2116    /// fixed-args launcher replacement as `new_exact_launcher_fixture`, but
2117    /// admitting `ProviderRuntimeRequirement::Acp` the honest way instead of
2118    /// hardcoding it. `new_harness_mcp_proxy_fixture` forces `config.
2119    /// fixture_raw_pty_runtime = true`, which tears out the live
2120    /// `ProviderRuntimeMonitor` entirely (`NodeServer::new_with_registry`)
2121    /// and makes `NodeShared::admit_provider_runtime`'s no-monitor branch
2122    /// hardcode `acp_transport = false` for every provider, refusing
2123    /// `ProviderRuntimeRequirement::Acp` unconditionally regardless of what
2124    /// the catalog declares. This constructor leaves `fixture_raw_pty_
2125    /// runtime` at its default `false`, so a real `ProviderRuntimeMonitor`
2126    /// is built from the (cloned, launcher-replaced) catalog spec, and
2127    /// `ProviderRuntimeMonitor::supports_acp_transport` reads the honest,
2128    /// unmodified catalog fact: every builtin provider this accepts
2129    /// (`claude`, `codex`, `grok`, `kimi`) already declares `capabilities.
2130    /// transports.acp` in `gate4agent-catalog::builtin`, so no capability is
2131    /// invented here -- this constructor only removes the fixture-only
2132    /// bypass standing in its way. `provider_runtime::policy_for_transport`'s
2133    /// `TransportKind::Acp` arm (applied unconditionally at spawn-prep time
2134    /// for every ACP session, fixture or not) is what actually grants the
2135    /// ACP-shaped `ProviderRuntimePolicy` downstream of that admission --
2136    /// this constructor never calls it directly, and does not need to.
2137    #[cfg(feature = "fixture")]
2138    pub fn new_harness_mcp_acp_launcher_fixture(
2139        config: NodeServerConfig,
2140        helper_program: PathBuf,
2141        provider: AgentId,
2142        program: PathBuf,
2143        args: Vec<OsString>,
2144    ) -> Result<Self, NodeServerError> {
2145        if !matches!(provider.as_str(), "claude" | "codex" | "grok" | "kimi") {
2146            return Err(NodeServerError::Registry(
2147                "ACP harness-MCP launcher fixture requires a supported provider".to_owned(),
2148            ));
2149        }
2150        let config = config.with_harness_mcp_helper(helper_program)?;
2151        let mut spec = Self::h3b_provider_spec_with_program_and_args(provider, program, args)?;
2152        let launch = spec.launch.clone();
2153        let acp = spec.capabilities.transports.acp.as_mut().ok_or_else(|| {
2154            NodeServerError::Registry(
2155                "ACP harness-MCP launcher fixture provider does not declare an ACP transport"
2156                    .to_owned(),
2157            )
2158        })?;
2159        // Routes the ACP spawn itself through the exact replaced launcher
2160        // (`AcpSession::spawn_with_launch` -> a bare `Command::new(&launch.
2161        // program)`, `gate4agent`'s `src/acp/spawn.rs`) instead of the
2162        // catalog's own vendor-npm resolution (`acp_command`, same file) --
2163        // without this the ACP transport ignores `spec.launch` entirely and
2164        // tries to spawn the real vendor CLI (e.g. `npx -y @agentclientprotocol/
2165        // codex-acp@1.10.0`), which this fixture must never reach.
2166        acp.launch_override = Some(launch);
2167        spec.display_name = "Controlled ACP harness-MCP launcher fixture".to_owned();
2168        Self::new_fixture_with_spec(config, spec)
2169    }
2170
2171    #[cfg(feature = "fixture")]
2172    pub fn new_resume_fixture(config: NodeServerConfig) -> Result<Self, NodeServerError> {
2173        let mut spec = gate4agent_testkit::interactive_agent_spec();
2174        let claude_id = AgentId::new("claude")
2175            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2176        let claude = builtin_registry()
2177            .get(&claude_id)
2178            .ok_or_else(|| NodeServerError::Registry("Claude fixture adapter is unavailable".to_owned()))?;
2179        spec.id = claude_id;
2180        spec.display_name = "Resumable Claude PTY fixture".to_owned();
2181        spec.expected_processes = vec![gate4agent_types::ProcessMatcher::Exact {
2182            name: "claude".to_owned(),
2183        }];
2184        spec.capabilities.transports.pty_adapter = claude
2185            .capabilities
2186            .transports
2187            .pty_adapter
2188            .clone();
2189        spec.capabilities.adapters.resume = claude
2190            .capabilities
2191            .adapters
2192            .resume
2193            .clone();
2194        let script = spec
2195            .launch
2196            .fixed_args
2197            .pop()
2198            .ok_or_else(|| NodeServerError::Registry("PTY fixture script is unavailable".to_owned()))?;
2199        spec.launch.fixed_args.push(format!(
2200            "& {{ param([Parameter(ValueFromRemainingArguments=$true)][string[]]$ignored) {script} }}",
2201        ));
2202        Self::new_fixture_with_spec(config, spec)
2203    }
2204
2205    #[cfg(feature = "fixture")]
2206    pub fn new_cmd_cwd_fixture(
2207        config: NodeServerConfig,
2208        command: String,
2209    ) -> Result<Self, NodeServerError> {
2210        let mut spec = gate4agent_testkit::interactive_agent_spec();
2211        spec.id = AgentId::new("claude")
2212            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2213        spec.display_name = "Controlled cmd cwd fixture".to_owned();
2214        spec.detection.command = "gate4agent-cwd-fixture.cmd".to_owned();
2215        spec.launch.program = command;
2216        spec.launch.fixed_args.clear();
2217        Self::new_fixture_with_spec(config, spec)
2218    }
2219
2220    #[cfg(feature = "fixture")]
2221    fn new_fixture_with_spec(
2222        config: NodeServerConfig,
2223        spec: gate4agent_types::AgentSpec,
2224    ) -> Result<Self, NodeServerError> {
2225        let catalog = AgentRegistry::new([spec])
2226            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2227        Self::new_with_registry(config, catalog)
2228    }
2229
2230    fn new_with_registry(
2231        config: NodeServerConfig,
2232        catalog: AgentRegistry,
2233    ) -> Result<Self, NodeServerError> {
2234        for profile in config.spawn_profiles.iter() {
2235            if catalog.get(&profile.provider).is_none() {
2236                return Err(NodeServerError::Registry(
2237                    "spawn profile references an unavailable provider".to_owned(),
2238                ));
2239            }
2240        }
2241        let input_settle_timeout_ms = catalog
2242            .iter()
2243            .map(|spec| spec.readiness.timeout_ms)
2244            .max()
2245            .unwrap_or(MUTATION_SETTLE_TIMEOUT_MS)
2246            .saturating_add(READINESS_SETTLE_HEADROOM_MS)
2247            .max(MUTATION_SETTLE_TIMEOUT_MS);
2248        let (provider_contracts, provider_adapter_contracts) =
2249            provider_contract_manifest(&catalog)?;
2250        let compatibility_support = node_compatibility_support_for_manifest(
2251            &provider_contracts,
2252            &provider_adapter_contracts,
2253        )?;
2254        validate_node_negotiated_handshake_capacity(&compatibility_support)
2255            .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2256        let enabled_providers = provider_contracts
2257            .iter()
2258            .map(|contract| contract.provider.clone())
2259            .collect();
2260        #[cfg(feature = "fixture")]
2261        let fixture_raw_pty_runtime = config.fixture_raw_pty_runtime;
2262        #[cfg(not(feature = "fixture"))]
2263        let fixture_raw_pty_runtime = false;
2264        let (provider_runtime_monitor, provider_runtime_statuses) = if fixture_raw_pty_runtime {
2265            let statuses = ProviderRuntimeStatuses::new(catalog.iter().map(|spec| {
2266                crate::protocol::ProviderRuntimeStatus::raw_passthrough(spec.id.clone(), None)
2267            })).expect("fixture provider catalog remains bounded");
2268            (None, statuses)
2269        } else {
2270            let monitor = Arc::new(ProviderRuntimeMonitor::new(&catalog));
2271            let statuses = monitor.collect();
2272            (Some(monitor), statuses)
2273        };
2274        let state_path_lock =
2275            session_registry::StatePathLock::acquire(config.state_path.as_deref())
2276                .map_err(|error| {
2277                    durable_state_server_error(error, DURABLE_STATE_LOCK_FAILED_ERROR)
2278                })?;
2279        let (delivery_store, delivered_bundles) = match config.state_path.as_ref() {
2280            Some(state_path) => {
2281                let delivery_root = delivery_store_root_for_state_path(state_path)
2282                    .ok_or_else(|| {
2283                        NodeServerError::DeliveryStore(
2284                            "delivery store root could not be derived from state path".to_owned(),
2285                        )
2286                    })?;
2287                let (store, bundles) = DeliveryStore::open(delivery_root).map_err(|error| {
2288                    let detail = match &error {
2289                        DeliveryStoreError::Storage(io_error) => {
2290                            format!("{error}: {io_error}")
2291                        }
2292                        _ => error.to_string(),
2293                    };
2294                    NodeServerError::DeliveryStore(detail)
2295                })?;
2296                (Some(store), bundles)
2297            }
2298            None => (None, Vec::new()),
2299        };
2300        let delivered_catalog = BundleCatalog::new(delivered_bundles).map_err(|error| {
2301            NodeServerError::DeliveryStore(format!("bundle catalog: {error}"))
2302        })?;
2303        let (context_pack_store, durable_context_packs) = match config.state_path.as_ref() {
2304            Some(state_path) => {
2305                let context_pack_root = context_pack_store_root_for_state_path(state_path)
2306                    .ok_or(NodeServerError::ContextPackStore)?;
2307                let (store, packs) = ContextPackStore::open(context_pack_root)
2308                    .map_err(|_| NodeServerError::ContextPackStore)?;
2309                (Some(store), packs)
2310            }
2311            None => (None, Vec::new()),
2312        };
2313        let mut durable_context_catalog = ContextPackCatalog::default();
2314        for pack in durable_context_packs {
2315            durable_context_catalog
2316                .insert(pack)
2317                .map_err(|_| NodeServerError::ContextPackStore)?;
2318        }
2319        let session_environment_materializer = config
2320            .session_environment
2321            .as_ref()
2322            .map(|environment| {
2323                SessionEnvironmentMaterializer::new(
2324                    environment.root.clone(),
2325                    Arc::clone(&environment.resolver),
2326                )
2327                .map_err(|_| NodeServerError::SessionEnvironmentMaterializer)
2328            })
2329            .transpose()?;
2330        // Session launchers go through the unprivileged `exec-as` wrapper when run-as is set
2331        // (the provider version probe above already used the original catalog).
2332        #[cfg(unix)]
2333        let catalog = crate::isolation::wrap_registry(catalog).map_err(NodeServerError::Registry)?;
2334        let native_session_catalog = config.history.clone().map(|history| {
2335            Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)))
2336        });
2337        let (handle, runtime) = match config.history.clone() {
2338            Some(history) => NativeRuntime::new_with_history(catalog, config.runtime, history),
2339            None => NativeRuntime::new(catalog, config.runtime),
2340        };
2341        let native_launch_profile_control = runtime.native_launch_profile_control();
2342        let harness_mcp_registry = config
2343            .harness_mcp_helper
2344            .clone()
2345            .map(HarnessMcpProxyRegistry::new);
2346        let events = handle.subscribe(CONTROL_EVENT_SUBSCRIPTION_CAPACITY);
2347        let incarnation_id = random_incarnation_id()
2348            .map_err(NodeServerError::IncarnationIdentity)?;
2349        let mut loaded = session_registry::load(config.state_path.as_deref(), &config.node_id)
2350            .map_err(durable_state_load_error)?;
2351        let managed_worktrees = std::mem::take(&mut loaded.managed_worktrees);
2352        let managed_worktree_tombstones =
2353            std::mem::take(&mut loaded.managed_worktree_tombstones);
2354        let materializations = std::mem::take(&mut loaded.materializations);
2355        let managed_spawn_replays = std::mem::take(&mut loaded.managed_spawn_replays);
2356        let (workspaces, records, persistence_warning) =
2357            merge_durable_state(&config.workspaces, loaded)?;
2358        let mut shared = NodeShared::new_with_incarnation(
2359            handle,
2360            config.access_token.clone(),
2361            config.node_id.clone(),
2362            incarnation_id,
2363            workspaces,
2364            enabled_providers,
2365            provider_runtime_statuses,
2366            provider_runtime_monitor,
2367            provider_contracts,
2368            provider_adapter_contracts,
2369            config.spawn_profiles.clone(),
2370            Some(native_launch_profile_control),
2371            native_session_catalog,
2372            session_environment_materializer,
2373            config.state_path.clone(),
2374            records,
2375            managed_worktrees,
2376            managed_worktree_tombstones,
2377            materializations,
2378            persistence_warning,
2379            input_settle_timeout_ms,
2380        );
2381        shared.harness_mcp_registry = harness_mcp_registry.clone();
2382        shared.session_record_retention = config.session_record_retention;
2383        shared.network_allowlist_catalog =
2384            RwLock::new(config.network_allowlist_catalog.clone());
2385        shared.bundle_catalog = RwLock::new(delivered_catalog);
2386        shared.delivery_store = Mutex::new(delivery_store);
2387        shared.context_catalog = RwLock::new(durable_context_catalog);
2388        shared.context_pack_store = Mutex::new(context_pack_store);
2389        shared.managed_spawn_replays = Mutex::new(
2390            managed_spawn_replays
2391                .into_iter()
2392                .map(|record| (record.idempotency_key.clone(), record))
2393                .collect(),
2394        );
2395        let shared = Arc::new(shared);
2396        if let Some(registry) = harness_mcp_registry {
2397            let weak = Arc::downgrade(&shared);
2398            registry.set_event_sink(Arc::new(move |event| match weak.upgrade() {
2399                Some(shared) => shared.publish_transient(event),
2400                None => false,
2401            }));
2402        }
2403        if shared.persistence_error().is_none() {
2404            shared.persist_state().map_err(|error| {
2405                durable_state_server_error(error, DURABLE_STATE_COMMIT_FAILED_ERROR)
2406            })?;
2407        }
2408        Ok(Self {
2409            config,
2410            runtime,
2411            shared,
2412            events,
2413            state_path_lock,
2414        })
2415    }
2416
2417    pub fn shutdown_handle(&self) -> NodeShutdownHandle {
2418        NodeShutdownHandle {
2419            shared: Arc::clone(&self.shared),
2420        }
2421    }
2422
2423    #[cfg(feature = "fixture")]
2424    pub fn spawn_managed_worktree_v2_failure_probe_fixture(
2425        &self,
2426    ) -> SpawnManagedWorktreeV2FailureProbe {
2427        self.shared
2428            .fixture_spawn_managed_worktree_v2_failure_probe
2429            .clone()
2430    }
2431
2432    /// Installs one immutable host-local environment profile before the server runs.
2433    ///
2434    /// Native resolvers remain inside the runtime; the shared node registry keeps
2435    /// only public IDs, revisions, provider bindings, and opaque native profile IDs.
2436    pub fn install_environment_profile(
2437        &mut self,
2438        profile: NodeEnvironmentProfile,
2439    ) -> Result<(), NodeServerError> {
2440        if !self.shared.enabled_providers.contains(profile.provider()) {
2441            return Err(NodeServerError::EnvironmentProfileProviderUnavailable(
2442                profile.provider().clone(),
2443            ));
2444        }
2445        let (binding, native_profiles, materialization) = profile.into_parts();
2446        if materialization.is_some() && self.shared.session_environment_materializer.is_none() {
2447            return Err(NodeServerError::SessionEnvironmentMaterializerRequired);
2448        }
2449        let mut profiles = self
2450            .shared
2451            .environment_profiles
2452            .write()
2453            .unwrap_or_else(|poisoned| poisoned.into_inner());
2454        if profiles.contains_key(&binding.id) {
2455            return Err(NodeServerError::DuplicateEnvironmentProfile(
2456                binding.id.clone(),
2457            ));
2458        }
2459        if profiles.len() >= MAX_NODE_ENVIRONMENT_PROFILES {
2460            return Err(NodeServerError::EnvironmentProfileCapacity {
2461                max: MAX_NODE_ENVIRONMENT_PROFILES,
2462            });
2463        }
2464        for native_id in binding.native_profile_ids() {
2465            if profiles.values().any(|current| {
2466                current.native_profile_ids().any(|current_id| current_id == native_id)
2467            }) {
2468                return Err(NodeServerError::DuplicateNativeEnvironmentProfile(
2469                    native_id.to_string(),
2470                ));
2471            }
2472        }
2473
2474        let mut installed = Vec::with_capacity(native_profiles.len());
2475        for native_profile in native_profiles {
2476            let native_id = native_profile.id().clone();
2477            if let Err(error) = self.runtime.upsert_native_launch_profile(native_profile) {
2478                for installed_id in installed.into_iter().rev() {
2479                    let _ = self.runtime.remove_native_launch_profile(&installed_id);
2480                }
2481                return Err(NodeServerError::NativeEnvironmentProfile(error.to_string()));
2482            }
2483            installed.push(native_id);
2484        }
2485        if let Some(materialization) = materialization {
2486            self.shared
2487                .environment_materialization_profiles
2488                .write()
2489                .unwrap_or_else(|poisoned| poisoned.into_inner())
2490                .insert(binding.id.clone(), materialization);
2491        }
2492        profiles.insert(binding.id.clone(), binding);
2493        Ok(())
2494    }
2495
2496    /// Installs one immutable host-local bundle before the server runs.
2497    pub fn install_bundle(&mut self, bundle: NodeBundle) -> Result<(), NodeServerError> {
2498        if self.shared.session_environment_materializer.is_none() {
2499            return Err(NodeServerError::SessionEnvironmentMaterializerRequired);
2500        }
2501        let mut catalog = self
2502            .shared
2503            .bundle_catalog
2504            .write()
2505            .unwrap_or_else(|poisoned| poisoned.into_inner());
2506        catalog
2507            .insert_idempotent(bundle)
2508            .map_err(|error| NodeServerError::BundleCatalog(error.to_string()))?;
2509        Ok(())
2510    }
2511
2512    pub async fn run_until_ctrl_signal(self) -> Result<(), NodeServerError> {
2513        let shutdown = self.shutdown_handle();
2514        let run = self.run();
2515        tokio::pin!(run);
2516        tokio::select! {
2517            result = &mut run => result,
2518            signal = wait_for_ctrl_signal() => {
2519                signal?;
2520                let shutdown_result = shutdown.request_shutdown().await;
2521                let run_result = run.await;
2522                shutdown_result.and(run_result)
2523            }
2524        }
2525    }
2526
2527    pub async fn run(self) -> Result<(), NodeServerError> {
2528        let NodeServer {
2529            config,
2530            mut runtime,
2531            shared,
2532            events,
2533            state_path_lock: _state_path_lock,
2534        } = self;
2535        shared.reconcile_materializations();
2536        shared.reconcile_managed_worktrees().await;
2537        shared.retire_unavailable_session_records().await;
2538        shared.reconcile_context_pack_exports().await;
2539        {
2540            let restore = Arc::clone(&shared);
2541            tokio::spawn(async move {
2542                sleep(Duration::from_secs(3)).await;
2543                restore.restore_journalled_sessions().await;
2544                loop {
2545                    restore.refresh_session_journal();
2546                    if restore.shutdown.load(Ordering::Acquire) { break; }
2547                    sleep(Duration::from_secs(10)).await;
2548                }
2549            });
2550        }
2551        // Lifecycle hooks are retired (owner ruling 2026-09-25): the node
2552        // observes sessions through ACP where a provider has it; there is no
2553        // hook ingress.
2554        let endpoint = config.endpoint.clone();
2555        let call_home = config.call_home;
2556        let call_home_identity = config.call_home_identity.clone();
2557        let api_listen = config.api_listen;
2558        let bridge_listen = config.bridge_listen;
2559        let bridge_underlay = config.bridge_underlay;
2560        let bridge_auth = browser_bridge::BridgeAuth::from_optional(config.bridge_token)
2561            .map_err(|_| NodeServerError::InvalidBridgeToken)?;
2562        let accept_shared = Arc::clone(&shared);
2563        let api_shared = Arc::clone(&shared);
2564        let bridge_shared = Arc::clone(&shared);
2565        let shutdown_shared = Arc::clone(&shared);
2566        let shutdown_timeout = Duration::from_millis(
2567            config.runtime.provider_shutdown_timeout_ms.max(1),
2568        );
2569        let result = {
2570            let runtime_loop = drive_runtime_until_shutdown(
2571                &mut runtime,
2572                events,
2573                Arc::clone(&shared),
2574                shutdown_timeout,
2575            );
2576            let accept_loop = accept_connections(&endpoint, call_home, call_home_identity, accept_shared);
2577            let api_loop = http_api::run(api_listen, api_shared);
2578            let bridge_loop = browser_bridge::run(bridge_listen, bridge_auth, bridge_shared, bridge_underlay);
2579            tokio::pin!(runtime_loop);
2580            tokio::pin!(accept_loop);
2581            tokio::pin!(api_loop);
2582            tokio::pin!(bridge_loop);
2583            tokio::select! {
2584                runtime_result = &mut runtime_loop => {
2585                    let shutdown_result = shutdown_shared
2586                        .begin_shutdown()
2587                        .await
2588                        .map_err(NodeServerError::ShutdownDispatch);
2589                    let (accept_result, api_result, bridge_result) =
2590                        tokio::join!(&mut accept_loop, &mut api_loop, &mut bridge_loop);
2591                    match runtime_result {
2592                        Err(error) => Err(error),
2593                        Ok(()) => shutdown_result
2594                            .and(accept_result)
2595                            .and(api_result.map_err(NodeServerError::HttpApi))
2596                            .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2597                    }
2598                }
2599                accept_result = &mut accept_loop => {
2600                    let shutdown_result = shutdown_shared
2601                        .begin_shutdown()
2602                        .await
2603                        .map_err(NodeServerError::ShutdownDispatch);
2604                    let (runtime_result, api_result, bridge_result) =
2605                        tokio::join!(&mut runtime_loop, &mut api_loop, &mut bridge_loop);
2606                    match accept_result {
2607                        Err(error) => Err(error),
2608                        Ok(()) => shutdown_result
2609                            .and(runtime_result)
2610                            .and(api_result.map_err(NodeServerError::HttpApi))
2611                            .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2612                    }
2613                }
2614                api_result = &mut api_loop => {
2615                    let shutdown_result = shutdown_shared
2616                        .begin_shutdown()
2617                        .await
2618                        .map_err(NodeServerError::ShutdownDispatch);
2619                    let (runtime_result, accept_result, bridge_result) =
2620                        tokio::join!(&mut runtime_loop, &mut accept_loop, &mut bridge_loop);
2621                    match api_result {
2622                        Err(error) => Err(NodeServerError::HttpApi(error)),
2623                        Ok(()) => shutdown_result
2624                            .and(runtime_result)
2625                            .and(accept_result)
2626                            .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2627                    }
2628                }
2629                bridge_result = &mut bridge_loop => {
2630                    let shutdown_result = shutdown_shared
2631                        .begin_shutdown()
2632                        .await
2633                        .map_err(NodeServerError::ShutdownDispatch);
2634                    let (runtime_result, accept_result, api_result) =
2635                        tokio::join!(&mut runtime_loop, &mut accept_loop, &mut api_loop);
2636                    match bridge_result {
2637                        Err(error) => Err(NodeServerError::BridgeApi(error)),
2638                        Ok(()) => shutdown_result
2639                            .and(runtime_result)
2640                            .and(accept_result)
2641                            .and(api_result.map_err(NodeServerError::HttpApi)),
2642                    }
2643                }
2644            }
2645        };
2646        // Persistence gap fix: durable DeliveryStore / ContextPackStore hold
2647        // exclusive flock (Unix) / share-mode locks (Windows) inside NodeShared.
2648        // NodeShutdownHandle also clones that Arc, so without an explicit release
2649        // here a supervisor that keeps the shutdown handle cannot reopen the same
2650        // state path after run() returns (EAGAIN / Resource temporarily unavailable).
2651        // StatePathLock already drops with this stack frame (`_state_path_lock`).
2652        if let Ok(mut guard) = shared.delivery_store.lock() {
2653            *guard = None;
2654        }
2655        if let Ok(mut guard) = shared.context_pack_store.lock() {
2656            *guard = None;
2657        }
2658        result
2659    }
2660}
2661
2662async fn drive_runtime_until_shutdown(
2663    runtime: &mut NativeRuntime,
2664    events: EventSubscription,
2665    shared: Arc<NodeShared>,
2666    shutdown_timeout: Duration,
2667) -> Result<(), NodeServerError> {
2668    // Published once, never per iteration. The worker loops inside
2669    // `NativeRuntime` fold into this profile continuously; turning it into
2670    // percentiles is what costs, and that is `GET /metrics`'s bill to pay.
2671    let _ = shared
2672        .shell_efficiency_profile
2673        .set(runtime.shell_efficiency_profile());
2674    let mut shutdown_started = None;
2675    let mut last_retention_sweep = Instant::now();
2676    let mut last_provisional_reap = Instant::now();
2677    loop {
2678        // Every phase below is timed unconditionally -- see
2679        // `drive_loop_profile`'s own doc comment for why an always-on,
2680        // allocation-free ring is cheap enough to never need a feature
2681        // flag. This is the only place these numbers can be taken: the
2682        // phases are calls made directly from this loop, not something a
2683        // callee could time on our behalf.
2684        let runtime_tick_start = Instant::now();
2685        let tick_result = runtime.tick().await;
2686        let runtime_tick_elapsed = runtime_tick_start.elapsed();
2687
2688        let event_drain_start = Instant::now();
2689        // Every command the kernel refused, named. `NativeRuntimeTick`
2690        // has carried these outcomes all along and nothing read them, so a
2691        // rejected command -- a hook event denied by runtime policy, a
2692        // stale generation, an unsupported capability -- vanished at the
2693        // last step of the loop that produced it. A refusal the operator
2694        // cannot see is indistinguishable from an event that never arrived,
2695        // and telling those two apart is most of debugging this stack.
2696        for outcome in &tick_result.command_outcomes {
2697            if let Err(error) = &outcome.result {
2698                shared.rejected_commands_total.fetch_add(1, Ordering::Relaxed);
2699                tracing::warn!(
2700                    command_id = outcome.command_id.0,
2701                    cause = %error,
2702                    "control command rejected"
2703                );
2704                shared.record_rejected_command(
2705                    outcome.command_id,
2706                    error.to_string(),
2707                    error.is_unsupported_transport(),
2708                );
2709            }
2710        }
2711        let mut events_drained: u64 = 0;
2712        while let Ok(event) = events.try_recv() {
2713            events_drained += 1;
2714            let clean_exit = matches!(
2715                event.event,
2716                ControlEventKind::Exited { exit_code: Some(0), forced: false },
2717            );
2718            let instance_id = event.instance_id;
2719            let generation = event.generation;
2720            // Resolve and spawn before publish_control: publish_control's own
2721            // managed-record bookkeeping (reconcile_managed_record) downgrades
2722            // this exact record from Live to Dormant and clears
2723            // active_session for every Exited event, inside the same
2724            // synchronous call. Capturing (record_id, session) here — before
2725            // that runs — is the only way to observe them still Live and
2726            // still populated; the export itself must still run on its own
2727            // detached task (never awaited inline on this loop) because it
2728            // needs NativeRuntime::tick() to keep running to ever settle, and
2729            // tick() is only ever driven by this same loop. See
2730            // reconcile_context_pack_export_for_record's doc comment.
2731            if clean_exit {
2732                if let Some((record_id, session)) =
2733                    shared.managed_record_export_target_for_instance(instance_id, generation)
2734                {
2735                    let export_shared = Arc::clone(&shared);
2736                    tokio::spawn(async move {
2737                        export_shared
2738                            .reconcile_context_pack_export_for_record(&record_id, session)
2739                            .await;
2740                    });
2741                }
2742            }
2743            shared.publish_control(event);
2744        }
2745        let event_drain_elapsed = event_drain_start.elapsed();
2746
2747        let publish_terminal_frames_start = Instant::now();
2748        shared.publish_terminal_frames();
2749        let publish_terminal_frames_elapsed = publish_terminal_frames_start.elapsed();
2750
2751        // Physical PTY count and the runtime's own per-phase tick timings
2752        // both live inside `NativeRuntime`, which `GET /metrics` (reading
2753        // only `NodeShared`) cannot reach directly -- this loop is the one
2754        // place holding `&mut NativeRuntime`, so it is the only place that
2755        // can copy either out.
2756        shared
2757            .native_session_gauge
2758            .store(runtime.active_native_sessions(), Ordering::Relaxed);
2759        *shared
2760            .runtime_tick_profile
2761            .lock()
2762            .unwrap_or_else(|poisoned| poisoned.into_inner()) = runtime.tick_profile_snapshot();
2763
2764        let remainder_start = Instant::now();
2765        if shared.shutdown.load(Ordering::Acquire) {
2766            let started = *shutdown_started.get_or_insert_with(Instant::now);
2767            let snapshot = shared.handle.snapshot();
2768            let sessions_settled = snapshot.sessions.iter().all(|session| {
2769                !matches!(
2770                    session.status,
2771                    gate4agent_types::SessionStatus::Starting
2772                        | gate4agent_types::SessionStatus::Running
2773                        | gate4agent_types::SessionStatus::Stopping
2774                )
2775            });
2776            if runtime.active_native_sessions() == 0 && sessions_settled {
2777                return Ok(());
2778            }
2779            if started.elapsed() >= shutdown_timeout {
2780                return Err(NodeServerError::ShutdownTimedOut {
2781                    active_native_sessions: runtime.active_native_sessions(),
2782                });
2783            }
2784        }
2785        let remainder_elapsed = remainder_start.elapsed();
2786
2787        {
2788            let mut profile = shared
2789                .drive_loop_profile
2790                .lock()
2791                .unwrap_or_else(|poisoned| poisoned.into_inner());
2792            profile.record_runtime_tick(runtime_tick_elapsed);
2793            profile.record_event_drain(event_drain_elapsed);
2794            profile.record_publish_terminal_frames(publish_terminal_frames_elapsed);
2795            profile.record_remainder(remainder_elapsed);
2796            profile.note_iteration(Instant::now());
2797        }
2798
2799        // "Produced nothing" means this iteration's `NativeRuntime::tick`
2800        // applied zero observations (`tick_result.observations_applied`,
2801        // which already folds in any terminal frames collected -- see
2802        // `NativeEffectDispatcher::drain_observations`) AND the
2803        // control-event subscription drained above forwarded zero events.
2804        // Command ingress and dispatched effects are deliberately NOT part
2805        // of this definition: an idle node still runs `step_control_plane`
2806        // and every provider supervisor's `tick` regardless, every 10ms --
2807        // so "nothing" here means nothing OBSERVABLE left this iteration,
2808        // matching what a reader would assume from `drive_loop_iterations`'s
2809        // own field name, not that the tick itself was free of work.
2810        shared.drive_loop_iterations_total.fetch_add(1, Ordering::Relaxed);
2811        if tick_result.observations_applied == 0 && events_drained == 0 {
2812            shared.drive_loop_iterations_idle.fetch_add(1, Ordering::Relaxed);
2813        }
2814
2815        // A sweep cadence, not a policy value -- see
2816        // `SESSION_RECORD_RETENTION_SWEEP_PERIOD`'s own doc comment. The
2817        // policy itself (whether this does anything at all) lives entirely
2818        // in `NodeShared::session_record_retention`.
2819        if last_retention_sweep.elapsed() >= SESSION_RECORD_RETENTION_SWEEP_PERIOD {
2820            shared.retire_unavailable_session_records().await;
2821            last_retention_sweep = Instant::now();
2822        }
2823        // Provisional Spawn grace: kill PTYs that never got an HQ attach.
2824        if last_provisional_reap.elapsed() >= Duration::from_secs(2) {
2825            for address in shared.take_due_provisional_spawns() {
2826                shared.reap_provisional_spawn(address).await;
2827            }
2828            last_provisional_reap = Instant::now();
2829        }
2830
2831        // Fast-poll for 300ms after PTY input so its echo is read and published
2832        // within ~1ms; otherwise nap 10ms but wake at once on new input.
2833        let hot = shared
2834            .pty_input_marks
2835            .lock()
2836            .unwrap_or_else(|poisoned| poisoned.into_inner())
2837            .values()
2838            .any(|(at, _)| at.elapsed() < Duration::from_millis(300));
2839        if hot {
2840            sleep(Duration::from_millis(1)).await;
2841        } else {
2842            tokio::select! {
2843                _ = sleep(Duration::from_millis(10)) => {}
2844                _ = shared.pty_input_wake.notified() => {}
2845            }
2846        }
2847    }
2848}
2849
2850#[derive(Clone)]
2851pub struct NodeShutdownHandle {
2852    shared: Arc<NodeShared>,
2853}
2854
2855impl NodeShutdownHandle {
2856    pub async fn request_shutdown(&self) -> Result<(), NodeServerError> {
2857        self.shared
2858            .begin_shutdown()
2859            .await
2860            .map_err(NodeServerError::ShutdownDispatch)
2861    }
2862}
2863
2864async fn wait_for_ctrl_signal() -> Result<(), NodeServerError> {
2865    #[cfg(windows)]
2866    {
2867    let mut ctrl_c = tokio::signal::windows::ctrl_c()?;
2868    let mut ctrl_break = tokio::signal::windows::ctrl_break()?;
2869    return tokio::select! {
2870        signal = ctrl_c.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2871        signal = ctrl_break.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2872    };
2873    }
2874    #[cfg(unix)]
2875    {
2876        let mut interrupt = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())?;
2877        let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
2878        tokio::select! {
2879            signal = interrupt.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2880            signal = terminate.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2881        }
2882    }
2883}
2884
2885/// The providers this node actually runs.
2886///
2887/// `builtin_registry()` is a reference catalog of everything the project has
2888/// ever described; the fleet is this list, and it is deliberately short. A
2889/// provider only belongs here if it can be exercised for real -- there is a
2890/// subscription behind it and a failure in it can be reproduced and
2891/// debugged. Carrying one that cannot is carrying code nobody can verify,
2892/// which is worse than not supporting it: it looks supported.
2893///
2894/// A provider dropped from the fleet by this rule is removed outright, not
2895/// carried dark: no catalog entry, no adapter, no dead code claiming support
2896/// for something nobody can verify runs.
2897/// Host interactive shell for HQ terminal attach (no provider CLI, no network).
2898fn shell_host_spec() -> AgentSpec {
2899    // Windows nodes (incl. MSYS-hosted) have no `/bin/bash` for Win32 Path::is_file;
2900    // fleet inventory on op-box resolves `bash` to the WSL stub at
2901    // `/c/Windows/system32/bash`, which is not a host shell. Use ComSpec/cmd.
2902    #[cfg(windows)]
2903    let (detect_command, program, fixed_args, process_name) = {
2904        let program = std::env::var("ComSpec").unwrap_or_else(|_| {
2905            r"C:\Windows\System32\cmd.exe".to_owned()
2906        });
2907        ("cmd".to_owned(), program, Vec::<String>::new(), "cmd".to_owned())
2908    };
2909    #[cfg(not(windows))]
2910    let (detect_command, program, fixed_args, process_name) = (
2911        "bash".to_owned(),
2912        "/bin/bash".to_owned(),
2913        vec!["-i".to_owned()],
2914        "bash".to_owned(),
2915    );
2916    AgentSpec {
2917        id: AgentId::new("shell").expect("shell agent id"),
2918        revision: "hatchery:shell-pty-1".to_owned(),
2919        display_name: "Host shell".to_owned(),
2920        detection: DetectionSpec {
2921            command: detect_command,
2922            aliases: vec!["sh".to_owned(), "shell".to_owned(), "cmd".to_owned()],
2923            required_commands: Vec::new(),
2924            unsupported_platforms: Vec::new(),
2925        },
2926        launch: LaunchSpec {
2927            program,
2928            fixed_args,
2929        },
2930        expected_processes: vec![ProcessMatcher::Exact {
2931            name: process_name,
2932        }],
2933        prompt: PromptSpec {
2934            initial: InitialPromptMode::None,
2935            native_draft: None,
2936        },
2937        readiness: AgentReadinessSpec::default(),
2938        capabilities: AgentCapabilities {
2939            agent_commands: None,
2940            transports: AgentTransportCapabilities {
2941                pty: true,
2942                pty_adapter: None,
2943                pipe: None,
2944                acp: None,
2945            },
2946            adapters: AgentAdapterCapabilities::default(),
2947        },
2948        verification: SpecVerification::Gate4AgentVerified,
2949    }
2950}
2951
2952fn active_registry() -> Result<AgentRegistry, NodeServerError> {
2953    let mut specs = vec![shell_host_spec()];
2954    specs.extend(
2955        builtin_registry()
2956            .iter()
2957            .filter(|spec| matches!(spec.id.as_str(), "claude" | "codex" | "grok" | "kimi"))
2958            .cloned(),
2959    );
2960    AgentRegistry::new(specs).map_err(|error| NodeServerError::Registry(error.to_string()))
2961}
2962
2963fn provider_contract_manifest(
2964    catalog: &AgentRegistry,
2965) -> Result<
2966    (
2967        Vec<ProviderContractSupport>,
2968        Vec<ProviderAdapterContractSupport>,
2969    ),
2970    NodeServerError,
2971> {
2972    let mut provider_contracts = Vec::new();
2973    let mut provider_adapter_contracts = Vec::new();
2974    for spec in catalog.iter() {
2975        let provider = spec.id.clone();
2976        let revision = ProviderContractRevision::new(spec.revision.clone())
2977            .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2978        provider_contracts.push(ProviderContractSupport {
2979            provider: provider.clone(),
2980            revision,
2981        });
2982        for (family, binding) in declared_adapter_bindings(spec)? {
2983            let revision = AdapterContractRevision::new(binding.revision.clone())
2984                .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2985            provider_adapter_contracts.push(ProviderAdapterContractSupport {
2986                provider: provider.clone(),
2987                family,
2988                adapter_id: binding.id.clone(),
2989                revision,
2990            });
2991        }
2992    }
2993    validate_provider_contract_manifest(&provider_contracts, &provider_adapter_contracts)
2994        .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2995    Ok((provider_contracts, provider_adapter_contracts))
2996}
2997
2998fn declared_adapter_bindings(
2999    spec: &AgentSpec,
3000) -> Result<Vec<(AdapterFamily, &AdapterBinding)>, NodeServerError> {
3001    let mut bindings = Vec::new();
3002    push_declared_binding(
3003        &mut bindings,
3004        AdapterFamily::PtySemantic,
3005        spec.capabilities.transports.pty_adapter.as_ref(),
3006    )?;
3007    push_declared_binding(
3008        &mut bindings,
3009        AdapterFamily::Pipe,
3010        spec.capabilities.adapters.pty_sidecar.as_ref(),
3011    )?;
3012    let pipe_transport = spec.capabilities.transports.pipe.as_ref();
3013    let pipe_family = pipe_transport.map(|transport| match transport.protocol {
3014        gate4agent_types::PipeProtocol::SemanticNdjson
3015        | gate4agent_types::PipeProtocol::StructuredJsonl => AdapterFamily::Pipe,
3016        gate4agent_types::PipeProtocol::OneShotText => AdapterFamily::OneShot,
3017    });
3018    push_declared_binding(
3019        &mut bindings,
3020        AdapterFamily::Pipe,
3021        pipe_transport
3022            .filter(|_| pipe_family == Some(AdapterFamily::Pipe))
3023            .map(|transport| &transport.adapter),
3024    )?;
3025    push_declared_binding(
3026        &mut bindings,
3027        AdapterFamily::Acp,
3028        spec.capabilities
3029            .transports
3030            .acp
3031            .as_ref()
3032            .map(|transport| &transport.adapter),
3033    )?;
3034    push_declared_binding(
3035        &mut bindings,
3036        AdapterFamily::Hook,
3037        spec.capabilities.adapters.hook.as_ref(),
3038    )?;
3039    push_declared_binding(
3040        &mut bindings,
3041        AdapterFamily::ManagedHook,
3042        spec.capabilities.adapters.managed_hook.as_ref(),
3043    )?;
3044    push_declared_binding(
3045        &mut bindings,
3046        AdapterFamily::OneShot,
3047        pipe_transport
3048            .filter(|_| pipe_family == Some(AdapterFamily::OneShot))
3049            .map(|transport| &transport.adapter),
3050    )?;
3051    push_declared_binding(
3052        &mut bindings,
3053        AdapterFamily::OneShot,
3054        spec.capabilities.adapters.one_shot.as_ref(),
3055    )?;
3056    push_declared_binding(
3057        &mut bindings,
3058        AdapterFamily::History,
3059        spec.capabilities.adapters.history.as_ref(),
3060    )?;
3061    push_declared_binding(
3062        &mut bindings,
3063        AdapterFamily::Resume,
3064        spec.capabilities.adapters.resume.as_ref(),
3065    )?;
3066    push_declared_binding(
3067        &mut bindings,
3068        AdapterFamily::SessionOptions,
3069        spec.capabilities.adapters.session_options.as_ref(),
3070    )?;
3071    push_declared_binding(
3072        &mut bindings,
3073        AdapterFamily::CapabilityProbe,
3074        spec.capabilities.adapters.capability_probe.as_ref(),
3075    )?;
3076    Ok(bindings)
3077}
3078
3079fn push_declared_binding<'a>(
3080    bindings: &mut Vec<(AdapterFamily, &'a AdapterBinding)>,
3081    family: AdapterFamily,
3082    binding: Option<&'a AdapterBinding>,
3083) -> Result<(), NodeServerError> {
3084    let Some(binding) = binding else {
3085        return Ok(());
3086    };
3087    if let Some((_, existing)) = bindings
3088        .iter()
3089        .find(|(existing_family, _)| *existing_family == family)
3090    {
3091        if *existing == binding {
3092            return Ok(());
3093        }
3094        return Err(NodeServerError::ProviderContractManifest(format!(
3095            "provider declares conflicting {family:?} adapter bindings"
3096        )));
3097    }
3098    bindings.push((family, binding));
3099    Ok(())
3100}
3101
3102fn merge_durable_state(
3103    configured: &[WorkspaceConfig],
3104    mut loaded: LoadedNodeState,
3105) -> Result<(Vec<WorkspaceConfig>, Vec<ManagedSessionRecord>, Option<String>), NodeServerError> {
3106    let mut workspaces = configured
3107        .iter()
3108        .cloned()
3109        .map(|workspace| (workspace.workspace_id.clone(), workspace))
3110        .collect::<BTreeMap<_, _>>();
3111    let mut roots = workspaces
3112        .values()
3113        .map(|workspace| {
3114            (
3115                platform::root_identity(&workspace.canonical_root),
3116                workspace.workspace_id.clone(),
3117            )
3118        })
3119        .collect::<BTreeMap<_, _>>();
3120    let mut warning = loaded
3121        .warning
3122        .take()
3123        .map(|message| session_registry::sanitized_persistence_summary(&message));
3124
3125    for (workspace_id, canonical_root) in loaded.workspaces {
3126        if let Some(existing) = workspaces.get(&workspace_id) {
3127            if !platform::roots_equal(&existing.canonical_root, &canonical_root) {
3128                return Err(NodeServerError::DurableState(io::Error::new(
3129                    io::ErrorKind::InvalidData,
3130                    DURABLE_STATE_CONFLICT_ERROR,
3131                )));
3132            }
3133            continue;
3134        }
3135        if roots.get(&platform::root_identity(&canonical_root)).is_some() {
3136            return Err(NodeServerError::DurableState(io::Error::new(
3137                io::ErrorKind::InvalidData,
3138                DURABLE_STATE_CONFLICT_ERROR,
3139            )));
3140        }
3141        if !Path::new(&canonical_root).is_dir() {
3142            warning.get_or_insert_with(|| {
3143                session_registry::DURABLE_STATE_WORKSPACE_WARNING.to_owned()
3144            });
3145            continue;
3146        }
3147        let workspace = WorkspaceConfig::new(workspace_id.clone(), &canonical_root).map_err(|_| {
3148            NodeServerError::DurableState(io::Error::new(
3149                io::ErrorKind::InvalidData,
3150                DURABLE_STATE_CONFLICT_ERROR,
3151            ))
3152        })?;
3153        roots.insert(
3154            platform::root_identity(&workspace.canonical_root),
3155            workspace.workspace_id.clone(),
3156        );
3157        workspaces.insert(workspace_id, workspace);
3158    }
3159
3160    for record in &mut loaded.records {
3161        match workspaces.get(&record.workspace_id) {
3162            Some(workspace)
3163                if platform::roots_equal(
3164                    &workspace.canonical_root,
3165                    windows_path_text(&record.canonical_root),
3166                ) => {}
3167            Some(_) => {
3168                return Err(NodeServerError::DurableState(io::Error::new(
3169                    io::ErrorKind::InvalidData,
3170                    DURABLE_STATE_CONFLICT_ERROR,
3171                )));
3172            }
3173            None => {
3174                record.state = ManagedSessionState::Unavailable;
3175                record.active_session = None;
3176                record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
3177            }
3178        }
3179    }
3180    Ok((workspaces.into_values().collect(), loaded.records, warning))
3181}
3182
3183/// One entry of `NodeShared::rejected_command_reasons` -- see that field's
3184/// own doc for the mechanism this exists to close.
3185struct RejectedCommandReason {
3186    message: String,
3187    /// True when the kernel refused this command specifically because the
3188    /// agent does not declare the requested transport (`KernelCommandError::
3189    /// UnsupportedTransport`) -- lets the reader choose the named
3190    /// `NodeFailureCode::UnsupportedTransport` over the generic
3191    /// `BackendOperationFailed` bucket, so this one well-known rejection
3192    /// keeps its identity all the way to the operator wire.
3193    unsupported_transport: bool,
3194    expires_at: Instant,
3195}
3196
3197struct NodeShared {
3198    handle: Gate4AgentHandle,
3199    access_token: String,
3200    node_id: NodeId,
3201    incarnation_id: NodeIncarnationId,
3202    started_at_unix_ms: u64,
3203    workspaces: RwLock<BTreeMap<WorkspaceId, String>>,
3204    worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode>,
3205    managed_worktree_profiles:
3206        BTreeMap<WorkspaceId, BTreeMap<WorktreeProfileId, ManagedWorktreeProfile>>,
3207    managed_worktrees: Mutex<ManagedWorktreeRegistry>,
3208    enabled_providers: Vec<AgentId>,
3209    provider_runtime_statuses: ProviderRuntimeStatuses,
3210    provider_runtime_status_updates:
3211        RwLock<BTreeMap<AgentId, crate::protocol::ProviderRuntimeStatus>>,
3212    provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
3213    provider_contracts: Vec<ProviderContractSupport>,
3214    provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
3215    spawn_profiles: SpawnProfileRegistry,
3216    environment_profiles:
3217        RwLock<BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>>,
3218    environment_materialization_profiles:
3219        RwLock<BTreeMap<SpawnEnvironmentProfileId, NodeSessionMaterializationProfile>>,
3220    /// Station **network** allowlist catalog (opaque policy ids only).
3221    /// Empty by default — any `network_allowlist` on spawn must be registered
3222    /// here or resolve refuses with `UnknownNetworkAllowlist`. Optional
3223    /// startup load via `NodeServerConfig::with_network_allowlist_catalog(_file)`
3224    /// / env `GATE4AGENT_NODE_NETWORK_ALLOWLIST_CATALOG`. Plan
3225    /// `station-network-and-browser-profile-knobs-2026-10-02.md` §2.1 / §4.
3226    /// Never holds cookies / OAuth / proxy credentials.
3227    network_allowlist_catalog: RwLock<crate::network_allowlist_catalog::NetworkAllowlistCatalog>,
3228    /// Node-local exclusive dig2browser station profile leases (feature
3229    /// `dig2-station-probe`). Opaque `browser_profile_id` ↔ session holder;
3230    /// cleared on session end. Never ImportSession / cookies on C2.
3231    #[cfg(feature = "dig2-station-probe")]
3232    browser_station_leases: crate::dig2_station_lease::BrowserStationLeaseTable,
3233    bundle_catalog: RwLock<BundleCatalog>,
3234    delivery_store: Mutex<Option<DeliveryStore>>,
3235    context_pack_store: Mutex<Option<ContextPackStore>>,
3236    context_catalog: RwLock<ContextPackCatalog>,
3237    native_launch_profile_control: Option<NativeLaunchProfileControl>,
3238    harness_mcp_registry: Option<HarnessMcpProxyRegistry>,
3239    native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
3240    session_environment_materializer: Option<SessionEnvironmentMaterializer>,
3241    /// Per-session m4a accounts assigned at spawn (see [`crate::m4a_accounts`]).
3242    m4a_accounts: Option<crate::m4a_accounts::M4aSessionAccounts>,
3243    /// nick -> the session that holds that m4a account (for letter wakes).
3244    m4a_live: Mutex<BTreeMap<String, (AgentInstanceId, AgentId)>>,
3245    /// m4a session key per runtime this node gave an account.
3246    m4a_keys: Mutex<BTreeMap<AgentInstanceId, String>>,
3247    /// Keys a resume hands to its new runtime (same nick and mailbox).
3248    m4a_reuse_keys: Mutex<BTreeMap<AgentInstanceId, String>>,
3249    /// Per-session grok leader sockets (never the operator's shared leader).
3250    grok_leaders: Mutex<BTreeMap<AgentInstanceId, std::path::PathBuf>>,
3251    /// m4a signer proxy (option A): when set, PTY sessions get a process-bound handle
3252    /// instead of a bearer capability.
3253    #[cfg(unix)]
3254    signer_proxy: std::sync::OnceLock<Arc<crate::keystore_proxy::SignerProxy>>,
3255    materializations: Mutex<BTreeMap<MaterializationId, MaterializationOwnershipRecord>>,
3256    spawn_idempotency: Mutex<SpawnIdempotencyCache>,
3257    managed_spawn_replays:
3258        Mutex<BTreeMap<SpawnIdempotencyKey, ManagedWorktreeSpawnReplayRecordV10>>,
3259    controller: Mutex<Option<ControllerLease>>,
3260    /// Fresh spawns waiting for their first successful HQ attach. Removed on
3261    /// Resize/TerminalBytes (sessionize) or when the grace reaper stops them.
3262    provisional_spawns: Mutex<BTreeMap<AgentInstanceId, (SessionAddress, Instant)>>,
3263    /// ACP launch options waiting for the agent to advertise its config options.
3264    pending_acp_options: Mutex<BTreeMap<AgentInstanceId, (Option<String>, Option<String>)>>,
3265    history: Mutex<NodeEventHistory>,
3266    event_tx: broadcast::Sender<NodeEventEnvelope>,
3267    terminal_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3268    /// The outbound `NodeEvent::AgentStream` push channel -- its own
3269    /// dedicated, capability-gated broadcast, mirroring `terminal_event_tx`
3270    /// exactly rather than riding the durable `event_tx` history (see
3271    /// `publish`'s own assertion): a subscriber who never negotiated
3272    /// `NODE_AGENT_STREAM_EVENTS_CAPABILITY` must never receive one of
3273    /// these, the same guarantee `include_terminal_frame_events` already
3274    /// gives terminal frames.
3275    agent_stream_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3276    /// The outbound `NodeEvent::HarnessMcpReadCall` push channel -- mirrors
3277    /// `agent_stream_event_tx` exactly, its own dedicated, capability-gated
3278    /// broadcast rather than a ride on the durable `event_tx` history. A
3279    /// `HarnessMcpReadCall` is a request/response call a live proxy call is
3280    /// waiting on (`harness_mcp_proxy.rs`'s `begin_call`); the shared bus's
3281    /// finite capacity and its willingness to silently evict a lagging
3282    /// subscriber's backlog is exactly how the call vanished with neither
3283    /// side ever logging it (see `publish_transient`'s own doc).
3284    harness_mcp_proxy_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3285    terminal_frame_watermarks:
3286        Mutex<BTreeMap<AgentInstanceId, (SessionAddress, u64)>>,
3287    /// Wakes the drive loop as soon as PTY input is queued (no 10ms nap).
3288    pty_input_wake: tokio::sync::Notify,
3289    /// Per session: when the last PTY input was dispatched (latency probe +
3290    /// a short fast-poll window so the echo is published within ~1ms).
3291    pty_input_marks: Mutex<BTreeMap<AgentInstanceId, (Instant, bool)>>,
3292    next_connection_id: AtomicU64,
3293    next_instance_id: AtomicU64,
3294    next_command_id: AtomicU64,
3295    shutdown: AtomicBool,
3296    shutdown_notify: Notify,
3297    preauth_slots: Arc<Semaphore>,
3298    authenticated_slots: Arc<Semaphore>,
3299    inspection_slots: Arc<Semaphore>,
3300    mutation_gate: AsyncMutex<()>,
3301    session_bindings: Mutex<BTreeMap<AgentInstanceId, SessionBinding>>,
3302    session_records: Mutex<SessionRecords>,
3303    state_path: Option<PathBuf>,
3304    persistence_error: RwLock<Option<String>>,
3305    state_transaction: Mutex<()>,
3306    input_settle_timeout_ms: u64,
3307    /// Drive-loop phase/rate timings -- see `drive_loop_profile`'s own doc
3308    /// comment. Written once per iteration by
3309    /// `drive_runtime_until_shutdown`, read by `GET /metrics`.
3310    drive_loop_profile: Mutex<DriveLoopProfiler>,
3311    /// Latest copy of `NativeRuntime::tick_profile_snapshot()` -- `Copy`,
3312    /// so an overwrite each iteration is cheap. The runtime itself is not
3313    /// reachable from here (it is owned locally by
3314    /// `drive_runtime_until_shutdown`), so this is how its own per-phase
3315    /// tick timings reach `GET /metrics`.
3316    runtime_tick_profile: Mutex<TickProfileSnapshot>,
3317    /// The live shell-efficiency profile, shared with the per-instance
3318    /// worker loops that fold into it.
3319    ///
3320    /// Deliberately NOT the per-iteration snapshot copy `runtime_tick_
3321    /// profile` above keeps: producing a snapshot sorts every ring behind
3322    /// it, and that sort belongs on the read. A `OnceLock` rather than a
3323    /// field set at construction because `NodeShared` is built before the
3324    /// `NativeRuntime` that owns the profile exists -- the drive loop
3325    /// publishes it once, before its first iteration. Unset means the
3326    /// runtime has not started driving yet, which `GET /metrics` reports as
3327    /// empty series rather than as zeroes it never measured.
3328    shell_efficiency_profile: OnceLock<Arc<Mutex<ShellEfficiencyProfile>>>,
3329    /// Physical PTY sessions the native runtime currently owns
3330    /// (`NativeRuntime::active_native_sessions`), refreshed once per drive
3331    /// loop iteration -- deliberately a separate count from
3332    /// `handle.snapshot().sessions.len()` (the control-plane kernel's own
3333    /// session bookkeeping), since the two can disagree and the gap is
3334    /// itself a diagnostic signal `GET /metrics` exposes.
3335    native_session_gauge: AtomicUsize,
3336    /// Lifetime totals for the per-connection serve loop, summed across
3337    /// every live connection.
3338    ///
3339    /// The drive loop's own phase timings account for about ten
3340    /// microseconds an iteration on an idle node, yet the process burns a
3341    /// quarter of a core — and burns it ONLY while a c2 is attached (0.5%
3342    /// with none, 23% with two, measured). That gap is somewhere in
3343    /// serving a connection, and there was no way to see it: iterations
3344    /// says whether the loop is spinning rather than sleeping, and events
3345    /// says whether it is spinning over real work or over nothing.
3346    connection_loop_iterations: AtomicU64,
3347    connection_events_sent: AtomicU64,
3348    /// Lifetime totals for `drive_runtime_until_shutdown`'s own iterations
3349    /// -- `drive_loop_iterations_idle` counts iterations that applied zero
3350    /// `NativeRuntime::tick` observations AND forwarded zero control
3351    /// events; see the exact "produced nothing" definition at the site in
3352    /// `drive_runtime_until_shutdown` that increments these. Read twice
3353    /// and divide by the interval, same rule as `connections` above --
3354    /// `idle / total` is backlog item 5's "wake on work" number.
3355    /// Lifetime count of control commands the kernel refused.
3356    rejected_commands_total: AtomicU64,
3357    /// The named reason behind a recent kernel rejection, by the
3358    /// `CommandId` `dispatch_bounded` handed back when it enqueued that
3359    /// command -- populated by `drive_runtime_until_shutdown` for every
3360    /// `Err` in a tick's `command_outcomes`, and drained by whichever
3361    /// spawn-dispatch waiter recognizes its own `Register`/`Start`
3362    /// `CommandId` among them.
3363    ///
3364    /// A `ControlCommand` only ever fails synchronously (a `NodeFailure`
3365    /// out of `dispatch`/`dispatch_bounded` itself) for a port-level ingress
3366    /// problem -- full or disconnected -- never for the kernel refusing the
3367    /// command's content (an unsupported transport, an unknown agent, ...);
3368    /// that verdict exists only once `NativeRuntime::tick` has processed the
3369    /// command, one drive-loop iteration later, in a different task than
3370    /// whichever caller is awaiting the spawn. Without this map, a spawn
3371    /// wait loop that polls `self.handle.snapshot()` for a session that
3372    /// will now never appear has no way to learn why -- it can only spin
3373    /// until its own commit deadline and report a generic "did not commit"
3374    /// timeout, discarding a reason the kernel already named precisely (see
3375    /// `KernelCommandError`'s variants, e.g. `UnsupportedTransport`).
3376    rejected_command_reasons: Mutex<BTreeMap<CommandId, RejectedCommandReason>>,
3377    drive_loop_iterations_total: AtomicU64,
3378    drive_loop_iterations_idle: AtomicU64,
3379    /// The operator-chosen age/keep-N policy the retention sweep applies to
3380    /// `ManagedSessionState::Unavailable` records. Both `0` by default --
3381    /// see `SessionRecordRetentionConfig`'s own doc comment.
3382    session_record_retention: SessionRecordRetentionConfig,
3383    /// Lifetime count of managed session records the retention sweep has
3384    /// retired. Same tier as `rejected_commands_total` above: a live,
3385    /// process-lifetime instrument, never persisted and never reloaded from
3386    /// `state-v1.json` -- a restart legitimately resets it, matching every
3387    /// other `*_total` counter here.
3388    retired_records_total: AtomicU64,
3389    #[cfg(feature = "fixture")]
3390    fixture_semantic_hook_policy: bool,
3391    #[cfg(feature = "fixture")]
3392    fixture_spawn_managed_worktree_v2_failure_probe: SpawnManagedWorktreeV2FailureProbe,
3393}
3394
3395impl Drop for NodeShared {
3396    fn drop(&mut self) {
3397        SessionLifecycleHook::on_shutdown(self);
3398    }
3399}
3400
3401#[derive(Clone, Debug, Eq, PartialEq)]
3402struct SessionBinding {
3403    workspace_id: WorkspaceId,
3404    generation: SessionGeneration,
3405    runtime_policy: ProviderRuntimePolicy,
3406    pending_resume: Option<(SessionGeneration, CommandId, ProviderRuntimePolicy)>,
3407    record_id: Option<SessionRecordId>,
3408    managed_worktree_lease_id: Option<ManagedWorktreeLeaseId>,
3409    environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
3410    bundle: Option<ResolvedBundleReceipt>,
3411    context: Option<ResolvedContextPackReceipt>,
3412    materialization_id: Option<MaterializationId>,
3413}
3414
3415#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3416enum SpawnRecordPolicy {
3417    ProviderIdentityOnly,
3418    Always,
3419}
3420
3421struct NativeEnvironmentSelectionGuard {
3422    control: Option<NativeLaunchProfileControl>,
3423    instance_id: AgentInstanceId,
3424}
3425
3426struct NativeInstanceOverlayGuard {
3427    control: Option<NativeLaunchProfileControl>,
3428    instance_id: AgentInstanceId,
3429}
3430
3431struct NativeHarnessMcpOverlayGuard {
3432    control: NativeLaunchProfileControl,
3433    instance_id: AgentInstanceId,
3434}
3435
3436impl NativeHarnessMcpOverlayGuard {
3437    fn retain(self) { std::mem::forget(self); }
3438}
3439
3440impl Drop for NativeHarnessMcpOverlayGuard {
3441    fn drop(&mut self) {
3442        self.control
3443            .clear_native_mcp_server_launch_overlay(self.instance_id);
3444    }
3445}
3446
3447impl NativeInstanceOverlayGuard {
3448    fn retain(mut self) {
3449        self.control = None;
3450    }
3451}
3452
3453impl Drop for NativeInstanceOverlayGuard {
3454    fn drop(&mut self) {
3455        if let Some(control) = self.control.as_ref() {
3456            control.clear_native_instance_launch_overlay(self.instance_id);
3457        }
3458    }
3459}
3460
3461enum PreparedNativeLaunchOverlay {
3462    Environment(NativeLaunchEnvironmentOverlay),
3463    Instance(NativeInstanceLaunchOverlay),
3464}
3465
3466impl NativeEnvironmentSelectionGuard {
3467    fn retain(mut self) {
3468        self.control = None;
3469    }
3470}
3471
3472impl Drop for NativeEnvironmentSelectionGuard {
3473    fn drop(&mut self) {
3474        if let Some(control) = self.control.as_ref() {
3475            control.clear_native_launch_profile_selection(self.instance_id);
3476        }
3477    }
3478}
3479
3480struct SessionMaterializationGuard<'a> {
3481    shared: &'a NodeShared,
3482    id: Option<MaterializationId>,
3483    /// Absolute ProviderHome (`home/`) under the session materialization root.
3484    /// Used for Claude station network `--settings` sidecar writes.
3485    provider_home: Option<PathBuf>,
3486}
3487
3488impl SessionMaterializationGuard<'_> {
3489    fn id(&self) -> Option<&MaterializationId> {
3490        self.id.as_ref()
3491    }
3492
3493    fn provider_home(&self) -> Option<&Path> {
3494        self.provider_home.as_deref()
3495    }
3496
3497    fn retain(mut self) {
3498        self.id = None;
3499    }
3500}
3501
3502impl Drop for SessionMaterializationGuard<'_> {
3503    fn drop(&mut self) {
3504        if let Some(id) = self.id.take() {
3505            let _ = self.shared.cleanup_materialization(&id);
3506        }
3507    }
3508}
3509
3510
3511/// Port: product-side reactions to session lifecycle transitions
3512/// (station lease, harness MCP overlay/registry, environment launch overlays).
3513trait SessionLifecycleHook {
3514    /// A binding was removed (unbind or spawn rollback).
3515    fn on_binding_removed(&self, address: &SessionAddress, removed: Option<&SessionBinding>);
3516    /// A session reached a terminal control event that revokes its harness MCP door.
3517    fn on_session_ended(&self, address: &SessionAddress);
3518    /// Node shutdown (or drop): revoke every harness MCP reservation.
3519    fn on_shutdown(&self);
3520    /// A harness MCP reservation was aborted before activation.
3521    fn on_harness_reservation_aborted(&self, instance_id: AgentInstanceId);
3522    /// Release session-owned materialization for a removed binding.
3523    fn cleanup_session_materialization(
3524        &self,
3525        address: &SessionAddress,
3526        binding: &SessionBinding,
3527    ) -> Result<(), NodeFailure>;
3528}
3529
3530impl SessionLifecycleHook for NodeShared {
3531    fn on_session_ended(&self, address: &SessionAddress) {
3532        self.retire_session_grok_leader(address.session.instance_id);
3533        if !self.shutdown.load(Ordering::Acquire) {
3534            if let Some(file) = self.session_resume_journal() {
3535                crate::pty_resume::remove(&file, address.session.instance_id.0);
3536            }
3537        }
3538        if let Some(accounts) = self.m4a_accounts.as_ref() {
3539            let instance = address.session.instance_id;
3540            let live = self.m4a_live.lock().unwrap_or_else(|p| p.into_inner());
3541            for (nick, _) in live.iter().filter(|(_, (i, _))| *i == instance) {
3542                accounts.remove_session_file(nick);
3543            }
3544        }
3545        if let Some(registry) = self.harness_mcp_registry.as_ref() {
3546            registry.revoke_session(address);
3547        }
3548        if let Some(control) = self.native_launch_profile_control.as_ref() {
3549            control.clear_native_mcp_server_launch_overlay(address.session.instance_id);
3550        }
3551    }
3552
3553    fn on_shutdown(&self) {
3554        let leaders: Vec<_> = std::mem::take(&mut *self.grok_leaders.lock().unwrap_or_else(|p| p.into_inner()))
3555            .into_values()
3556            .collect();
3557        for socket in leaders {
3558            crate::grok_leader::retire(&socket);
3559        }
3560        if let Some(registry) = self.harness_mcp_registry.as_ref() {
3561            let instances = registry.shutdown();
3562            if let Some(control) = self.native_launch_profile_control.as_ref() {
3563                for instance_id in instances {
3564                    control.clear_native_mcp_server_launch_overlay(instance_id);
3565                }
3566            }
3567        }
3568    }
3569
3570    fn on_harness_reservation_aborted(&self, instance_id: AgentInstanceId) {
3571        if let Some(control) = self.native_launch_profile_control.as_ref() {
3572            control.clear_native_mcp_server_launch_overlay(instance_id);
3573        }
3574    }
3575
3576    fn cleanup_session_materialization(
3577        &self,
3578        address: &SessionAddress,
3579        binding: &SessionBinding,
3580    ) -> Result<(), NodeFailure> {
3581        self.cleanup_session_owned_materialization(address, binding)
3582    }
3583
3584    fn on_binding_removed(&self, address: &SessionAddress, removed: Option<&SessionBinding>) {
3585        // Exclusive dig2browser station profile lease is node-local; clear
3586        // on every session unbind / rollback. Never ImportSession/cookies.
3587        #[cfg(feature = "dig2-station-probe")]
3588        {
3589            crate::dig2_station_lease::release_lease_for_holder(
3590                &self.browser_station_leases,
3591                address.session.instance_id,
3592            );
3593        }
3594        if let Some(control) = self.native_launch_profile_control.as_ref() {
3595            control.clear_native_mcp_server_launch_overlay(
3596                address.session.instance_id,
3597            );
3598        }
3599        if let Some(registry) = self.harness_mcp_registry.as_ref() {
3600            registry.revoke_session(address);
3601        }
3602        if removed
3603            .is_some_and(|binding| {
3604                binding.environment_profile.is_some()
3605                    || binding.bundle.is_some()
3606                    || binding.context.is_some()
3607            })
3608        {
3609            if let Some(control) = self.native_launch_profile_control.as_ref() {
3610                control.clear_native_launch_profile_selection(
3611                    address.session.instance_id,
3612                );
3613                control.clear_native_instance_launch_overlay(
3614                    address.session.instance_id,
3615                );
3616            }
3617        }
3618    }
3619}
3620
3621struct SpawnPrepareRequest<'r> {
3622    instance_id: AgentInstanceId,
3623    address: &'r SessionAddress,
3624    provider: &'r AgentId,
3625    mode: SessionMode,
3626    environment_profile: Option<&'r ResolvedEnvironmentProfileReceipt>,
3627    bundle: Option<&'r ResolvedBundleReceipt>,
3628    context: Option<&'r ResolvedContextPackReceipt>,
3629    managed_authority: Option<&'r ManagedWorktreeLeaseId>,
3630    harness_mcp: Option<&'r PreparedHarnessMcpSpawn>,
3631    approval_level: ApprovalLevel,
3632    network_allowlist: Option<&'r crate::protocol::SpawnNetworkAllowlistId>,
3633    browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
3634}
3635
3636/// Guards held between "session identity minted" and "launch committed".
3637/// Field order is the drop order and matches the pre-refactor local order
3638/// (instance overlay, environment selection, materialization, harness, station lease).
3639struct PreparedSpawn<'a> {
3640    instance_overlay: Option<NativeInstanceOverlayGuard>,
3641    environment_selection: Option<NativeEnvironmentSelectionGuard>,
3642    materialization_guard: Option<SessionMaterializationGuard<'a>>,
3643    harness_mcp_overlay: Option<NativeHarnessMcpOverlayGuard>,
3644    #[cfg(feature = "dig2-station-probe")]
3645    browser_station_lease_guard: Option<crate::dig2_station_lease::BrowserStationLeaseGuard>,
3646    materialization_id: Option<MaterializationId>,
3647}
3648
3649impl PreparedSpawn<'_> {
3650    /// Session-owned launch state now belongs to the binding.
3651    fn retain_session_guards(&mut self) {
3652        if let Some(selection) = self.environment_selection.take() {
3653            selection.retain();
3654        }
3655        if let Some(overlay) = self.instance_overlay.take() {
3656            overlay.retain();
3657        }
3658        if let Some(guard) = self.materialization_guard.take() {
3659            guard.retain();
3660        }
3661    }
3662
3663    /// Launch committed: keep harness overlay and station lease for the session lifetime.
3664    fn retain_launch_guards(&mut self) {
3665        if let Some(overlay) = self.harness_mcp_overlay.take() {
3666            overlay.retain();
3667        }
3668        #[cfg(feature = "dig2-station-probe")]
3669        if let Some(guard) = self.browser_station_lease_guard.take() {
3670            guard.retain();
3671        }
3672    }
3673}
3674
3675/// Port: everything product-side that must be prepared before a session is
3676/// bound and started (station lease, harness MCP overlay, materialization,
3677/// environment selection, network overlays, launch overlay).
3678trait SpawnPreparer {
3679    fn prepare<'a>(&'a self, request: SpawnPrepareRequest<'_>) -> Result<PreparedSpawn<'a>, NodeFailure>;
3680    /// Select the native launch profile for an environment-profile session.
3681    fn select_environment(
3682        &self,
3683        instance_id: AgentInstanceId,
3684        provider: &AgentId,
3685        mode: SessionMode,
3686        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
3687    ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure>;
3688    /// Install a prepared launch overlay for the instance.
3689    fn install_launch_overlay(
3690        &self,
3691        instance_id: AgentInstanceId,
3692        overlay: PreparedNativeLaunchOverlay,
3693    ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure>;
3694}
3695
3696impl NodeShared {
3697    /// m4a session key of a new runtime: a resume reuses the key (and so the nick and mailbox)
3698    /// of the runtime it replaces; a fresh spawn gets `<incarnation>-<instance>`.
3699    fn m4a_session_key(&self, instance_id: AgentInstanceId) -> String {
3700        let reused = self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).remove(&instance_id);
3701        let key = reused.unwrap_or_else(|| format!("{}-{}", self.incarnation_id, instance_id.0));
3702        self.m4a_keys.lock().unwrap_or_else(|p| p.into_inner()).insert(instance_id, key.clone());
3703        if let Some(file) = self.session_resume_journal() {
3704            crate::pty_resume::set_session_key(&file, instance_id.0, &key);
3705        }
3706        key
3707    }
3708
3709    fn session_workdir(&self, root: &std::path::Path, instance_id: AgentInstanceId, provider: &AgentId) -> Option<std::path::PathBuf> {
3710        let accounts = self.m4a_accounts.as_ref()?;
3711        let nick = self.m4a_nick_of(instance_id)?;
3712        match accounts.session_workdir(root, provider.as_str(), &nick) {
3713            Ok(dir) => dir,
3714            Err(error) => {
3715                tracing::warn!(%nick, %error, "session folder not prepared; the session runs in the workspace root");
3716                None
3717            }
3718        }
3719    }
3720
3721    fn m4a_nick_of(&self, instance_id: AgentInstanceId) -> Option<String> {
3722        self.m4a_live.lock().unwrap_or_else(|p| p.into_inner()).iter().find(|(_, (i, _))| *i == instance_id).map(|(n, _)| n.clone())
3723    }
3724
3725    /// The managed record of a node session carries its m4a nick as its name (HQ shows it,
3726    /// `/api/spawn` returns it).
3727    fn name_record_after_nick(&self, instance_id: AgentInstanceId, nick: &str) {
3728        let mut records = self.session_records.lock().unwrap_or_else(|p| p.into_inner());
3729        for record in records.records.values_mut() {
3730            if record.active_session.as_ref().is_some_and(|a| a.session.instance_id == instance_id) {
3731                record.display_name = nick.to_owned();
3732            }
3733        }
3734    }
3735
3736    /// Gives a node session its own m4a account: identity env, session file, MCP entry and
3737    /// the mailbox listener that wakes it.
3738    fn m4a_session_overlay(
3739        &self,
3740        accounts: &crate::m4a_accounts::M4aSessionAccounts,
3741        m4a_provider: &str,
3742        provider: &AgentId,
3743        instance_id: AgentInstanceId,
3744        environment_overlay: Option<PreparedNativeLaunchOverlay>,
3745        session_key: &str,
3746        mode: SessionMode,
3747    ) -> Result<Option<PreparedNativeLaunchOverlay>, NodeFailure> {
3748        let session_key = session_key.to_owned();
3749        if mode == SessionMode::Acp {
3750            return self.m4a_acp_mcp_overlay(accounts, m4a_provider, provider, instance_id, environment_overlay, &session_key);
3751        }
3752        Ok(                match accounts.provision(m4a_provider, &session_key) {
3753                    Ok(account) => {
3754                        crate::pty_resume::note_account_ref(&session_key, &account.session_id);
3755                        let mut identity = account.env_mutations();
3756                        identity.extend(crate::m4a_accounts::provider_session_env(provider.as_str()));
3757                        let merged = match environment_overlay {
3758                            None => NativeInstanceLaunchOverlay::new_session_identity(provider.clone(), identity)
3759                                .map(PreparedNativeLaunchOverlay::Instance),
3760                            Some(PreparedNativeLaunchOverlay::Instance(mut overlay)) => {
3761                                overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
3762                            }
3763                            Some(PreparedNativeLaunchOverlay::Environment(mut overlay)) => {
3764                                overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Environment(overlay))
3765                            }
3766                        }
3767                        .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a session account overlay is invalid"))?;
3768                        tracing::info!(provider = %provider, instance_id = ?instance_id, nick = %account.nick, "m4a session account assigned at spawn");
3769                        let mut merged = merged;
3770                        match accounts.write_session_file(&account) {
3771                            Err(error) => tracing::warn!(nick = %account.nick, %error, "m4a session file not written"),
3772                            Ok(_) => match accounts.mcp_launch_args(provider.as_str(), &account.nick) {
3773                                Ok(args) if !args.is_empty() => {
3774                                    merged = match merged {
3775                                        PreparedNativeLaunchOverlay::Instance(mut overlay) => overlay
3776                                            .append_extra_args(args)
3777                                            .map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
3778                                            .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a mcp overlay is invalid"))?,
3779                                        PreparedNativeLaunchOverlay::Environment(overlay) => overlay
3780                                            .into_instance_with_extra_args(args)
3781                                            .map(PreparedNativeLaunchOverlay::Instance)
3782                                            .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a mcp overlay is invalid"))?,
3783                                    };
3784                                    tracing::info!(nick = %account.nick, "m4a mcp server delivered at spawn");
3785                                }
3786                                Ok(_) => {}
3787                                Err(error) => tracing::warn!(nick = %account.nick, %error, "m4a mcp server not delivered"),
3788                            },
3789                        }
3790                        self.m4a_live
3791                            .lock()
3792                            .unwrap_or_else(|p| p.into_inner())
3793                            .insert(account.nick.clone(), (instance_id, provider.clone()));
3794                        self.name_record_after_nick(instance_id, &account.nick);
3795                        tracing::info!(nick = %account.nick, instance = instance_id.0, "m4a nick bound to session");
3796                        // Letters to this account wake the session: the node listens on its mailbox.
3797                        crate::m4a_wake::watch_spawned(provider.as_str(), &account.nick, &account.session_id, account.store_root.as_deref(), None);
3798                        Some(merged)
3799                    }
3800                    Err(error) => {
3801                        tracing::warn!(provider = %provider, instance_id = ?instance_id, %error, "m4a session account not assigned");
3802                        environment_overlay
3803                    }
3804                })
3805    }
3806}
3807
3808impl NodeShared {
3809    /// ACP has no env/argv overlay for this: the session's m4a account rides as an MCP server
3810    /// handed to the agent in session/new, with the account env on that server only.
3811    fn m4a_acp_mcp_overlay(
3812        &self,
3813        accounts: &crate::m4a_accounts::M4aSessionAccounts,
3814        m4a_provider: &str,
3815        provider: &AgentId,
3816        instance_id: AgentInstanceId,
3817        environment_overlay: Option<PreparedNativeLaunchOverlay>,
3818        session_key: &str,
3819    ) -> Result<Option<PreparedNativeLaunchOverlay>, NodeFailure> {
3820        let Some(control) = self.native_launch_profile_control.as_ref() else {
3821            tracing::warn!(%provider, "m4a acp account not assigned: no launch profile control");
3822            return Ok(environment_overlay);
3823        };
3824        let account = match accounts.provision(m4a_provider, session_key) {
3825            Ok(a) => a,
3826            Err(error) => {
3827                tracing::warn!(%provider, instance_id = ?instance_id, %error, "m4a session account not assigned");
3828                return Ok(environment_overlay);
3829            }
3830        };
3831        let session_file = match accounts.write_session_file(&account) {
3832            Ok(p) => p,
3833            Err(error) => {
3834                tracing::warn!(nick = %account.nick, %error, "m4a session file not written");
3835                return Ok(environment_overlay);
3836            }
3837        };
3838        let env_entries: Vec<(OsString, OsString)> = account
3839            .env_mutations()
3840            .into_iter()
3841            .chain(crate::m4a_accounts::provider_session_env(provider.as_str()))
3842            .filter_map(|m| m.value.map(|v| (m.key, v)))
3843            .collect();
3844        let args: Vec<OsString> = vec![
3845            "--session-file".into(),
3846            session_file.into_os_string(),
3847            "mcp".into(),
3848            "--provider".into(),
3849            provider.as_str().into(),
3850        ];
3851        let overlay = NativeMcpServerLaunchOverlay::new(
3852            provider.clone(),
3853            crate::m4a_accounts::mcp_server_name(provider.as_str()).as_str(),
3854            accounts.bin().as_os_str().to_os_string(),
3855            args,
3856            env_entries,
3857            Vec::new(),
3858        );
3859        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())) {
3860            Ok(_) => tracing::info!(nick = %account.nick, instance = instance_id.0, "m4a mcp server delivered over acp"),
3861            Err(error) => {
3862                tracing::warn!(nick = %account.nick, %error, "m4a acp mcp server not delivered");
3863                return Ok(environment_overlay);
3864            }
3865        }
3866        self.m4a_live
3867            .lock()
3868            .unwrap_or_else(|p| p.into_inner())
3869            .insert(account.nick.clone(), (instance_id, provider.clone()));
3870        self.name_record_after_nick(instance_id, &account.nick);
3871        crate::m4a_wake::watch_spawned(provider.as_str(), &account.nick, &account.session_id, account.store_root.as_deref(), None);
3872        Ok(environment_overlay)
3873    }
3874}
3875
3876impl SpawnPreparer for NodeShared {
3877    fn select_environment(
3878        &self,
3879        instance_id: AgentInstanceId,
3880        provider: &AgentId,
3881        mode: SessionMode,
3882        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
3883    ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
3884        self.select_environment_profile(instance_id, provider, mode, environment_profile)
3885    }
3886
3887    fn install_launch_overlay(
3888        &self,
3889        instance_id: AgentInstanceId,
3890        overlay: PreparedNativeLaunchOverlay,
3891    ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
3892        self.install_prepared_launch_overlay(instance_id, overlay)
3893    }
3894
3895    fn prepare<'a>(&'a self, request: SpawnPrepareRequest<'_>) -> Result<PreparedSpawn<'a>, NodeFailure> {
3896        let SpawnPrepareRequest {
3897            instance_id,
3898            address,
3899            provider,
3900            mode,
3901            environment_profile,
3902            bundle,
3903            context,
3904            managed_authority,
3905            harness_mcp,
3906            approval_level,
3907            network_allowlist,
3908            browser_profile_id,
3909        } = request;
3910        // Track A follow-on: exclusive node-local BrowserStationLease after
3911        // dig2 probe (feature dig2-station-probe). Opaque profile id only —
3912        // never ImportSession / cookies on C2. Linux/feature-off: probe
3913        // already refused or stub; this block is cfg'd out when feature off.
3914        #[cfg(feature = "dig2-station-probe")]
3915        let mut browser_station_lease_guard = if let Some(profile_id) = browser_profile_id {
3916            use crate::dig2_station_lease::{acquire_lease, BrowserStationLeaseError};
3917            match acquire_lease(
3918                &self.browser_station_leases,
3919                profile_id,
3920                instance_id,
3921                unix_time_ms(),
3922            ) {
3923                Ok(guard) => Some(guard),
3924                Err(BrowserStationLeaseError::ProfileBusy { .. }) => {
3925                    return Err(failure(
3926                        NodeFailureCode::BrowserStationProfileBusy,
3927                        "dig2browser station profile is already leased by another session",
3928                    ));
3929                }
3930                Err(BrowserStationLeaseError::CapacityExceeded) => {
3931                    return Err(failure(
3932                        NodeFailureCode::BrowserStationProfileBusy,
3933                        "dig2browser station profile lease capacity exhausted",
3934                    ));
3935                }
3936                Err(BrowserStationLeaseError::HolderAlreadyLeased) => {
3937                    return Err(failure(
3938                        NodeFailureCode::BrowserStationProfileBusy,
3939                        "session already holds a dig2browser station profile lease",
3940                    ));
3941                }
3942            }
3943        } else {
3944            None
3945        };
3946        #[cfg(not(feature = "dig2-station-probe"))]
3947        let _ = browser_profile_id;
3948        let mut harness_mcp_overlay = if let Some(prepared) = harness_mcp {
3949            // Slice A(ii) of gate4agent-arc-mailbox-and-task-layer: the door
3950            // exists for PTY (env vars) and ACP (`session/new.mcpServers`,
3951            // `gate4agent-shell-native`'s `mcp_server_acp_entry`) alike --
3952            // both read the identical overlay resolved below, one as child
3953            // environment, the other translated into one stdio MCP server
3954            // entry. `Inline`/`Pipe` stays refused: this arc's proof is ACP
3955            // only, and nothing downstream reads a harness-MCP overlay for
3956            // that transport yet.
3957            if !matches!(mode, SessionMode::Pty | SessionMode::Acp) || prepared.provider != *provider
3958            {
3959                return Err(failure(
3960                    NodeFailureCode::BindingMismatch,
3961                    "harness MCP reservation does not match the exact PTY/ACP provider",
3962                ));
3963            }
3964            prepared.verify_helper_program().map_err(harness_mcp_failure)?;
3965            let control = self.native_launch_profile_control.clone().ok_or_else(|| {
3966                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP launch control is unavailable")
3967            })?;
3968            // Every name below is the caller's, carried in the reservation's
3969            // launch description: which environment variables receive the
3970            // endpoint, token and helper program, what the MCP server is called,
3971            // which argv it gets and which stale variables are erased.
3972            let launch = prepared.launch();
3973            let mut env_entries = vec![
3974                (
3975                    OsString::from(launch.endpoint_env.as_str()),
3976                    prepared.endpoint().as_os_str().to_os_string(),
3977                ),
3978                (
3979                    OsString::from(launch.token_env.as_str()),
3980                    prepared.token().expose().into(),
3981                ),
3982                (
3983                    OsString::from(launch.program_env.as_str()),
3984                    prepared.helper_program().as_os_str().to_os_string(),
3985                ),
3986            ];
3987            if let Some(trace_entry) = harness_mcp_trace_env_entry(launch) {
3988                env_entries.push(trace_entry);
3989            }
3990            if let Some(door) = crate::door::door_listen_addr() {
3991                env_entries.push((
3992                    OsString::from("HATCHERY_NODE_DOOR"),
3993                    OsString::from(door.to_string()),
3994                ));
3995                // Same key shape as HQ browser/draft ids:
3996                // `pty-{incarnation}-{workspace}-{instance}` — never reuse a
3997                // slot across node boots when the instance counter resets.
3998                let session_key = format!(
3999                    "pty-{}-{}-{}",
4000                    self.incarnation_id,
4001                    address.workspace_id.as_str(),
4002                    address.session.instance_id.0
4003                );
4004                env_entries.push((
4005                    OsString::from("HATCHERY_LOCAL_SERVER_SESSION"),
4006                    OsString::from(session_key),
4007                ));
4008            }
4009            let overlay = NativeMcpServerLaunchOverlay::new(
4010                provider.clone(),
4011                launch.server_name.as_str(),
4012                prepared.helper_program().as_os_str().to_os_string(),
4013                launch.args.iter().map(OsString::from).collect(),
4014                env_entries,
4015                launch.scrub_env.iter().map(OsString::from).collect(),
4016            ).map_err(|_| failure(
4017                NodeFailureCode::HarnessMcpUnavailable,
4018                "harness MCP launch overlay is unavailable",
4019            ))?;
4020            control
4021                .install_native_mcp_server_launch_overlay(instance_id, overlay)
4022                .map_err(|_| failure(
4023                    NodeFailureCode::HarnessMcpUnavailable,
4024                    "harness MCP launch overlay could not be installed",
4025                ))?;
4026            // State-change line: the overlay is installed and will be read
4027            // back by the PTY child's environment or, for ACP,
4028            // `gate4agent-shell-native`'s `mcp_server_acp_entry` at spawn
4029            // time -- no secrets here, only which instance/provider/mode
4030            // now carries a harness-MCP door.
4031            tracing::info!(
4032                node_id = %self.node_id,
4033                instance_id = ?instance_id,
4034                provider = %provider,
4035                mode = ?mode,
4036                "harness MCP overlay installed for spawn",
4037            );
4038            Some(NativeHarnessMcpOverlayGuard { control, instance_id })
4039        } else {
4040            None
4041        };
4042        let prepared_materialization = self.prepare_session_materialization(
4043            &address,
4044            &provider,
4045            mode,
4046            environment_profile,
4047            bundle,
4048            context,
4049            managed_authority.cloned(),
4050        )?;
4051        let (environment_overlay, mut materialization_guard) = match prepared_materialization {
4052            Some((overlay, guard)) => (Some(overlay), Some(guard)),
4053            None => (None, None),
4054        };
4055        let materialization_id = materialization_guard
4056            .as_ref()
4057            .and_then(SessionMaterializationGuard::id)
4058            .cloned();
4059        let mut environment_selection = SpawnPreparer::select_environment(self, 
4060            instance_id,
4061            &provider,
4062            mode,
4063            environment_profile,
4064        )?;
4065        // Station network overlays: Codex `-c network_access` and/or Claude
4066        // ProviderHome `--settings` sidecar (permits → allowedDomains).
4067        let provider_home = materialization_guard
4068            .as_ref()
4069            .and_then(SessionMaterializationGuard::provider_home);
4070        let network_overlay_args = self.resolve_network_allowlist_overlay_args(
4071            &provider,
4072            mode,
4073            approval_level,
4074            network_allowlist,
4075            provider_home,
4076        )?;
4077        let mut network_overlay_args = network_overlay_args;
4078        // Auto-permit (default on): pre-trust cwd / onboarding in provider config + auto/yolo flags.
4079        {
4080            let cwd = self.workspace_root(&address.workspace_id).ok().map(PathBuf::from);
4081            let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
4082            if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") {
4083                homes.extend(std::env::split_paths(&extra));
4084            }
4085            if let Some(h) = provider_home { homes.push(h.to_path_buf()); }
4086            crate::auto_permit::prepare(provider.as_str(), &homes, cwd.as_deref());
4087            network_overlay_args.extend(crate::auto_permit::launch_args(provider.as_str(), mode));
4088        }
4089        if mode == SessionMode::Acp && crate::auto_permit::acp_auto_approve(provider.as_str()) {
4090            crate::auto_permit::register_acp(instance_id.0, provider.as_str());
4091        }
4092        // Session registry: journal the session so it is resumed after a node restart.
4093        if matches!(mode, SessionMode::Pty | SessionMode::Acp) && crate::pty_resume::enabled() && crate::pty_resume::supported(provider.as_str()) {
4094            if let Some(file) = self.session_resume_journal() {
4095                crate::pty_resume::upsert(&file, crate::pty_resume::Entry {
4096                    instance_id: instance_id.0,
4097                    provider: provider.as_str().to_owned(),
4098                    workspace_id: address.workspace_id.as_str().to_owned(),
4099                    provider_session_id: String::new(),
4100                    session_key: self.m4a_keys.lock().unwrap_or_else(|p| p.into_inner()).get(&instance_id).cloned()
4101                        .or_else(|| self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).get(&instance_id).cloned())
4102                        .unwrap_or_else(|| format!("{}-{}", self.incarnation_id, instance_id.0)),
4103                    cwd: String::new(),
4104                    acp: mode == SessionMode::Acp,
4105                    options: SPAWN_SESSION_OPTIONS.try_with(|o| o.clone()).ok().flatten(),
4106                    cols: 0,
4107                    rows: 0,
4108                    origin: SPAWN_ORIGIN.try_with(|o| o.clone()).ok().flatten(),
4109                    parent_session_id: SPAWN_PARENT.try_with(|o| o.clone()).ok().flatten(),
4110                });
4111                if let Some(dir) = file.parent() {
4112                    crate::session_meta::upsert(dir, instance_id.0, crate::session_meta::Meta {
4113                        origin: SPAWN_ORIGIN.try_with(|o| o.clone()).ok().flatten(),
4114                        parent_session_id: SPAWN_PARENT.try_with(|o| o.clone()).ok().flatten(),
4115                        provider: Some(provider.as_str().to_owned()),
4116                        ..crate::session_meta::Meta::default()
4117                    });
4118                    crate::session_meta::revive(dir, instance_id.0);
4119                }
4120                tracing::info!(instance_id = instance_id.0, provider = %provider, "session journalled for restart resume");
4121            }
4122        }
4123        if let Some(socket) = self.session_grok_leader_socket(&provider, mode, instance_id) {
4124            network_overlay_args.extend(crate::grok_leader::leader_socket_args(&socket));
4125        }
4126        let environment_overlay = match (environment_overlay.flatten(), network_overlay_args) {
4127            (prepared, args) if args.is_empty() => prepared,
4128            (Some(PreparedNativeLaunchOverlay::Instance(mut overlay)), args) => {
4129                overlay.append_extra_args(args).map_err(|_| {
4130                    failure(
4131                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4132                        "network allowlist overlay could not merge with instance launch args",
4133                    )
4134                })?;
4135                Some(PreparedNativeLaunchOverlay::Instance(overlay))
4136            }
4137            (Some(PreparedNativeLaunchOverlay::Environment(overlay)), args) => {
4138                Some(PreparedNativeLaunchOverlay::Instance(
4139                    overlay
4140                        .into_instance_with_extra_args(args)
4141                        .map_err(|_| {
4142                            failure(
4143                                NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4144                                "network allowlist overlay could not merge with environment overlay",
4145                            )
4146                        })?,
4147                ))
4148            }
4149            (None, args) => Some(PreparedNativeLaunchOverlay::Instance(
4150                NativeInstanceLaunchOverlay::new(
4151                    provider.clone(),
4152                    match mode {
4153                        SessionMode::Pty => TransportKind::Pty,
4154                        SessionMode::Acp => TransportKind::Acp,
4155                        SessionMode::Inline => TransportKind::Pipe,
4156                    },
4157                    Vec::new(),
4158                    args,
4159                )
4160                .map_err(|_| {
4161                    failure(
4162                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4163                        "network allowlist overlay is invalid for this transport",
4164                    )
4165                })?,
4166            )),
4167        };
4168        // Per-session m4a account: own nick + capability for this PTY child only, on its own
4169        // overlay or appended to the overlay the spawn already carries.
4170        #[cfg(unix)]
4171        // `HATCHERY_M4A_SIGNER_SHELL=1` also gives operator shells an identity (live canaries).
4172        let signer_provider = crate::m4a_accounts::m4a_provider(provider.as_str()).or_else(|| {
4173            (provider.as_str() == "shell" && std::env::var_os("HATCHERY_M4A_SIGNER_SHELL").is_some_and(|v| v == "1")).then_some("shell")
4174        });
4175        #[cfg(unix)]
4176        // A provider session with its own m4a account (MCP server + cap) takes that path; the
4177        // signer handle is for sessions without one (operator shells).
4178        let signer_provider = signer_provider.filter(|p| *p == "shell" || self.m4a_accounts.is_none());
4179        #[cfg(unix)]
4180        let signer_identity = match (mode, self.signer_proxy.get(), signer_provider) {
4181            (SessionMode::Pty, Some(proxy), Some(m4a_provider)) => {
4182                let session_key = format!("{}-{}", self.incarnation_id, instance_id.0);
4183                let uid = unsafe { libc_geteuid() };
4184                let handle = proxy.reserve(&session_key, m4a_provider, uid);
4185                let sock = std::env::var_os(crate::keystore_proxy::SOCKET_ENV).unwrap_or_default();
4186                tracing::info!(provider = %provider, instance_id = ?instance_id, "m4a signer handle reserved at spawn");
4187                Some(vec![
4188                    gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from(crate::keystore_proxy::HANDLE_ENV), value: Some(std::ffi::OsString::from(handle)) },
4189                    gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from(crate::keystore_proxy::SOCKET_ENV), value: Some(sock) },
4190                    gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from("M4A_SESSION_ID"), value: Some(std::ffi::OsString::from(session_key)) },
4191                ])
4192            }
4193            _ => None,
4194        };
4195        #[cfg(not(unix))]
4196        let signer_identity: Option<Vec<gate4agent_catalog::EnvMutation>> = None;
4197        let environment_overlay = match (mode, self.m4a_accounts.as_ref(), crate::m4a_accounts::m4a_provider(provider.as_str()), signer_identity) {
4198            (SessionMode::Pty, _, _, Some(identity)) => {
4199                let merged = match environment_overlay {
4200                    None => NativeInstanceLaunchOverlay::new_session_identity(provider.clone(), identity).map(PreparedNativeLaunchOverlay::Instance),
4201                    Some(PreparedNativeLaunchOverlay::Instance(mut overlay)) => {
4202                        overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
4203                    }
4204                    Some(PreparedNativeLaunchOverlay::Environment(mut overlay)) => {
4205                        overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Environment(overlay))
4206                    }
4207                }
4208                .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a signer handle overlay is invalid"))?;
4209                Some(merged)
4210            }
4211            (SessionMode::Pty | SessionMode::Acp, Some(accounts), Some(m4a_provider), None) => {
4212                let session_key = self.m4a_session_key(instance_id);
4213                self.m4a_session_overlay(accounts, m4a_provider, provider, instance_id, environment_overlay, &session_key, mode)?
4214            }
4215            (SessionMode::Pty, Some(_), None, _) => {
4216                tracing::warn!(provider = %provider, instance_id = ?instance_id, "m4a session account not assigned: provider has no m4a mapping");
4217                environment_overlay
4218            }
4219            _ => environment_overlay,
4220        };
4221        let mut instance_overlay = environment_overlay
4222            .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
4223            .transpose()?
4224            .flatten();
4225        Ok(PreparedSpawn {
4226            instance_overlay,
4227            environment_selection,
4228            materialization_guard,
4229            harness_mcp_overlay,
4230            #[cfg(feature = "dig2-station-probe")]
4231            browser_station_lease_guard,
4232            materialization_id,
4233        })
4234    }
4235}
4236
4237struct SessionRecords {
4238    records: BTreeMap<SessionRecordId, ManagedSessionRecord>,
4239}
4240
4241struct SpawnIdempotencyCache {
4242    entries: BTreeMap<SpawnIdempotencyKey, SpawnIdempotencyEntry>,
4243}
4244
4245struct SpawnIdempotencyEntry {
4246    value: SpawnIdempotencyValue,
4247    expires_at: Instant,
4248}
4249
4250enum SpawnIdempotencyValue {
4251    Standard {
4252        spec: SpawnSpec,
4253        result: Result<ResolvedSpawnReceipt, NodeFailure>,
4254    },
4255    Managed {
4256        request: ManagedWorktreeSpawnRequest,
4257        result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
4258    },
4259    HarnessMcp,
4260}
4261
4262enum DurableManagedSpawnReplayDecision {
4263    Reserved,
4264    PendingLinked,
4265    Committed(ManagedWorktreeSpawnReceipt),
4266}
4267
4268impl SpawnIdempotencyValue {
4269    fn references_context(&self, context_id: &SpawnContextId) -> bool {
4270        match self {
4271            Self::Standard { result, .. } => result
4272                .as_ref()
4273                .ok()
4274                .and_then(|receipt| receipt.context.as_ref()),
4275            Self::Managed { result, .. } => result
4276                .as_ref()
4277                .ok()
4278                .and_then(|receipt| receipt.spawn.context.as_ref()),
4279            Self::HarnessMcp => None,
4280        }
4281        .is_some_and(|receipt| &receipt.id == context_id)
4282    }
4283}
4284
4285#[derive(Clone, Copy)]
4286struct ControllerLease {
4287    connection_id: u64,
4288    expires_at: Instant,
4289}
4290
4291struct NodeEventHistory {
4292    last_sequence: u64,
4293    replay_floor_sequence: u64,
4294    events: VecDeque<NodeEventEnvelope>,
4295    record_providers: BTreeMap<SessionRecordId, AgentId>,
4296    removed_record_providers: BTreeMap<u64, AgentId>,
4297}
4298
4299impl NodeEventHistory {
4300    fn new(record_providers: BTreeMap<SessionRecordId, AgentId>) -> Self {
4301        Self {
4302            last_sequence: 0,
4303            replay_floor_sequence: 1,
4304            events: VecDeque::with_capacity(NODE_EVENT_HISTORY_MAX),
4305            record_providers,
4306            removed_record_providers: BTreeMap::new(),
4307        }
4308    }
4309}
4310
4311impl NodeShared {
4312    /// A node-spawned grok PTY session gets its own leader socket, so its agent backend runs
4313    /// as a descendant of this session's process (with the session's own environment) and
4314    /// never attaches to an operator's shared leader. Same grok home, same login.
4315    fn session_resume_journal(&self) -> Option<PathBuf> {
4316        self.state_path.as_ref().and_then(|p| p.parent()).map(crate::pty_resume::path)
4317    }
4318
4319    /// Node start: resume every journalled session through its provider's resume path,
4320    /// under its old instance number, so HQ shows the same chat.
4321    async fn restore_journalled_sessions(&self) {
4322        let Some(file) = self.session_resume_journal() else { return };
4323        if !crate::pty_resume::enabled() { return; }
4324        for e in crate::pty_resume::load(&file) {
4325            crate::pty_resume::remove(&file, e.instance_id);
4326            // Same m4a session key as before the restart: the session keeps its account and nick.
4327            if !e.session_key.is_empty() {
4328                self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).insert(AgentInstanceId(e.instance_id), e.session_key.clone());
4329            }
4330            let (Ok(ws), Ok(provider)) = (WorkspaceId::new(e.workspace_id.clone()), AgentId::new(e.provider.clone())) else { continue };
4331            if e.acp {
4332                let record_id = {
4333                    let records = self.session_records.lock().unwrap_or_else(|p| p.into_inner());
4334                    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())
4335                };
4336                let cur = self.next_instance_id.load(Ordering::Acquire);
4337                if e.instance_id >= cur { self.next_instance_id.store(e.instance_id, Ordering::Release); }
4338                let size = gate4agent_types::TerminalSize { rows: 40, columns: 120 };
4339                let resumed = match record_id {
4340                    Some(rid) => SPAWN_SESSION_OPTIONS.scope(e.options.clone(), self.resume_session_record(&rid, size, None)).await.map(|(_, a)| a),
4341                    None => Err(failure(NodeFailureCode::SessionRecordNotResumable, "no record")),
4342                };
4343                // An agent without ACP session/load (claude) cannot resume: same number, fresh chat.
4344                let r = match resumed {
4345                    Ok(a) => Ok(a),
4346                    Err(err) => {
4347                        tracing::info!(instance_id = e.instance_id, message = %err.message, "acp resume unavailable; starting fresh under the same number");
4348                        self.next_instance_id.store(e.instance_id.max(self.next_instance_id.load(Ordering::Acquire).min(e.instance_id)), Ordering::Release);
4349                        SPAWN_SESSION_OPTIONS.scope(e.options.clone(), self.spawn_session(ws, provider, SessionMode::Acp, size, None)).await
4350                    }
4351                };
4352                match r {
4353                    Ok(addr) => tracing::info!(instance_id = addr.session.instance_id.0, provider = %e.provider, "journalled acp session restored"),
4354                    Err(err) => tracing::warn!(instance_id = e.instance_id, provider = %e.provider, message = %err.message, "journalled acp session restore failed"),
4355                }
4356                continue;
4357            }
4358            if e.provider_session_id.is_empty() || !provider_session_exists(&e.provider, &e.provider_session_id) {
4359                // Nothing said yet in that chat: start it fresh under the same number.
4360                let cur = self.next_instance_id.load(Ordering::Acquire);
4361                if e.instance_id >= cur { self.next_instance_id.store(e.instance_id, Ordering::Release); }
4362                let size = gate4agent_types::TerminalSize { rows: 40, columns: 120 };
4363                match SPAWN_SESSION_OPTIONS.scope(e.options.clone(), self.spawn_session(ws, provider, SessionMode::Pty, size, None)).await {
4364                    Ok(addr) => tracing::info!(instance_id = addr.session.instance_id.0, provider = %e.provider, "journalled session had no transcript; started fresh"),
4365                    Err(err) => tracing::warn!(instance_id = e.instance_id, provider = %e.provider, message = %err.message, "journalled session restart failed"),
4366                }
4367                continue;
4368            }
4369            let folder = std::path::Path::new(&e.cwd);
4370            let in_session_dir = folder.parent().and_then(|p| p.file_name()).is_some_and(|n| n == ".hatchery-sessions");
4371            let name = match (in_session_dir, folder.file_name()) {
4372                (true, Some(n)) => n.to_string_lossy().into_owned(),
4373                _ => format!("{} #{}", e.provider, e.instance_id),
4374            };
4375            let identity = ProviderSessionIdentity { key: gate4agent_types::ProviderSessionKey::SessionId, id: e.provider_session_id.clone(), transcript_path: None };
4376            let record = match self.index_provider_session(ws, provider, identity, name) {
4377                Ok(r) => r,
4378                Err(err) => { tracing::warn!(instance_id = e.instance_id, message = %err.message, "journalled session not indexed"); continue; }
4379            };
4380            let cur = self.next_instance_id.load(Ordering::Acquire);
4381            if e.instance_id >= cur { self.next_instance_id.store(e.instance_id, Ordering::Release); }
4382            let size = gate4agent_types::TerminalSize { rows: if e.rows > 0 { e.rows } else { 40 }, columns: if e.cols > 0 { e.cols } else { 120 } };
4383            let r = SPAWN_SESSION_OPTIONS.scope(e.options.clone(), self.resume_session_record(&record.record_id, size, None)).await;
4384            match r {
4385                Ok((_, addr)) => tracing::info!(instance_id = addr.session.instance_id.0, provider = %e.provider, "journalled session resumed"),
4386                Err(err) => tracing::warn!(instance_id = e.instance_id, provider = %e.provider, message = %err.message, "journalled session resume failed"),
4387            }
4388        }
4389    }
4390
4391    fn refresh_session_journal(&self) {
4392        let Some(file) = self.session_resume_journal() else { return };
4393        let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
4394        if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") { homes.extend(std::env::split_paths(&extra)); }
4395        crate::pty_resume::refresh(&file, &homes);
4396        let ids: Vec<(u64, String)> = {
4397            let records = self.session_records.lock().unwrap_or_else(|p| p.into_inner());
4398            records.records.values().filter_map(|r| Some((r.active_session.as_ref()?.session.instance_id.0, r.provider_session.as_ref()?.id.clone()))).collect()
4399        };
4400        for (inst, sid) in ids {
4401            crate::pty_resume::set_session_id(&file, inst, &sid);
4402        }
4403    }
4404
4405    fn session_grok_leader_socket(
4406        &self,
4407        provider: &AgentId,
4408        mode: SessionMode,
4409        instance_id: AgentInstanceId,
4410    ) -> Option<std::path::PathBuf> {
4411        if provider.as_str() != "grok" || mode != SessionMode::Pty || crate::grok_leader::disabled() {
4412            return None;
4413        }
4414        let root = self
4415            .m4a_accounts
4416            .as_ref()
4417            .and_then(|a| a.base().parent().map(std::path::Path::to_path_buf))
4418            .unwrap_or_else(std::env::temp_dir);
4419        let socket = crate::grok_leader::socket_path(&root, &format!("{}-{}", self.incarnation_id, instance_id.0));
4420        if let Some(previous) = self
4421            .grok_leaders
4422            .lock()
4423            .unwrap_or_else(|p| p.into_inner())
4424            .insert(instance_id, socket.clone())
4425        {
4426            crate::grok_leader::retire(&previous);
4427        }
4428        Some(socket)
4429    }
4430
4431    /// Stops this session's own grok leader, if it had one. Never touches any other leader.
4432    fn retire_session_grok_leader(&self, instance_id: AgentInstanceId) {
4433        let socket = self.grok_leaders.lock().unwrap_or_else(|p| p.into_inner()).remove(&instance_id);
4434        if let Some(socket) = socket {
4435            crate::grok_leader::retire(&socket);
4436        }
4437    }
4438
4439    fn new_with_incarnation(
4440        handle: Gate4AgentHandle,
4441        access_token: String,
4442        node_id: NodeId,
4443        incarnation_id: NodeIncarnationId,
4444        workspaces: Vec<WorkspaceConfig>,
4445        enabled_providers: Vec<AgentId>,
4446        provider_runtime_statuses: ProviderRuntimeStatuses,
4447        provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
4448        provider_contracts: Vec<ProviderContractSupport>,
4449        provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
4450        spawn_profiles: SpawnProfileRegistry,
4451        native_launch_profile_control: Option<NativeLaunchProfileControl>,
4452        native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
4453        session_environment_materializer: Option<SessionEnvironmentMaterializer>,
4454        state_path: Option<PathBuf>,
4455        records: Vec<ManagedSessionRecord>,
4456        managed_worktrees: Vec<ManagedWorktreeLeaseRecord>,
4457        managed_worktree_tombstones: Vec<ManagedWorktreeLeaseRecord>,
4458        materializations: Vec<MaterializationOwnershipRecord>,
4459        persistence_warning: Option<String>,
4460        input_settle_timeout_ms: u64,
4461    ) -> Self {
4462        let (event_tx, _) = broadcast::channel(NODE_BROADCAST_CAPACITY);
4463        let (terminal_event_tx, _) = broadcast::channel(NODE_TERMINAL_BROADCAST_CAPACITY);
4464        let (agent_stream_event_tx, _) = broadcast::channel(NODE_AGENT_STREAM_BROADCAST_CAPACITY);
4465        let (harness_mcp_proxy_event_tx, _) =
4466            broadcast::channel(NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY);
4467        let record_providers = records
4468            .iter()
4469            .map(|record| (record.record_id.clone(), record.provider.clone()))
4470            .collect();
4471        let mut worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode> = workspaces
4472            .iter()
4473            .map(|workspace| {
4474                (workspace.workspace_id.clone(), workspace.worktree_service_mode)
4475            })
4476            .collect();
4477        for lease in &managed_worktrees {
4478            worktree_service_modes.insert(lease.workspace_id.clone(), WorktreeServiceMode::Off);
4479        }
4480        let managed_worktree_profiles = workspaces.iter()
4481            .map(|workspace| (
4482                workspace.workspace_id.clone(),
4483                workspace.managed_worktree_profiles.clone(),
4484            ))
4485            .collect();
4486        let mut managed_worktrees = ManagedWorktreeRegistry::from_records(
4487            managed_worktrees,
4488            managed_worktree_tombstones,
4489        ).expect("durable managed worktree registry was validated while loading");
4490        managed_worktrees.clear_stale_session_holders(incarnation_id, unix_time_ms());
4491        managed_worktrees.reattach_record_holders(
4492            &managed_worktree_record_holders(&records),
4493            unix_time_ms(),
4494        );
4495        Self {
4496            handle,
4497            access_token,
4498            node_id,
4499            incarnation_id,
4500            started_at_unix_ms: std::time::SystemTime::now()
4501                .duration_since(std::time::UNIX_EPOCH)
4502                .unwrap_or_default()
4503                .as_millis()
4504                .min(u64::MAX as u128) as u64,
4505            workspaces: RwLock::new(
4506                workspaces
4507                    .into_iter()
4508                    .map(|workspace| (workspace.workspace_id, workspace.canonical_root))
4509                    .collect(),
4510            ),
4511            worktree_service_modes,
4512            managed_worktree_profiles,
4513            managed_worktrees: Mutex::new(managed_worktrees),
4514            enabled_providers,
4515            provider_runtime_statuses,
4516            provider_runtime_status_updates: RwLock::new(BTreeMap::new()),
4517            provider_runtime_monitor,
4518            provider_contracts,
4519            provider_adapter_contracts,
4520            spawn_profiles,
4521            environment_profiles: RwLock::new(BTreeMap::new()),
4522            environment_materialization_profiles: RwLock::new(BTreeMap::new()),
4523            network_allowlist_catalog: RwLock::new(crate::network_allowlist_catalog::NetworkAllowlistCatalog::new()),
4524            #[cfg(feature = "dig2-station-probe")]
4525            browser_station_leases: crate::dig2_station_lease::new_lease_table(),
4526            bundle_catalog: RwLock::new(BundleCatalog::default()),
4527            delivery_store: Mutex::new(None),
4528            context_pack_store: Mutex::new(None),
4529            context_catalog: RwLock::new(ContextPackCatalog::default()),
4530            native_launch_profile_control,
4531            harness_mcp_registry: None,
4532            native_session_catalog,
4533            session_environment_materializer,
4534            m4a_accounts: crate::m4a_accounts::M4aSessionAccounts::from_env(),
4535            m4a_live: Mutex::new(BTreeMap::new()),
4536            m4a_keys: Mutex::new(BTreeMap::new()),
4537            m4a_reuse_keys: Mutex::new(BTreeMap::new()),
4538            grok_leaders: Mutex::new(BTreeMap::new()),
4539            #[cfg(unix)]
4540            #[cfg(unix)]
4541            signer_proxy: std::sync::OnceLock::new(),
4542            materializations: Mutex::new(
4543                materializations
4544                    .into_iter()
4545                    .map(|record| (record.id().clone(), record))
4546                    .collect(),
4547            ),
4548            spawn_idempotency: Mutex::new(SpawnIdempotencyCache {
4549                entries: BTreeMap::new(),
4550            }),
4551            managed_spawn_replays: Mutex::new(BTreeMap::new()),
4552            controller: Mutex::new(None),
4553            provisional_spawns: Mutex::new(BTreeMap::new()),
4554            pending_acp_options: Mutex::new(BTreeMap::new()),
4555            history: Mutex::new(NodeEventHistory::new(record_providers)),
4556            event_tx,
4557            terminal_event_tx,
4558            agent_stream_event_tx,
4559            harness_mcp_proxy_event_tx,
4560            terminal_frame_watermarks: Mutex::new(BTreeMap::new()),
4561            pty_input_wake: tokio::sync::Notify::new(),
4562            pty_input_marks: Mutex::new(BTreeMap::new()),
4563            next_connection_id: AtomicU64::new(1),
4564            next_instance_id: AtomicU64::new(1),
4565            next_command_id: AtomicU64::new(1),
4566            shutdown: AtomicBool::new(false),
4567            shutdown_notify: Notify::new(),
4568            preauth_slots: Arc::new(Semaphore::new(MAX_PREAUTH_CONNECTIONS)),
4569            authenticated_slots: Arc::new(Semaphore::new(MAX_AUTHENTICATED_CONNECTIONS)),
4570            inspection_slots: Arc::new(Semaphore::new(WORKSPACE_INSPECTION_MAX_CONCURRENCY)),
4571            mutation_gate: AsyncMutex::new(()),
4572            session_bindings: Mutex::new(BTreeMap::new()),
4573            session_records: Mutex::new(SessionRecords {
4574                records: records
4575                    .into_iter()
4576                    .map(|record| (record.record_id.clone(), record))
4577                    .collect(),
4578            }),
4579            state_path,
4580            persistence_error: RwLock::new(
4581                persistence_warning
4582                    .map(|message| session_registry::sanitized_persistence_summary(&message)),
4583            ),
4584            state_transaction: Mutex::new(()),
4585            input_settle_timeout_ms,
4586            drive_loop_profile: Mutex::new(DriveLoopProfiler::default()),
4587            runtime_tick_profile: Mutex::new(TickProfileSnapshot::default()),
4588            shell_efficiency_profile: OnceLock::new(),
4589            native_session_gauge: AtomicUsize::new(0),
4590            connection_loop_iterations: AtomicU64::new(0),
4591            connection_events_sent: AtomicU64::new(0),
4592            rejected_commands_total: AtomicU64::new(0),
4593            rejected_command_reasons: Mutex::new(BTreeMap::new()),
4594            drive_loop_iterations_total: AtomicU64::new(0),
4595            drive_loop_iterations_idle: AtomicU64::new(0),
4596            session_record_retention: SessionRecordRetentionConfig::default(),
4597            retired_records_total: AtomicU64::new(0),
4598            #[cfg(feature = "fixture")]
4599            fixture_semantic_hook_policy: false,
4600            #[cfg(feature = "fixture")]
4601            fixture_spawn_managed_worktree_v2_failure_probe:
4602                SpawnManagedWorktreeV2FailureProbe::default(),
4603        }
4604    }
4605
4606    #[cfg(test)]
4607    fn new(
4608        handle: Gate4AgentHandle,
4609        access_token: String,
4610        node_id: NodeId,
4611        workspaces: Vec<WorkspaceConfig>,
4612        enabled_providers: Vec<AgentId>,
4613    ) -> Self {
4614        Self::new_with_incarnation(
4615            handle,
4616            access_token,
4617            node_id,
4618            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
4619            workspaces,
4620            enabled_providers,
4621            ProviderRuntimeStatuses::default(),
4622            None,
4623            Vec::new(),
4624            Vec::new(),
4625            SpawnProfileRegistry::default(),
4626            None,
4627            None,
4628            None,
4629            None,
4630            Vec::new(),
4631            Vec::new(),
4632            Vec::new(),
4633            Vec::new(),
4634            None,
4635            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
4636        )
4637    }
4638
4639    fn persistence_error(&self) -> Option<String> {
4640        self.persistence_error
4641            .read()
4642            .unwrap_or_else(|poisoned| poisoned.into_inner())
4643            .clone()
4644    }
4645
4646    async fn admit_provider_runtime(
4647        &self,
4648        provider: &AgentId,
4649        requirement: ProviderRuntimeRequirement,
4650    ) -> Result<ProviderRuntimePolicy, NodeFailure> {
4651        let admission = if let Some(monitor) = self.provider_runtime_monitor.clone() {
4652            let pipe_transport = monitor.supports_pipe_transport(provider);
4653            let acp_transport = monitor.supports_acp_transport(provider);
4654            let provider = provider.clone();
4655            let refresh = tokio::task::spawn_blocking(move || monitor.evaluate(&provider));
4656            let (status, admission) = timeout(
4657                Duration::from_millis(PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS),
4658                refresh,
4659            )
4660            .await
4661            .map_err(|_| failure(
4662                NodeFailureCode::BackendBusy,
4663                "provider runtime probe exceeded its bounded deadline",
4664            ))?
4665            .map_err(|_| failure(
4666                NodeFailureCode::BackendOperationFailed,
4667                "provider runtime probe task failed",
4668            ))?;
4669            if let Some(status) = status {
4670                self
4671                    .provider_runtime_status_updates
4672                    .write()
4673                    .unwrap_or_else(|poisoned| poisoned.into_inner())
4674                    .insert(status.provider().clone(), status);
4675            }
4676            admission.and_then(|policy| {
4677                require_policy(policy, pipe_transport, acp_transport, requirement).map(|()| policy)
4678            })
4679        } else {
4680            // No live monitor means no catalog to consult -- there is
4681            // nothing here to confirm this provider declares a Pipe or ACP
4682            // transport, so Inline/Acp are rejected the same conservative
4683            // way an undeclared provider always was, rather than admitted
4684            // on trust.
4685            crate::provider_runtime::admit_status(
4686                &self.provider_runtime_statuses,
4687                provider,
4688                false,
4689                false,
4690                requirement,
4691            )
4692        };
4693        let admission = admission.map(|policy| self.admit_declared_hook_semantics(provider, policy));
4694        admission.map_err(|error| match error {
4695            ProviderRuntimeAdmissionError::LauncherUnavailable => failure(
4696                NodeFailureCode::BackendOperationFailed,
4697                "provider launcher is unavailable",
4698            ),
4699            ProviderRuntimeAdmissionError::SemanticCapabilityUnverified => failure(
4700                NodeFailureCode::UnsupportedCapability,
4701                "provider semantic capability is not verified",
4702            ),
4703            ProviderRuntimeAdmissionError::ProbeBusy => failure(
4704                NodeFailureCode::BackendBusy,
4705                "provider runtime probe is already in progress",
4706            ),
4707        })
4708    }
4709
4710    /// Whether the catalog declares a Pipe transport for `provider` --
4711    /// the fact `require_policy` needs to admit or reject
4712    /// `ProviderRuntimeRequirement::Inline`. With no live monitor there is no
4713    /// catalog to consult, so this stays conservative and rejects, matching
4714    /// `admit_provider_runtime`'s own no-monitor branch.
4715    fn provider_supports_pipe_transport(&self, provider: &AgentId) -> bool {
4716        match self.provider_runtime_monitor.as_deref() {
4717            Some(monitor) => monitor.supports_pipe_transport(provider),
4718            None => false,
4719        }
4720    }
4721
4722    /// Whether the catalog declares an ACP transport for `provider` -- the
4723    /// fact `require_policy` needs to admit or reject
4724    /// `ProviderRuntimeRequirement::Acp`. Mirrors
4725    /// `provider_supports_pipe_transport` exactly, including the
4726    /// conservative no-monitor default.
4727    fn provider_supports_acp_transport(&self, provider: &AgentId) -> bool {
4728        match self.provider_runtime_monitor.as_deref() {
4729            Some(monitor) => monitor.supports_acp_transport(provider),
4730            None => false,
4731        }
4732    }
4733
4734    /// Grants hook ingestion to a provider whose catalog entry declares a
4735    /// Hook adapter, whatever runtime mode its launcher probe landed in.
4736    ///
4737    /// `admit_status`'s `RawPassthrough` arm hands back
4738    /// `ProviderRuntimePolicy::raw_pty()`, which grants nothing semantic --
4739    /// correct for everything it was written to answer, and wrong for hooks
4740    /// specifically. A hook event is not inferred from terminal behaviour,
4741    /// so it has nothing to do with whether a launcher probe verified this
4742    /// CLI version's PTY contract; the authority is the catalog declaring
4743    /// the adapter plus the ingress authenticating the route. Without this
4744    /// the node installs a provider's hooks, the CLI faithfully calls them,
4745    /// and every event is refused -- which is exactly the state grok was in.
4746    ///
4747    /// Applied to both admission paths so the monitor's own derivation and
4748    /// this coarse fallback cannot disagree about the same provider.
4749    fn admit_declared_hook_semantics(
4750        &self,
4751        provider: &AgentId,
4752        policy: ProviderRuntimePolicy,
4753    ) -> ProviderRuntimePolicy {
4754        if policy.hook_semantics || !policy.raw_pty_lifecycle {
4755            return policy;
4756        }
4757        let declares_hook = self.provider_adapter_contracts.iter().any(|contract| {
4758            contract.provider == *provider && contract.family == AdapterFamily::Hook
4759        });
4760        if !declares_hook {
4761            return policy;
4762        }
4763        ProviderRuntimePolicy::new(
4764            policy.raw_pty_lifecycle,
4765            policy.semantic_readiness,
4766            policy.structured_prompt,
4767            policy.provider_session_identity,
4768            policy.semantic_resume,
4769            true,
4770        )
4771        .expect("granting hook semantics over a raw-PTY policy is internally valid")
4772    }
4773
4774    /// Station network allowlist → provider launch overlay argv.
4775    ///
4776    /// - **Codex**: first-slice `-c sandbox_workspace_write.network_access` from
4777    ///   `provider_native` under Moderate (PTY or ACP).
4778    /// - **Claude**: catalog `permits` → ProviderHome sidecar settings JSON +
4779    ///   documented `--settings <path>` on **PTY** only (Linux/macOS/WSL2).
4780    ///   ACP refuses (no measured ACP settings-path surface). Native Windows
4781    ///   refuses (no vendor Bash sandbox). Membership-only (empty permits) =
4782    ///   no overlay.
4783    /// - **Kimi / Grok**: no first-party settings/argv network surface here —
4784    ///   empty overlay when no `provider_native`; invented native keys already
4785    ///   refuse at load / resolve.
4786    ///
4787    /// Empty when no allowlist / nothing to map. Never cookies / secrets.
4788    fn resolve_network_allowlist_overlay_args(
4789        &self,
4790        provider: &AgentId,
4791        mode: SessionMode,
4792        approval_level: ApprovalLevel,
4793        network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4794        provider_home: Option<&Path>,
4795    ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4796        let Some(allowlist_id) = network_allowlist else {
4797            return Ok(Vec::new());
4798        };
4799        let catalog = self
4800            .network_allowlist_catalog
4801            .read()
4802            .unwrap_or_else(|poisoned| poisoned.into_inner());
4803        let Some(entry) = catalog.get(allowlist_id) else {
4804            return Err(failure(
4805                NodeFailureCode::UnknownNetworkAllowlist,
4806                "spawn network allowlist is unavailable on this node",
4807            ));
4808        };
4809
4810        // Claude: settings-shaped overlay from permits (not provider_native).
4811        if provider.as_str() == "claude" && !entry.permits.is_empty() {
4812            crate::network_allowlist_catalog::claude_bash_sandbox_network_os_supported().map_err(
4813                |message| {
4814                    failure(
4815                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4816                        &message,
4817                    )
4818                },
4819            )?;
4820            if !matches!(mode, SessionMode::Pty) {
4821                return Err(failure(
4822                    NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4823                    "Claude sandbox.network --settings overlay requires PTY transport; ACP has no measured settings-path surface (refuse rather than invent RPC)",
4824                ));
4825            }
4826            let Some(home) = provider_home else {
4827                return Err(failure(
4828                    NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4829                    "Claude sandbox.network --settings overlay requires ProviderHome materialization (CLAUDE_CONFIG_DIR)",
4830                ));
4831            };
4832            let domains =
4833                crate::network_allowlist_catalog::claude_allowed_domains_from_permits(&entry.permits);
4834            if domains.is_empty() {
4835                return Err(failure(
4836                    NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4837                    "Claude sandbox.network overlay could not map catalog permits to allowedDomains",
4838                ));
4839            }
4840            let settings_path = home.join(
4841                crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
4842            );
4843            let json =
4844                crate::network_allowlist_catalog::claude_station_network_settings_json(&domains);
4845            crate::session_environment::secure_create_file(&settings_path, json.as_bytes()).map_err(
4846                |_| {
4847                    failure(
4848                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4849                        "Claude station network settings sidecar could not be written under ProviderHome",
4850                    )
4851                },
4852            )?;
4853            let overlay =
4854                crate::network_allowlist_catalog::claude_settings_network_overlay_args(&settings_path);
4855            return Ok(overlay.into_iter().map(std::ffi::OsString::from).collect());
4856        }
4857
4858        let Some(native) = entry.provider_native.as_ref() else {
4859            return Ok(Vec::new());
4860        };
4861        let overlay = crate::network_allowlist_catalog::resolve_provider_native_launch_overlay(
4862            provider.as_str(),
4863            approval_level,
4864            native,
4865        )
4866        .map_err(|message| {
4867            failure(
4868                NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4869                &message,
4870            )
4871        })?;
4872        if overlay.is_empty() {
4873            return Ok(Vec::new());
4874        }
4875        if !matches!(mode, SessionMode::Pty | SessionMode::Acp) {
4876            return Err(failure(
4877                NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4878                "Codex network_access -c overlay requires PTY or ACP transport",
4879            ));
4880        }
4881        Ok(overlay.into_iter().map(std::ffi::OsString::from).collect())
4882    }
4883
4884    /// Backward-compatible alias used by Codex-focused unit tests.
4885    fn resolve_codex_network_access_overlay_args(
4886        &self,
4887        provider: &AgentId,
4888        mode: SessionMode,
4889        approval_level: ApprovalLevel,
4890        network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4891    ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4892        self.resolve_network_allowlist_overlay_args(
4893            provider,
4894            mode,
4895            approval_level,
4896            network_allowlist,
4897            None,
4898        )
4899    }
4900
4901    fn resolve_environment_profile(
4902        &self,
4903        resolved: &ResolvedSpawnSpec,
4904    ) -> Result<Option<ResolvedEnvironmentProfileReceipt>, NodeFailure> {
4905        // Station network allowlist catalog enforce (plan
4906        // station-network-and-browser-profile-knobs §2.1 / §4): empty-default
4907        // catalog — unknown ids refuse rather than silent ambient. Runs even
4908        // when no environment_profile_id is present so a bare allowlist still
4909        // cannot bypass the catalog. Never cookies / OAuth / proxy credentials.
4910        if let Some(allowlist_id) = resolved.network_allowlist.as_ref() {
4911            let catalog = self
4912                .network_allowlist_catalog
4913                .read()
4914                .unwrap_or_else(|poisoned| poisoned.into_inner());
4915            let Some(entry) = catalog.get(allowlist_id) else {
4916                return Err(failure(
4917                    NodeFailureCode::UnknownNetworkAllowlist,
4918                    "spawn network allowlist is unavailable on this node",
4919                ));
4920            };
4921            // Track B: refuse provider-native mappings this provider cannot
4922            // honor (e.g. codex_network_access on Claude). Never silent ambient.
4923            if let Some(native) = entry.provider_native.as_ref() {
4924                if let Err(message) = crate::network_allowlist_catalog::provider_native_mapping_supported(
4925                    resolved.provider.as_str(),
4926                    resolved.approval_level,
4927                    native,
4928                ) {
4929                    return Err(failure(
4930                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4931                        &message,
4932                    ));
4933                }
4934            }
4935        }
4936        // Station knobs Track A (dig2browser-station-probe-and-network-permit-set):
4937        // when feature `dig2-station-probe` is on and browser_profile_id is set,
4938        // cheap named-pipe path reachability must succeed (Windows
4939        // `\\.\pipe\{suffix}`); platform-unsupported or unreachable → clear
4940        // NodeFailureCode. Runs even without environment_profile_id (same
4941        // placement spirit as allowlist catalog). Feature off keeps stub echo.
4942        // Never cookies / OAuth / proxy credentials. Exclusive lease is
4943        // acquired later in spawn_session_with_deadline (after instance id).
4944        #[cfg(feature = "dig2-station-probe")]
4945        if resolved.browser_profile_id.is_some() {
4946            use crate::dig2_station_probe::{probe_station_reachable, StationProbeError};
4947            match probe_station_reachable() {
4948                Ok(()) => {}
4949                Err(StationProbeError::UnsupportedPlatform)
4950                | Err(StationProbeError::InvalidPipeSuffix) => {
4951                    return Err(failure(
4952                        NodeFailureCode::BrowserStationProbeUnavailable,
4953                        "dig2browser station probe unavailable on this platform",
4954                    ));
4955                }
4956                Err(StationProbeError::Unreachable) => {
4957                    return Err(failure(
4958                        NodeFailureCode::BrowserStationUnreachable,
4959                        "dig2browser station named-pipe is unreachable",
4960                    ));
4961                }
4962            }
4963        }
4964        let Some(profile_id) = resolved.environment_profile_id.as_ref() else {
4965            return Ok(None);
4966        };
4967        let profiles = self
4968            .environment_profiles
4969            .read()
4970            .unwrap_or_else(|poisoned| poisoned.into_inner());
4971        let binding = profiles.get(profile_id).ok_or_else(|| {
4972            failure(
4973                NodeFailureCode::UnknownEnvironmentProfile,
4974                "spawn environment profile is unavailable on this node",
4975            )
4976        })?;
4977        if binding.provider != resolved.provider
4978            || binding.native_profile_id(resolved.mode).is_none()
4979        {
4980            return Err(failure(
4981                NodeFailureCode::EnvironmentProfileBindingMismatch,
4982                "spawn environment profile does not match the provider and mode",
4983            ));
4984        }
4985        // Echo opaque network_allowlist / browser_profile_id onto the receipt
4986        // after catalog + optional dig2 probe above. Empty/whitespace ids
4987        // already refuse at Spawn*Id construction.
4988        Ok(Some(ResolvedEnvironmentProfileReceipt {
4989            profile_id: binding.id.clone(),
4990            profile_revision: binding.revision.clone(),
4991            network_allowlist: resolved.network_allowlist.clone(),
4992            browser_profile_id: resolved.browser_profile_id.clone(),
4993        }))
4994    }
4995
4996    fn resolve_bundle(
4997        &self,
4998        resolved: &ResolvedSpawnSpec,
4999        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5000    ) -> Result<Option<ResolvedBundleReceipt>, NodeFailure> {
5001        let Some(bundle_id) = resolved.bundle_id.as_ref() else {
5002            return Ok(None);
5003        };
5004        let catalog = self.bundle_catalog
5005            .read()
5006            .unwrap_or_else(|poisoned| poisoned.into_inner());
5007        let bundle = catalog.get(bundle_id).ok_or_else(|| {
5008                failure(
5009                    NodeFailureCode::UnknownBundle,
5010                    "spawn bundle is unavailable on this node",
5011                )
5012            })?;
5013        self.bundle_layout(
5014            &resolved.provider,
5015            resolved.mode,
5016            environment_profile,
5017            bundle,
5018        )?;
5019        Ok(Some(bundle.receipt()))
5020    }
5021
5022    fn begin_delivery_stage(
5023        &self,
5024        manifest: DeliveryBundleManifestV2,
5025    ) -> Result<NodeResponse, NodeFailure> {
5026        let mut store = self
5027            .delivery_store
5028            .lock()
5029            .unwrap_or_else(|poisoned| poisoned.into_inner());
5030        let store = store.as_mut().ok_or_else(|| {
5031            failure(
5032                NodeFailureCode::DeliveryStageStorageFailed,
5033                "node delivery store is unavailable",
5034            )
5035        })?;
5036        let begun = store.begin(manifest).map_err(delivery_failure)?;
5037        Ok(NodeResponse::DeliveryStageBegun {
5038            stage_id: begun.stage_id,
5039            manifest_digest: begun.manifest_digest,
5040            missing_blobs: begun.missing_blobs,
5041        })
5042    }
5043
5044    fn put_delivery_blob_chunk(
5045        &self,
5046        stage_id: DeliveryStageId,
5047        blob_digest: DeliveryBlobDigestV1,
5048        offset: u64,
5049        chunk_hex: DeliveryBlobChunkHexV1,
5050    ) -> Result<NodeResponse, NodeFailure> {
5051        let mut store = self
5052            .delivery_store
5053            .lock()
5054            .unwrap_or_else(|poisoned| poisoned.into_inner());
5055        let store = store.as_mut().ok_or_else(|| {
5056            failure(
5057                NodeFailureCode::DeliveryStageStorageFailed,
5058                "node delivery store is unavailable",
5059            )
5060        })?;
5061        let accepted = store
5062            .put_chunk(&stage_id, &blob_digest, offset, &chunk_hex.decode())
5063            .map_err(delivery_failure)?;
5064        Ok(NodeResponse::DeliveryBlobChunkAccepted {
5065            stage_id: accepted.stage_id,
5066            blob_digest: accepted.blob_digest,
5067            next_offset: accepted.next_offset,
5068        })
5069    }
5070
5071    fn commit_delivery_stage(
5072        &self,
5073        stage_id: DeliveryStageId,
5074    ) -> Result<DeliveryCommitReceiptV1, NodeFailure> {
5075        let mut store = self
5076            .delivery_store
5077            .lock()
5078            .unwrap_or_else(|poisoned| poisoned.into_inner());
5079        let store = store.as_mut().ok_or_else(|| {
5080            failure(
5081                NodeFailureCode::DeliveryStageStorageFailed,
5082                "node delivery store is unavailable",
5083            )
5084        })?;
5085        let prepared = store.prepare_commit(&stage_id).map_err(delivery_failure)?;
5086        let mut catalog = self
5087            .bundle_catalog
5088            .write()
5089            .unwrap_or_else(|poisoned| poisoned.into_inner());
5090        let mut next_catalog = catalog.clone();
5091        next_catalog
5092            .insert_idempotent(prepared.bundle().clone())
5093            .map_err(|_| {
5094                failure(
5095                    NodeFailureCode::DeliveryStageConflict,
5096                    "delivery bundle identity conflicts with installed content",
5097                )
5098            })?;
5099        let receipt = store.publish_commit(prepared).map_err(delivery_failure)?;
5100        *catalog = next_catalog;
5101        Ok(receipt)
5102    }
5103
5104    fn abort_delivery_stage(
5105        &self,
5106        stage_id: &DeliveryStageId,
5107    ) -> Result<(), NodeFailure> {
5108        let mut store = self
5109            .delivery_store
5110            .lock()
5111            .unwrap_or_else(|poisoned| poisoned.into_inner());
5112        let store = store.as_mut().ok_or_else(|| {
5113            failure(
5114                NodeFailureCode::DeliveryStageStorageFailed,
5115                "node delivery store is unavailable",
5116            )
5117        })?;
5118        store.abort(stage_id).map_err(delivery_failure)
5119    }
5120
5121    fn resolve_context(
5122        &self,
5123        resolved: &ResolvedSpawnSpec,
5124    ) -> Result<Option<ResolvedContextPackReceipt>, NodeFailure> {
5125        let Some(context_id) = resolved.context_id.as_ref() else {
5126            return Ok(None);
5127        };
5128        self.context_catalog
5129            .read()
5130            .unwrap_or_else(|poisoned| poisoned.into_inner())
5131            .get(context_id)
5132            .map(|context| context.receipt().clone())
5133            .ok_or_else(|| {
5134                failure(
5135                    NodeFailureCode::UnknownContextPack,
5136                    "spawn context pack is unavailable on this node",
5137                )
5138            })
5139            .map(Some)
5140    }
5141
5142    fn bundle_layout(
5143        &self,
5144        provider: &AgentId,
5145        mode: SessionMode,
5146        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5147        bundle: &NodeBundle,
5148    ) -> Result<BundleProviderLayout, NodeFailure> {
5149        let layout = validate_bundle_binding(provider, mode, bundle).map_err(|_| {
5150            failure(
5151                NodeFailureCode::BundleBindingMismatch,
5152                "spawn bundle does not match the provider and mode",
5153            )
5154        })?;
5155        if layout != BundleProviderLayout::Codex {
5156            return Ok(layout);
5157        }
5158        let profile = environment_profile.and_then(|receipt| {
5159            let profiles = self
5160                .environment_profiles
5161                .read()
5162                .unwrap_or_else(|poisoned| poisoned.into_inner());
5163            let binding = profiles.get(&receipt.profile_id)?;
5164            if binding.revision != receipt.profile_revision
5165                || &binding.provider != provider
5166                || binding.native_profile_id(mode).is_none()
5167            {
5168                return None;
5169            }
5170            self.environment_materialization_profiles
5171                .read()
5172                .unwrap_or_else(|poisoned| poisoned.into_inner())
5173                .get(&receipt.profile_id)
5174                .cloned()
5175        });
5176        if !profile.is_some_and(|profile| profile.supports_bundle_layout(layout)) {
5177            return Err(failure(
5178                NodeFailureCode::BundleBindingMismatch,
5179                "Codex bundle requires an exact isolated CODEX_HOME profile",
5180            ));
5181        }
5182        Ok(layout)
5183    }
5184
5185    fn select_environment_profile(
5186        &self,
5187        instance_id: AgentInstanceId,
5188        provider: &AgentId,
5189        mode: SessionMode,
5190        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5191    ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
5192        let Some(environment_profile) = environment_profile else {
5193            return Ok(None);
5194        };
5195        let native_profile_id = {
5196            let profiles = self
5197                .environment_profiles
5198                .read()
5199                .unwrap_or_else(|poisoned| poisoned.into_inner());
5200            let binding = profiles
5201                .get(&environment_profile.profile_id)
5202                .ok_or_else(|| {
5203                    failure(
5204                        NodeFailureCode::UnknownEnvironmentProfile,
5205                        "session environment profile is unavailable on this node",
5206                    )
5207                })?;
5208            if &binding.revision != &environment_profile.profile_revision
5209                || &binding.provider != provider
5210            {
5211                return Err(failure(
5212                    NodeFailureCode::EnvironmentProfileBindingMismatch,
5213                    "session environment profile revision or provider changed",
5214                ));
5215            }
5216            binding.native_profile_id(mode).cloned().ok_or_else(|| {
5217                failure(
5218                    NodeFailureCode::EnvironmentProfileBindingMismatch,
5219                    "session environment profile does not support this mode",
5220                )
5221            })?
5222        };
5223        let control = self.native_launch_profile_control.clone().ok_or_else(|| {
5224            failure(
5225                NodeFailureCode::BackendOperationFailed,
5226                "native environment profile controller is unavailable",
5227            )
5228        })?;
5229        control
5230            .select_native_launch_profile(instance_id, native_profile_id)
5231            .map_err(|_| {
5232                failure(
5233                    NodeFailureCode::BackendOperationFailed,
5234                    "native environment profile selection failed",
5235                )
5236            })?;
5237        Ok(Some(NativeEnvironmentSelectionGuard {
5238            control: Some(control),
5239            instance_id,
5240        }))
5241    }
5242
5243    fn install_prepared_launch_overlay(
5244        &self,
5245        instance_id: AgentInstanceId,
5246        overlay: PreparedNativeLaunchOverlay,
5247    ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
5248        let control = self.native_launch_profile_control.clone().ok_or_else(|| {
5249            failure(
5250                NodeFailureCode::BackendOperationFailed,
5251                "native launch profile controller is unavailable",
5252            )
5253        })?;
5254        match overlay {
5255            PreparedNativeLaunchOverlay::Environment(overlay) => {
5256                control
5257                    .install_native_launch_environment_overlay(instance_id, overlay)
5258                    .map_err(|_| failure(
5259                        NodeFailureCode::BackendOperationFailed,
5260                        "session environment overlay installation failed",
5261                    ))?;
5262                Ok(None)
5263            }
5264            PreparedNativeLaunchOverlay::Instance(overlay) => {
5265                control
5266                    .install_native_instance_launch_overlay(instance_id, overlay)
5267                    .map_err(|_| failure(
5268                        NodeFailureCode::BundleBindingMismatch,
5269                        "session bundle launch overlay installation failed",
5270                    ))?;
5271                Ok(Some(NativeInstanceOverlayGuard {
5272                    control: Some(control),
5273                    instance_id,
5274                }))
5275            }
5276        }
5277    }
5278
5279    fn environment_materialization_profile(
5280        &self,
5281        environment_profile: &ResolvedEnvironmentProfileReceipt,
5282    ) -> Option<NodeSessionMaterializationProfile> {
5283        self.environment_materialization_profiles
5284            .read()
5285            .unwrap_or_else(|poisoned| poisoned.into_inner())
5286            .get(&environment_profile.profile_id)
5287            .cloned()
5288    }
5289
5290    fn materialization_profile(
5291        &self,
5292        provider: &AgentId,
5293        mode: SessionMode,
5294        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5295        bundle: Option<&ResolvedBundleReceipt>,
5296        context: Option<&ResolvedContextPackReceipt>,
5297    ) -> Result<Option<(
5298        NodeSessionMaterializationProfile,
5299        bool,
5300        Option<BundleProviderLayout>,
5301    )>, NodeFailure> {
5302        let environment = environment_profile
5303            .and_then(|receipt| self.environment_materialization_profile(receipt));
5304        let (bundle, bundle_layout) = match bundle {
5305            Some(receipt) => {
5306                let catalog = self.bundle_catalog.read()
5307                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5308                let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
5309                    NodeFailureCode::UnknownBundle,
5310                    "session bundle is unavailable on this node",
5311                ))?;
5312                if installed.receipt() != *receipt {
5313                    return Err(failure(
5314                        NodeFailureCode::BundleBindingMismatch,
5315                        "session bundle revision or digest changed",
5316                    ));
5317                }
5318                let layout = self.bundle_layout(
5319                    provider,
5320                    mode,
5321                    environment_profile,
5322                    installed,
5323                )?;
5324                (Some(installed.clone()), Some(layout))
5325            }
5326            None => (None, None),
5327        };
5328        let has_environment_overlay = environment.is_some() || context.is_some();
5329        let profile = match (environment, bundle.as_ref(), bundle_layout) {
5330            (None, None, None) => None,
5331            (Some(profile), None, None) => Some(Ok(profile)),
5332            (None, Some(bundle), Some(layout)) => {
5333                Some(NodeSessionMaterializationProfile::from_bundle(bundle, layout))
5334            }
5335            (Some(profile), Some(bundle), Some(layout)) => {
5336                Some(profile.with_bundle(bundle, layout))
5337            }
5338            _ => {
5339                return Err(failure(
5340                    NodeFailureCode::BundleBindingMismatch,
5341                    "session bundle provider layout is unavailable",
5342                ));
5343            }
5344        };
5345        let profile = profile
5346            .transpose()
5347            .map_err(|_| failure(
5348                NodeFailureCode::BundleMaterializationFailed,
5349                "session bundle materialization profile is invalid",
5350            ))?;
5351        let profile = match (profile, context) {
5352            (Some(profile), Some(receipt)) => {
5353                let catalog = self.context_catalog
5354                    .read()
5355                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5356                let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
5357                    NodeFailureCode::UnknownContextPack,
5358                    "session context pack is unavailable on this node",
5359                ))?;
5360                if pack.receipt() != receipt {
5361                    return Err(failure(
5362                        NodeFailureCode::ContextPackMaterializationFailed,
5363                        "session context pack identity changed",
5364                    ));
5365                }
5366                profile.with_context(pack)
5367            }
5368            (None, Some(receipt)) => {
5369                let catalog = self.context_catalog
5370                    .read()
5371                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5372                let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
5373                    NodeFailureCode::UnknownContextPack,
5374                    "session context pack is unavailable on this node",
5375                ))?;
5376                if pack.receipt() != receipt {
5377                    return Err(failure(
5378                        NodeFailureCode::ContextPackMaterializationFailed,
5379                        "session context pack identity changed",
5380                    ));
5381                }
5382                NodeSessionMaterializationProfile::from_context(pack)
5383            }
5384            (Some(profile), None) => Ok(profile),
5385            (None, None) => return Ok(None),
5386        }
5387        .map_err(|_| failure(
5388            NodeFailureCode::ContextPackMaterializationFailed,
5389            "session context pack materialization profile is invalid",
5390        ))?;
5391        Ok(Some((profile, has_environment_overlay, bundle_layout)))
5392    }
5393
5394    fn prepare_session_materialization<'a>(
5395        &'a self,
5396        address: &SessionAddress,
5397        provider: &AgentId,
5398        mode: SessionMode,
5399        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5400        bundle: Option<&ResolvedBundleReceipt>,
5401        context: Option<&ResolvedContextPackReceipt>,
5402        managed_lease_id: Option<ManagedWorktreeLeaseId>,
5403    ) -> Result<Option<(Option<PreparedNativeLaunchOverlay>, SessionMaterializationGuard<'a>)>, NodeFailure> {
5404        let Some((profile, has_environment_overlay, bundle_layout)) =
5405            self.materialization_profile(provider, mode, environment_profile, bundle, context)?
5406        else {
5407            return Ok(None);
5408        };
5409        let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5410            failure(
5411                NodeFailureCode::BackendOperationFailed,
5412                "session environment materializer is unavailable",
5413            )
5414        })?;
5415        let materialization_failure_code = if context.is_some() {
5416            NodeFailureCode::ContextPackMaterializationFailed
5417        } else if bundle.is_some() {
5418            NodeFailureCode::BundleMaterializationFailed
5419        } else {
5420            NodeFailureCode::BackendOperationFailed
5421        };
5422        let id = MaterializationId::new(format!(
5423            "mat-{}-{}-{}",
5424            self.incarnation_id,
5425            address.session.instance_id.0,
5426            address.session.generation.0,
5427        ))
5428        .map_err(|_| {
5429            failure(
5430                NodeFailureCode::BackendOperationFailed,
5431                "session environment materialization identity failed",
5432            )
5433        })?;
5434        let owner = MaterializationOwner::Session {
5435            incarnation_id: self.incarnation_id,
5436            instance_id: address.session.instance_id,
5437            generation: address.session.generation,
5438        };
5439        let mut ownership = materializer
5440            .begin(
5441                id.clone(),
5442                environment_profile.cloned(),
5443                bundle.cloned(),
5444                context.cloned(),
5445                owner,
5446                managed_lease_id,
5447                &profile,
5448                unix_time_ms(),
5449            )
5450            .map_err(|_| {
5451                failure(
5452                    materialization_failure_code,
5453                    "session materialization preparation failed",
5454                )
5455            })?;
5456        {
5457            let _transaction = self
5458                .state_transaction
5459                .lock()
5460                .unwrap_or_else(|poisoned| poisoned.into_inner());
5461            let duplicate = self
5462                .materializations
5463                .lock()
5464                .unwrap_or_else(|poisoned| poisoned.into_inner())
5465                .insert(id.clone(), ownership.clone());
5466            if duplicate.is_some() {
5467                return Err(failure(
5468                    NodeFailureCode::BackendOperationFailed,
5469                    "session environment materialization identity conflict",
5470                ));
5471            }
5472            if let Err(error) = self.persist_state_locked() {
5473                self.materializations
5474                    .lock()
5475                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5476                    .remove(&id);
5477                return Err(persistence_failure(error));
5478            }
5479        }
5480        let environment = match materializer.materialize(&mut ownership, &profile, unix_time_ms()) {
5481            Ok(environment) => environment,
5482            Err(_) => {
5483                let transaction = self
5484                    .state_transaction
5485                    .lock()
5486                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5487                self.materializations
5488                    .lock()
5489                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5490                    .insert(id.clone(), ownership.clone());
5491                if self.persist_state_locked().is_err() {
5492                    self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5493                }
5494                drop(transaction);
5495                if ownership.state() == MaterializationState::CleanupRequired {
5496                    let _ = self.cleanup_materialization(&id);
5497                }
5498                return Err(failure(
5499                    materialization_failure_code,
5500                    "session materialization failed",
5501                ));
5502            }
5503        };
5504        {
5505            let _transaction = self
5506                .state_transaction
5507                .lock()
5508                .unwrap_or_else(|poisoned| poisoned.into_inner());
5509            self.materializations
5510                .lock()
5511                .unwrap_or_else(|poisoned| poisoned.into_inner())
5512                .insert(id.clone(), ownership.clone());
5513            if let Err(error) = self.persist_state_locked() {
5514                drop(_transaction);
5515                let _ = self.cleanup_materialization(&id);
5516                return Err(persistence_failure(error));
5517            }
5518        }
5519        let guard = SessionMaterializationGuard {
5520            shared: self,
5521            id: Some(id),
5522            provider_home: Some(ownership.provider_home().to_path_buf()),
5523        };
5524        let bundle_arguments = match bundle {
5525            Some(receipt) => {
5526                let catalog = self.bundle_catalog.read()
5527                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5528                let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
5529                    NodeFailureCode::UnknownBundle,
5530                    "session bundle is unavailable on this node",
5531                ))?;
5532                if installed.receipt() != *receipt {
5533                    return Err(failure(
5534                        NodeFailureCode::BundleBindingMismatch,
5535                        "session bundle revision or digest changed",
5536                    ));
5537                }
5538                let layout = bundle_layout.ok_or_else(|| failure(
5539                    NodeFailureCode::BundleBindingMismatch,
5540                    "materialized bundle provider layout is unavailable",
5541                ))?;
5542                materializer.revalidate_bundle(&ownership, installed, layout).map_err(|_| {
5543                    failure(
5544                        NodeFailureCode::BundleMaterializationFailed,
5545                        "session bundle byte revalidation failed",
5546                    )
5547                })?;
5548                Some(bundle_launch_arguments(
5549                    layout,
5550                    ownership.bundle_root(),
5551                    ownership.provider_home(),
5552                ).map_err(|_| failure(
5553                    NodeFailureCode::BundleBindingMismatch,
5554                    "session bundle launch binding is invalid",
5555                ))?)
5556            }
5557            None => None,
5558        };
5559        if let Some(receipt) = context {
5560            materializer
5561                .revalidate_context(&ownership, receipt)
5562                .map_err(|_| failure(
5563                    NodeFailureCode::ContextPackMaterializationFailed,
5564                    "session context pack byte revalidation failed",
5565                ))?;
5566        }
5567        let transport = match mode {
5568            SessionMode::Pty => TransportKind::Pty,
5569            SessionMode::Inline => TransportKind::Pipe,
5570            SessionMode::Acp => TransportKind::Acp,
5571        };
5572        let overlay = if let Some(extra_args) = bundle_arguments {
5573            Some(PreparedNativeLaunchOverlay::Instance(
5574                NativeInstanceLaunchOverlay::new(
5575                    provider.clone(),
5576                    transport,
5577                    if has_environment_overlay {
5578                        environment
5579                    } else {
5580                        Vec::new()
5581                    },
5582                    extra_args,
5583                )
5584                .map_err(|_| {
5585                    failure(
5586                        NodeFailureCode::BundleBindingMismatch,
5587                        "session bundle launch overlay is invalid",
5588                    )
5589                })?,
5590            ))
5591        } else if has_environment_overlay {
5592            let context_only = environment_profile.is_none()
5593                && bundle.is_none()
5594                && context.is_some();
5595            let overlay = if context_only {
5596                NativeLaunchEnvironmentOverlay::new_context_root(
5597                    provider.clone(),
5598                    transport,
5599                    environment,
5600                )
5601                .map_err(|_| {
5602                    failure(
5603                        NodeFailureCode::ContextPackMaterializationFailed,
5604                        "context-only launch overlay is invalid",
5605                    )
5606                })?
5607            } else {
5608                NativeLaunchEnvironmentOverlay::new(
5609                    provider.clone(),
5610                    transport,
5611                    environment,
5612                )
5613                .map_err(|_| {
5614                    failure(
5615                        NodeFailureCode::BackendOperationFailed,
5616                        "session environment overlay is invalid",
5617                    )
5618                })?
5619            };
5620            Some(PreparedNativeLaunchOverlay::Environment(overlay))
5621        } else {
5622            None
5623        };
5624        Ok(Some((overlay, guard)))
5625    }
5626
5627    fn cleanup_materialization(&self, id: &MaterializationId) -> Result<(), NodeFailure> {
5628        let Some(mut ownership) = self
5629            .materializations
5630            .lock()
5631            .unwrap_or_else(|poisoned| poisoned.into_inner())
5632            .get(id)
5633            .cloned()
5634        else {
5635            return Ok(());
5636        };
5637        if ownership.state() != MaterializationState::CleanupRequired {
5638            ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5639                failure(
5640                    NodeFailureCode::BackendOperationFailed,
5641                    "session environment cleanup state is invalid",
5642                )
5643            })?;
5644        }
5645        {
5646            let _transaction = self
5647                .state_transaction
5648                .lock()
5649                .unwrap_or_else(|poisoned| poisoned.into_inner());
5650            let previous = self
5651                .materializations
5652                .lock()
5653                .unwrap_or_else(|poisoned| poisoned.into_inner())
5654                .insert(id.clone(), ownership.clone());
5655            if let Err(error) = self.persist_state_locked() {
5656                let mut materializations = self
5657                    .materializations
5658                    .lock()
5659                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5660                if let Some(previous) = previous {
5661                    materializations.insert(id.clone(), previous);
5662                }
5663                return Err(persistence_failure(error));
5664            }
5665        }
5666        let cleanup = self
5667            .session_environment_materializer
5668            .as_ref()
5669            .ok_or(())
5670            .and_then(|materializer| materializer.cleanup(&ownership).map_err(|_| ()));
5671        if cleanup.is_err() {
5672            let _ = ownership.mark_recovery_required(unix_time_ms());
5673            self.materializations
5674                .lock()
5675                .unwrap_or_else(|poisoned| poisoned.into_inner())
5676                .insert(id.clone(), ownership);
5677            if self.persist_state().is_err() {
5678                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5679            }
5680            return Err(failure(
5681                NodeFailureCode::BackendOperationFailed,
5682                "session environment cleanup requires recovery",
5683            ));
5684        }
5685        let _transaction = self
5686            .state_transaction
5687            .lock()
5688            .unwrap_or_else(|poisoned| poisoned.into_inner());
5689        self.materializations
5690            .lock()
5691            .unwrap_or_else(|poisoned| poisoned.into_inner())
5692            .remove(id);
5693        if let Err(error) = self.persist_state_locked() {
5694            self.materializations
5695                .lock()
5696                .unwrap_or_else(|poisoned| poisoned.into_inner())
5697                .insert(id.clone(), ownership);
5698            return Err(persistence_failure(error));
5699        }
5700        Ok(())
5701    }
5702
5703    fn resolve_record_materialization(
5704        &self,
5705        record_id: &SessionRecordId,
5706        provider: &AgentId,
5707        mode: SessionMode,
5708        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5709        bundle: Option<&ResolvedBundleReceipt>,
5710        context: Option<&ResolvedContextPackReceipt>,
5711    ) -> Result<Option<(MaterializationId, Option<PreparedNativeLaunchOverlay>)>, NodeFailure> {
5712        let ownership = {
5713            let materializations = self
5714                .materializations
5715                .lock()
5716                .unwrap_or_else(|poisoned| poisoned.into_inner());
5717            let mut matches = materializations.values().filter(|record| {
5718                record.owner()
5719                    == &MaterializationOwner::Record {
5720                        record_id: record_id.clone(),
5721                    }
5722            });
5723            let ownership = matches.next().cloned();
5724            if matches.next().is_some() {
5725                return Err(failure(
5726                    NodeFailureCode::BackendOperationFailed,
5727                    "managed session has conflicting environment ownership",
5728                ));
5729            }
5730            ownership
5731        };
5732        if let (Some(ownership), Some(receipt)) = (ownership.as_ref(), context) {
5733            let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5734                failure(
5735                    NodeFailureCode::ContextPackMaterializationFailed,
5736                    "session context materializer is unavailable",
5737                )
5738            })?;
5739            let pack = materializer
5740                .revalidate_context(ownership, receipt)
5741                .map_err(|_| {
5742                    self.mark_materialization_recovery_required(ownership.id());
5743                    failure(
5744                        NodeFailureCode::ContextPackMaterializationFailed,
5745                        "managed session context pack revalidation failed",
5746                    )
5747                })?;
5748            self.context_catalog
5749                .write()
5750                .unwrap_or_else(|poisoned| poisoned.into_inner())
5751                .insert(pack)
5752                .map_err(|_| failure(
5753                    NodeFailureCode::ContextPackMaterializationFailed,
5754                    "managed session context pack catalog restore failed",
5755                ))?;
5756        }
5757        let materialization = self.materialization_profile(
5758            provider,
5759            mode,
5760            environment_profile,
5761            bundle,
5762            context,
5763        )?;
5764        match (ownership, materialization) {
5765            (None, None) => Ok(None),
5766            (None, Some(_)) | (Some(_), None) => Err(failure(
5767                NodeFailureCode::BackendOperationFailed,
5768                "managed session materialization is unavailable",
5769            )),
5770            (Some(ownership), Some((profile, has_environment_materialization, bundle_layout))) => {
5771                if ownership.environment_profile() != environment_profile
5772                    || ownership.bundle() != bundle
5773                    || ownership.context() != context
5774                    || ownership.state() != MaterializationState::Ready
5775                {
5776                    self.mark_materialization_recovery_required(ownership.id());
5777                    return Err(failure(
5778                        NodeFailureCode::BackendOperationFailed,
5779                        "managed session environment ownership requires recovery",
5780                    ));
5781                }
5782                let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5783                    failure(
5784                        NodeFailureCode::BackendOperationFailed,
5785                        "session environment materializer is unavailable",
5786                    )
5787                })?;
5788                if materializer.revalidate(&ownership).is_err() {
5789                    self.mark_materialization_recovery_required(ownership.id());
5790                    return Err(failure(
5791                        NodeFailureCode::BackendOperationFailed,
5792                        "managed session environment revalidation failed",
5793                    ));
5794                }
5795                let bundle_arguments = if let Some(bundle_receipt) = bundle {
5796                    let catalog = self.bundle_catalog.read()
5797                        .unwrap_or_else(|poisoned| poisoned.into_inner());
5798                    let Some(installed) = catalog.get(&bundle_receipt.id) else {
5799                        return Err(failure(
5800                            NodeFailureCode::UnknownBundle,
5801                            "managed session bundle is unavailable",
5802                        ));
5803                    };
5804                    let layout = bundle_layout.ok_or_else(|| failure(
5805                        NodeFailureCode::BundleBindingMismatch,
5806                        "record bundle provider layout is unavailable",
5807                    ))?;
5808                    if materializer.revalidate_bundle(&ownership, installed, layout).is_err() {
5809                        drop(catalog);
5810                        self.mark_materialization_recovery_required(ownership.id());
5811                        return Err(failure(
5812                            NodeFailureCode::BundleMaterializationFailed,
5813                            "managed session bundle revalidation failed",
5814                        ));
5815                    }
5816                    Some(bundle_launch_arguments(
5817                        layout,
5818                        ownership.bundle_root(),
5819                        ownership.provider_home(),
5820                    ).map_err(|_| failure(
5821                        NodeFailureCode::BundleBindingMismatch,
5822                        "managed session bundle launch binding is invalid",
5823                    ))?)
5824                } else {
5825                    None
5826                };
5827                if !has_environment_materialization {
5828                    let overlay = bundle_arguments.map(|extra_args| {
5829                        NativeInstanceLaunchOverlay::new(
5830                            provider.clone(),
5831                            TransportKind::Pty,
5832                            Vec::new(),
5833                            extra_args,
5834                        )
5835                        .map(PreparedNativeLaunchOverlay::Instance)
5836                        .map_err(|_| failure(
5837                            NodeFailureCode::BundleBindingMismatch,
5838                            "managed session bundle launch overlay is invalid",
5839                        ))
5840                    }).transpose()?;
5841                    return Ok(Some((ownership.id().clone(), overlay)));
5842                }
5843                let environment = match materializer.resolve_environment(&ownership, &profile) {
5844                    Ok(environment) => environment,
5845                    Err(
5846                        SessionEnvironmentMaterializeError::SecretUnavailable
5847                        | SessionEnvironmentMaterializeError::SecretDenied
5848                        | SessionEnvironmentMaterializeError::InvalidSecretValue,
5849                    ) => {
5850                        return Err(failure(
5851                            NodeFailureCode::BackendOperationFailed,
5852                            "managed session environment resolution failed",
5853                        ));
5854                    }
5855                    Err(
5856                        SessionEnvironmentMaterializeError::InvalidRoot
5857                        | SessionEnvironmentMaterializeError::OwnershipMismatch
5858                        | SessionEnvironmentMaterializeError::Filesystem(_)
5859                        | SessionEnvironmentMaterializeError::Record(_),
5860                    ) => {
5861                        self.mark_materialization_recovery_required(ownership.id());
5862                        return Err(failure(
5863                            NodeFailureCode::BackendOperationFailed,
5864                            "managed session environment resolution failed",
5865                        ));
5866                    }
5867                };
5868                let transport = match mode {
5869                    SessionMode::Pty => TransportKind::Pty,
5870                    SessionMode::Inline => TransportKind::Pipe,
5871                    SessionMode::Acp => TransportKind::Acp,
5872                };
5873                let overlay = match bundle_arguments {
5874                    Some(extra_args) => PreparedNativeLaunchOverlay::Instance(
5875                        NativeInstanceLaunchOverlay::new(
5876                            provider.clone(),
5877                            transport,
5878                            environment,
5879                            extra_args,
5880                        )
5881                        .map_err(|_| failure(
5882                            NodeFailureCode::BundleBindingMismatch,
5883                            "managed session bundle launch overlay is invalid",
5884                        ))?,
5885                    ),
5886                    None => PreparedNativeLaunchOverlay::Environment(
5887                        NativeLaunchEnvironmentOverlay::new(
5888                            provider.clone(),
5889                            transport,
5890                            environment,
5891                        )
5892                        .map_err(|_| {
5893                            failure(
5894                                NodeFailureCode::BackendOperationFailed,
5895                                "managed session environment overlay is invalid",
5896                            )
5897                        })?,
5898                    ),
5899                };
5900                Ok(Some((ownership.id().clone(), Some(overlay))))
5901            }
5902        }
5903    }
5904
5905    fn destroy_record_materialization(
5906        &self,
5907        record_id: &SessionRecordId,
5908    ) -> Result<bool, NodeFailure> {
5909        let ids = self
5910            .materializations
5911            .lock()
5912            .unwrap_or_else(|poisoned| poisoned.into_inner())
5913            .values()
5914            .filter_map(|record| {
5915                (record.owner()
5916                    == &MaterializationOwner::Record {
5917                        record_id: record_id.clone(),
5918                    })
5919                .then(|| record.id().clone())
5920            })
5921            .collect::<Vec<_>>();
5922        if ids.len() > 1 {
5923            return Err(failure(
5924                NodeFailureCode::BackendOperationFailed,
5925                "managed session has conflicting environment ownership",
5926            ));
5927        }
5928        let Some(id) = ids.first().cloned() else {
5929            return Ok(false);
5930        };
5931        let (terminal_record, mut cleanup_ownership) = {
5932            let _transaction = self
5933                .state_transaction
5934                .lock()
5935                .unwrap_or_else(|poisoned| poisoned.into_inner());
5936            let original_record = self.record(record_id)?;
5937            let mut terminal_record = original_record.clone();
5938            terminal_record.provider_session = None;
5939            terminal_record.active_session = None;
5940            terminal_record.state = ManagedSessionState::Unavailable;
5941            terminal_record.last_error = Some("environment-profile-unavailable".to_owned());
5942            terminal_record.updated_at_unix_ms = unix_time_ms();
5943            let original_ownership = self
5944                .materializations
5945                .lock()
5946                .unwrap_or_else(|poisoned| poisoned.into_inner())
5947                .get(&id)
5948                .cloned()
5949                .ok_or_else(|| {
5950                    failure(
5951                        NodeFailureCode::BackendOperationFailed,
5952                        "managed session environment ownership disappeared during cleanup",
5953                    )
5954                })?;
5955            let mut cleanup_ownership = original_ownership.clone();
5956            if cleanup_ownership.state() != MaterializationState::CleanupRequired {
5957                cleanup_ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5958                    failure(
5959                        NodeFailureCode::BackendOperationFailed,
5960                        "managed session environment cleanup state is invalid",
5961                    )
5962                })?;
5963            }
5964            self.session_records
5965                .lock()
5966                .unwrap_or_else(|poisoned| poisoned.into_inner())
5967                .records
5968                .insert(record_id.clone(), terminal_record.clone());
5969            self.materializations
5970                .lock()
5971                .unwrap_or_else(|poisoned| poisoned.into_inner())
5972                .insert(id.clone(), cleanup_ownership.clone());
5973            if let Err(error) = self.persist_state_locked() {
5974                self.session_records
5975                    .lock()
5976                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5977                    .records
5978                    .insert(record_id.clone(), original_record);
5979                self.materializations
5980                    .lock()
5981                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5982                    .insert(id.clone(), original_ownership);
5983                return Err(persistence_failure(error));
5984            }
5985            (terminal_record, cleanup_ownership)
5986        };
5987        let cleanup = self
5988            .session_environment_materializer
5989            .as_ref()
5990            .ok_or(())
5991            .and_then(|materializer| materializer.cleanup(&cleanup_ownership).map_err(|_| ()));
5992        if cleanup.is_err() {
5993            let _ = cleanup_ownership.mark_recovery_required(unix_time_ms());
5994            self.materializations
5995                .lock()
5996                .unwrap_or_else(|poisoned| poisoned.into_inner())
5997                .insert(id.clone(), cleanup_ownership);
5998            if self.persist_state().is_err() {
5999                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6000            }
6001            return Err(failure(
6002                NodeFailureCode::BackendOperationFailed,
6003                "managed session environment cleanup requires recovery",
6004            ));
6005        }
6006        let _transaction = self
6007            .state_transaction
6008            .lock()
6009            .unwrap_or_else(|poisoned| poisoned.into_inner());
6010        self.remove_record_memory(record_id);
6011        self.materializations
6012            .lock()
6013            .unwrap_or_else(|poisoned| poisoned.into_inner())
6014            .remove(&id);
6015        if let Err(error) = self.persist_state_locked() {
6016            self.session_records
6017                .lock()
6018                .unwrap_or_else(|poisoned| poisoned.into_inner())
6019                .records
6020                .insert(record_id.clone(), terminal_record);
6021            self.materializations
6022                .lock()
6023                .unwrap_or_else(|poisoned| poisoned.into_inner())
6024                .insert(id, cleanup_ownership);
6025            return Err(persistence_failure(error));
6026        }
6027        Ok(true)
6028    }
6029
6030    fn mark_materialization_recovery_required(&self, id: &MaterializationId) {
6031        let changed = {
6032            let mut materializations = self
6033                .materializations
6034                .lock()
6035                .unwrap_or_else(|poisoned| poisoned.into_inner());
6036            let Some(ownership) = materializations.get_mut(id) else {
6037                return;
6038            };
6039            ownership.mark_recovery_required(unix_time_ms()).is_ok()
6040        };
6041        if changed && self.persist_state().is_err() {
6042            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6043        }
6044    }
6045
6046    fn reconcile_materializations(&self) {
6047        let records = self
6048            .materializations
6049            .lock()
6050            .unwrap_or_else(|poisoned| poisoned.into_inner())
6051            .values()
6052            .cloned()
6053            .collect::<Vec<_>>();
6054        for mut ownership in records {
6055            let result = match (ownership.state(), ownership.owner()) {
6056                (MaterializationState::Preparing, _)
6057                | (MaterializationState::CleanupRequired, _) => {
6058                    self.cleanup_materialization(ownership.id())
6059                }
6060                (MaterializationState::Ready, MaterializationOwner::Session { .. }) => {
6061                    if ownership.mark_recovery_required(unix_time_ms()).is_ok() {
6062                        self.materializations
6063                            .lock()
6064                            .unwrap_or_else(|poisoned| poisoned.into_inner())
6065                            .insert(ownership.id().clone(), ownership.clone());
6066                        self.persist_state().map_err(persistence_failure)
6067                    } else {
6068                        Err(failure(
6069                            NodeFailureCode::BackendOperationFailed,
6070                            "session environment reconciliation failed",
6071                        ))
6072                    }
6073                }
6074                (MaterializationState::Ready, MaterializationOwner::Record { record_id }) => self
6075                    .session_environment_materializer
6076                    .as_ref()
6077                    .ok_or_else(|| {
6078                        failure(
6079                            NodeFailureCode::BackendOperationFailed,
6080                            "session environment materializer is unavailable",
6081                        )
6082                    })
6083                    .and_then(|materializer| {
6084                        materializer.revalidate(&ownership).map_err(|_| failure(
6085                            NodeFailureCode::BackendOperationFailed,
6086                            "session materialization revalidation failed",
6087                        ))?;
6088                        let record = self.record(record_id)?;
6089                        if record.environment_profile.as_ref() != ownership.environment_profile()
6090                            || record.bundle.as_ref() != ownership.bundle()
6091                            || record.context.as_ref() != ownership.context()
6092                        {
6093                            return Err(failure(
6094                                NodeFailureCode::BackendOperationFailed,
6095                                "session materialization ownership changed after restart",
6096                            ));
6097                        }
6098                        if let Some(receipt) = ownership.bundle() {
6099                            let catalog = self.bundle_catalog.read()
6100                                .unwrap_or_else(|poisoned| poisoned.into_inner());
6101                            let bundle = catalog.get(&receipt.id).ok_or_else(|| failure(
6102                                NodeFailureCode::UnknownBundle,
6103                                "session bundle is unavailable after restart",
6104                            ))?;
6105                            if bundle.receipt() != *receipt {
6106                                return Err(failure(
6107                                    NodeFailureCode::BundleBindingMismatch,
6108                                    "session bundle changed after restart",
6109                                ));
6110                            }
6111                            let layout = self.bundle_layout(
6112                                &record.provider,
6113                                record.mode,
6114                                record.environment_profile.as_ref(),
6115                                bundle,
6116                            )?;
6117                            materializer
6118                                .revalidate_bundle(&ownership, bundle, layout)
6119                                .map_err(|_| failure(
6120                                    NodeFailureCode::BundleMaterializationFailed,
6121                                    "session bundle revalidation failed",
6122                                ))?;
6123                        }
6124                        if let Some(receipt) = ownership.context() {
6125                            let pack = materializer
6126                                .revalidate_context(&ownership, receipt)
6127                                .map_err(|_| {
6128                                    failure(
6129                                        NodeFailureCode::ContextPackMaterializationFailed,
6130                                        "session context revalidation failed",
6131                                    )
6132                                })?;
6133                            self.context_catalog
6134                                .write()
6135                                .unwrap_or_else(|poisoned| poisoned.into_inner())
6136                                .insert(pack)
6137                                .map_err(|_| {
6138                                    failure(
6139                                        NodeFailureCode::ContextPackMaterializationFailed,
6140                                        "session context catalog restore failed",
6141                                    )
6142                                })?;
6143                        }
6144                        Ok(())
6145                    }),
6146                (MaterializationState::RecoveryRequired, _) => Ok(()),
6147            };
6148            if result.is_err() && ownership.state() != MaterializationState::RecoveryRequired {
6149                let _ = ownership.mark_recovery_required(unix_time_ms());
6150                self.materializations
6151                    .lock()
6152                    .unwrap_or_else(|poisoned| poisoned.into_inner())
6153                    .insert(ownership.id().clone(), ownership);
6154                if self.persist_state().is_err() {
6155                    self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6156                }
6157            }
6158        }
6159        let materialized_profiles = self
6160            .environment_materialization_profiles
6161            .read()
6162            .unwrap_or_else(|poisoned| poisoned.into_inner())
6163            .keys()
6164            .cloned()
6165            .collect::<Vec<_>>();
6166        let ownership = self
6167            .materializations
6168            .lock()
6169            .unwrap_or_else(|poisoned| poisoned.into_inner())
6170            .values()
6171            .cloned()
6172            .collect::<Vec<_>>();
6173        let changed = {
6174            let mut session_records = self
6175                .session_records
6176                .lock()
6177                .unwrap_or_else(|poisoned| poisoned.into_inner());
6178            let mut changed = false;
6179            for record in session_records.records.values_mut() {
6180                let environment_profile = record.environment_profile.as_ref();
6181                if environment_profile.is_none()
6182                    && record.bundle.is_none()
6183                    && record.context.is_none()
6184                {
6185                    continue;
6186                }
6187                if record.bundle.is_none()
6188                    && environment_profile.is_some_and(|profile| {
6189                        !materialized_profiles.contains(&profile.profile_id)
6190                    })
6191                {
6192                    continue;
6193                }
6194                let has_exact_ownership = ownership.iter().any(|candidate| {
6195                    candidate.state() == MaterializationState::Ready
6196                        && candidate.environment_profile() == environment_profile
6197                        && candidate.bundle() == record.bundle.as_ref()
6198                        && candidate.context() == record.context.as_ref()
6199                        && candidate.owner()
6200                            == &MaterializationOwner::Record {
6201                                record_id: record.record_id.clone(),
6202                            }
6203                });
6204                if !has_exact_ownership {
6205                    record.provider_session = None;
6206                    record.active_session = None;
6207                    record.state = ManagedSessionState::Unavailable;
6208                    record.last_error = Some(if record.bundle.is_some() {
6209                        "bundle-unavailable".to_owned()
6210                    } else if record.context.is_some() {
6211                        "context-unavailable".to_owned()
6212                    } else {
6213                        "environment-profile-unavailable".to_owned()
6214                    });
6215                    record.updated_at_unix_ms = unix_time_ms();
6216                    changed = true;
6217                }
6218            }
6219            changed
6220        };
6221        if changed && self.persist_state().is_err() {
6222            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6223        }
6224    }
6225
6226    fn replay_spawn_spec(
6227        &self,
6228        spec: &SpawnSpec,
6229        now: Instant,
6230    ) -> Result<Option<Result<ResolvedSpawnReceipt, NodeFailure>>, NodeFailure> {
6231        let mut cache = self
6232            .spawn_idempotency
6233            .lock()
6234            .unwrap_or_else(|poisoned| poisoned.into_inner());
6235        cache.entries.retain(|_, entry| entry.expires_at > now);
6236        if let Some(entry) = cache.entries.get(&spec.idempotency_key) {
6237            let SpawnIdempotencyValue::Standard { spec: existing, result } = &entry.value else {
6238                return Err(failure(
6239                    NodeFailureCode::SpawnIdempotencyConflict,
6240                    "spawn idempotency key was reused across spawn kinds",
6241                ));
6242            };
6243            if existing != spec {
6244                return Err(failure(
6245                    NodeFailureCode::SpawnIdempotencyConflict,
6246                    "spawn idempotency key was reused with a different specification",
6247                ));
6248            }
6249            return Ok(Some(result.clone()));
6250        }
6251        if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
6252            return Err(failure(
6253                NodeFailureCode::SpawnIdempotencyCapacity,
6254                "spawn idempotency cache reached its bounded capacity",
6255            ));
6256        }
6257        Ok(None)
6258    }
6259
6260    fn remember_spawn_spec(
6261        &self,
6262        spec: SpawnSpec,
6263        result: Result<ResolvedSpawnReceipt, NodeFailure>,
6264        accepted_at: Instant,
6265    ) {
6266        self
6267            .spawn_idempotency
6268            .lock()
6269            .unwrap_or_else(|poisoned| poisoned.into_inner())
6270            .entries
6271            .insert(
6272                spec.idempotency_key.clone(),
6273                SpawnIdempotencyEntry {
6274                    value: SpawnIdempotencyValue::Standard { spec, result },
6275                    expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
6276                },
6277            );
6278    }
6279
6280    fn claim_harness_mcp_spawn(
6281        &self,
6282        _reservation_id: &HarnessMcpReservationId,
6283        _activation_digest: &HarnessMcpActivationDigest,
6284        spec: &SpawnSpec,
6285        now: Instant,
6286    ) -> Result<(), NodeFailure> {
6287        let mut cache = self.spawn_idempotency.lock()
6288            .unwrap_or_else(|poisoned| poisoned.into_inner());
6289        cache.entries.retain(|_, entry| entry.expires_at > now);
6290        if cache.entries.contains_key(&spec.idempotency_key) {
6291            return Err(failure(
6292                NodeFailureCode::SpawnIdempotencyConflict,
6293                "spawn idempotency key was reused across spawn kinds",
6294            ));
6295        }
6296        if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
6297            return Err(failure(
6298                NodeFailureCode::SpawnIdempotencyCapacity,
6299                "spawn idempotency cache reached its bounded capacity",
6300            ));
6301        }
6302        cache.entries.insert(spec.idempotency_key.clone(), SpawnIdempotencyEntry {
6303            value: SpawnIdempotencyValue::HarnessMcp,
6304            expires_at: now + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
6305        });
6306        Ok(())
6307    }
6308
6309    fn replay_managed_spawn(
6310        &self,
6311        request: &ManagedWorktreeSpawnRequest,
6312        now: Instant,
6313    ) -> Result<Option<Result<ManagedWorktreeSpawnReceipt, NodeFailure>>, NodeFailure> {
6314        let mut cache = self.spawn_idempotency.lock()
6315            .unwrap_or_else(|poisoned| poisoned.into_inner());
6316        cache.entries.retain(|_, entry| entry.expires_at > now);
6317        if let Some(entry) = cache.entries.get(&request.spawn_spec.idempotency_key) {
6318            let SpawnIdempotencyValue::Managed { request: existing, result } = &entry.value else {
6319                return Err(failure(
6320                    NodeFailureCode::SpawnIdempotencyConflict,
6321                    "spawn idempotency key was reused across spawn kinds",
6322                ));
6323            };
6324            if existing != request {
6325                return Err(failure(
6326                    NodeFailureCode::SpawnIdempotencyConflict,
6327                    "spawn idempotency key was reused with a different managed specification",
6328                ));
6329            }
6330            return Ok(Some(result.clone()));
6331        }
6332        if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
6333            return Err(failure(
6334                NodeFailureCode::SpawnIdempotencyCapacity,
6335                "spawn idempotency cache reached its bounded capacity",
6336            ));
6337        }
6338        Ok(None)
6339    }
6340
6341    fn remember_managed_spawn(
6342        &self,
6343        request: ManagedWorktreeSpawnRequest,
6344        result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
6345        accepted_at: Instant,
6346    ) {
6347        self.spawn_idempotency.lock()
6348            .unwrap_or_else(|poisoned| poisoned.into_inner())
6349            .entries
6350            .insert(
6351                request.spawn_spec.idempotency_key.clone(),
6352                SpawnIdempotencyEntry {
6353                    value: SpawnIdempotencyValue::Managed { request, result },
6354                    expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
6355                },
6356            );
6357    }
6358
6359    fn remember_managed_spawn_attempt(
6360        &self,
6361        durable_v2_key: Option<&SpawnIdempotencyKey>,
6362        request: ManagedWorktreeSpawnRequest,
6363        result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
6364        accepted_at: Instant,
6365    ) {
6366        if durable_v2_key.is_none() {
6367            self.remember_managed_spawn(request, result, accepted_at);
6368        }
6369    }
6370
6371    fn acquire_durable_managed_spawn_v2(
6372        &self,
6373        request: &ManagedWorktreeSpawnRequestV2,
6374        request_digest: String,
6375    ) -> Result<DurableManagedSpawnReplayDecision, NodeFailure> {
6376        if self.state_path.is_none() {
6377            return Err(failure(
6378                NodeFailureCode::BackendOperationFailed,
6379                "managed worktree V2 requires durable node state",
6380            ));
6381        }
6382        let _transaction = self.state_transaction
6383            .lock()
6384            .unwrap_or_else(|poisoned| poisoned.into_inner());
6385        let key = request.spawn_spec.idempotency_key.clone();
6386        let mut previous = None;
6387        {
6388            let mut replays = self.managed_spawn_replays
6389                .lock()
6390                .unwrap_or_else(|poisoned| poisoned.into_inner());
6391            if let Some(record) = replays.get_mut(&key) {
6392                if record.request_digest != request_digest {
6393                    return Err(failure(
6394                        NodeFailureCode::SpawnIdempotencyConflict,
6395                        "managed worktree V2 idempotency key was reused with a different request",
6396                    ));
6397                }
6398                match &record.state {
6399                    ManagedWorktreeSpawnReplayStateV10::Pending {
6400                        owner_incarnation_id,
6401                        lease_id: None,
6402                    } if owner_incarnation_id != &self.incarnation_id => {
6403                        previous = Some(record.clone());
6404                        record.state = ManagedWorktreeSpawnReplayStateV10::Pending {
6405                            owner_incarnation_id: self.incarnation_id,
6406                            lease_id: None,
6407                        };
6408                        record.updated_at_unix_ms = unix_time_ms();
6409                    }
6410                    ManagedWorktreeSpawnReplayStateV10::Pending {
6411                        lease_id: None,
6412                        ..
6413                    } => {
6414                        return Err(failure(
6415                            NodeFailureCode::BackendBusy,
6416                            "managed worktree V2 reservation is already active",
6417                        ));
6418                    }
6419                    ManagedWorktreeSpawnReplayStateV10::Pending {
6420                        lease_id: Some(_),
6421                        ..
6422                    } => {
6423                        return Ok(DurableManagedSpawnReplayDecision::PendingLinked);
6424                    }
6425                    ManagedWorktreeSpawnReplayStateV10::Committed { receipt } => {
6426                        return Ok(DurableManagedSpawnReplayDecision::Committed(
6427                            receipt.clone(),
6428                        ));
6429                    }
6430                }
6431            } else if replays.len() >= MAX_MANAGED_WORKTREE_SPAWN_REPLAYS {
6432                return Err(failure(
6433                    NodeFailureCode::SpawnIdempotencyCapacity,
6434                    "durable managed worktree replay ledger reached its bounded capacity",
6435                ));
6436            } else {
6437                let now = unix_time_ms();
6438                replays.insert(key.clone(), ManagedWorktreeSpawnReplayRecordV10 {
6439                    idempotency_key: key.clone(),
6440                    request_digest,
6441                    source_workspace_id: request.spawn_spec.target.workspace_id.clone(),
6442                    profile_id: request.worktree_profile_id.clone(),
6443                    expected_profile_revision: request.expected_profile_revision.clone(),
6444                    state: ManagedWorktreeSpawnReplayStateV10::Pending {
6445                        owner_incarnation_id: self.incarnation_id,
6446                        lease_id: None,
6447                    },
6448                    created_at_unix_ms: now,
6449                    updated_at_unix_ms: now,
6450                });
6451            }
6452        }
6453        if let Err(error) = self.persist_state_locked() {
6454            let mut replays = self.managed_spawn_replays
6455                .lock()
6456                .unwrap_or_else(|poisoned| poisoned.into_inner());
6457            match previous {
6458                Some(previous) => {
6459                    replays.insert(key, previous);
6460                }
6461                None => {
6462                    replays.remove(&key);
6463                }
6464            }
6465            return Err(persistence_failure(error));
6466        }
6467        Ok(DurableManagedSpawnReplayDecision::Reserved)
6468    }
6469
6470    fn link_durable_managed_spawn_v2_lease_locked(
6471        &self,
6472        key: &SpawnIdempotencyKey,
6473        lease_id: &ManagedWorktreeLeaseId,
6474    ) -> Result<ManagedWorktreeSpawnReplayRecordV10, NodeFailure> {
6475        let mut replays = self.managed_spawn_replays
6476            .lock()
6477            .unwrap_or_else(|poisoned| poisoned.into_inner());
6478        let record = replays.get_mut(key).ok_or_else(|| {
6479            failure(
6480                NodeFailureCode::ManagedWorktreeRecoveryRequired,
6481                "managed worktree V2 reservation disappeared before allocation",
6482            )
6483        })?;
6484        let previous = record.clone();
6485        match &mut record.state {
6486            ManagedWorktreeSpawnReplayStateV10::Pending {
6487                lease_id: current,
6488                ..
6489            }
6490                if current.is_none() =>
6491            {
6492                *current = Some(lease_id.clone());
6493                record.updated_at_unix_ms = unix_time_ms();
6494                Ok(previous)
6495            }
6496            _ => Err(failure(
6497                NodeFailureCode::ManagedWorktreeRecoveryRequired,
6498                "managed worktree V2 reservation is not allocation-ready",
6499            )),
6500        }
6501    }
6502
6503    fn restore_durable_managed_spawn_v2_locked(
6504        &self,
6505        key: &SpawnIdempotencyKey,
6506        previous: ManagedWorktreeSpawnReplayRecordV10,
6507    ) {
6508        self.managed_spawn_replays
6509            .lock()
6510            .unwrap_or_else(|poisoned| poisoned.into_inner())
6511            .insert(key.clone(), previous);
6512    }
6513
6514    fn commit_durable_managed_spawn_v2(
6515        &self,
6516        request: &ManagedWorktreeSpawnRequestV2,
6517        receipt: ManagedWorktreeSpawnReceipt,
6518    ) -> Result<(), NodeFailure> {
6519        let _transaction = self.state_transaction
6520            .lock()
6521            .unwrap_or_else(|poisoned| poisoned.into_inner());
6522        let key = &request.spawn_spec.idempotency_key;
6523        let previous = {
6524            let mut replays = self.managed_spawn_replays
6525                .lock()
6526                .unwrap_or_else(|poisoned| poisoned.into_inner());
6527            let record = replays.get_mut(key).ok_or_else(|| {
6528                failure(
6529                    NodeFailureCode::ManagedWorktreeRecoveryRequired,
6530                    "managed worktree V2 reservation disappeared before commit",
6531                )
6532            })?;
6533            let previous = record.clone();
6534            match &record.state {
6535                ManagedWorktreeSpawnReplayStateV10::Pending {
6536                    lease_id: Some(lease_id),
6537                    ..
6538                } if lease_id == &receipt.lease.lease_id => {}
6539                _ => {
6540                    return Err(failure(
6541                        NodeFailureCode::ManagedWorktreeRecoveryRequired,
6542                        "managed worktree V2 reservation is not linked to the spawned lease",
6543                    ));
6544                }
6545            }
6546            record.state = ManagedWorktreeSpawnReplayStateV10::Committed { receipt };
6547            record.updated_at_unix_ms = unix_time_ms();
6548            previous
6549        };
6550        if let Err(error) = self.persist_state_locked() {
6551            self.restore_durable_managed_spawn_v2_locked(key, previous);
6552            return Err(persistence_failure(error));
6553        }
6554        Ok(())
6555    }
6556
6557    fn remove_unallocated_durable_managed_spawn_v2(
6558        &self,
6559        request: &ManagedWorktreeSpawnRequestV2,
6560    ) -> Result<(), NodeFailure> {
6561        let _transaction = self.state_transaction
6562            .lock()
6563            .unwrap_or_else(|poisoned| poisoned.into_inner());
6564        let key = &request.spawn_spec.idempotency_key;
6565        let removed = {
6566            let mut replays = self.managed_spawn_replays
6567                .lock()
6568                .unwrap_or_else(|poisoned| poisoned.into_inner());
6569            match replays.get(key).map(|record| &record.state) {
6570                Some(ManagedWorktreeSpawnReplayStateV10::Pending {
6571                    lease_id: None,
6572                    ..
6573                }) => {
6574                    replays.remove(key)
6575                }
6576                _ => None,
6577            }
6578        };
6579        let Some(removed) = removed else {
6580            return Ok(());
6581        };
6582        if let Err(error) = self.persist_state_locked() {
6583            self.managed_spawn_replays
6584                .lock()
6585                .unwrap_or_else(|poisoned| poisoned.into_inner())
6586                .insert(key.clone(), removed);
6587            return Err(persistence_failure(error));
6588        }
6589        Ok(())
6590    }
6591
6592    fn resolve_spawn_spec(&self, spec: &SpawnSpec) -> Result<ResolvedSpawnSpec, NodeFailure> {
6593        if spec.target.node_id != self.node_id {
6594            return Err(failure(
6595                NodeFailureCode::SpawnTargetMismatch,
6596                "spawn target does not match this node",
6597            ));
6598        }
6599        let defaults = self.spawn_profiles.get(&spec.profile_id).ok_or_else(|| {
6600            failure(
6601                NodeFailureCode::UnknownSpawnProfile,
6602                "spawn profile is unavailable on this node",
6603            )
6604        })?;
6605        let resolved = spec.resolve(defaults).map_err(|error| match error {
6606            SpawnSpecResolveError::ProfileRevisionMismatch { .. } => failure(
6607                NodeFailureCode::SpawnProfileRevisionMismatch,
6608                "spawn profile revision does not match the loaded profile",
6609            ),
6610            _ => failure(
6611                NodeFailureCode::InvalidRequest,
6612                "spawn specification could not be resolved",
6613            ),
6614        })?;
6615        if resolved.target.worktree_id.is_some() {
6616            self.require_worktree_service(&resolved.target.workspace_id)?;
6617        }
6618        let environment_profile = self.resolve_environment_profile(&resolved)?;
6619        self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6620        self.resolve_context(&resolved)?;
6621        Ok(resolved)
6622    }
6623
6624    async fn resolve_spawn_workspace(
6625        &self,
6626        resolved: &ResolvedSpawnSpec,
6627        deadline: Instant,
6628    ) -> Result<WorkspaceId, NodeFailure> {
6629        let Some(worktree_id) = resolved.target.worktree_id.as_ref() else {
6630            return Ok(resolved.target.workspace_id.clone());
6631        };
6632        if self.managed_worktrees.lock()
6633            .unwrap_or_else(|poisoned| poisoned.into_inner())
6634            .lease_for_workspace(worktree_id)
6635            .is_some()
6636        {
6637            return Err(failure(
6638                NodeFailureCode::ManagedWorktreeOwnershipConflict,
6639                "managed worktree targets may only be spawned through their lease request",
6640            ));
6641        }
6642        self.require_worktree_service(&resolved.target.workspace_id)?;
6643        let source_root = self.workspace_root(&resolved.target.workspace_id)?;
6644        let selected_root = self.workspace_root(worktree_id)?;
6645        let remaining = spawn_deadline_remaining(deadline)?;
6646        let worktrees = timeout(remaining, list_git_worktrees(&source_root))
6647            .await
6648            .map_err(|_| {
6649                failure(
6650                    NodeFailureCode::SpawnDeadlineExceeded,
6651                    "Git worktree selection exceeded the spawn deadline",
6652                )
6653            })?
6654            .map_err(git_worktree_failure)?;
6655        validate_selected_worktree(&source_root, &selected_root, &worktrees)?;
6656        Ok(worktree_id.clone())
6657    }
6658
6659    async fn spawn_from_spec(
6660        &self,
6661        spec: SpawnSpec,
6662    ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6663        self.spawn_from_spec_with_proxy(spec, None).await
6664    }
6665
6666    async fn spawn_from_spec_with_proxy(
6667        &self,
6668        spec: SpawnSpec,
6669        harness_mcp: Option<&PreparedHarnessMcpSpawn>,
6670    ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6671        let accepted_at = Instant::now();
6672        if harness_mcp.is_none() {
6673            if let Some(replayed) = self.replay_spawn_spec(&spec, accepted_at)? {
6674                return replayed;
6675            }
6676        }
6677        let resolved = self.resolve_spawn_spec(&spec)?;
6678        let environment_profile = self.resolve_environment_profile(&resolved)?;
6679        let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6680        let context = self.resolve_context(&resolved)?;
6681        let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6682        let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6683        let spawn_workspace_id = self.resolve_spawn_workspace(&resolved, deadline).await;
6684        let spawn_workspace_id = match spawn_workspace_id {
6685            Ok(workspace_id) => workspace_id,
6686            Err(error) => {
6687                let result = Err(error);
6688                if harness_mcp.is_none() {
6689                    self.remember_spawn_spec(spec, result.clone(), accepted_at);
6690                }
6691                return result;
6692            }
6693        };
6694        if self.managed_worktrees.lock()
6695            .unwrap_or_else(|poisoned| poisoned.into_inner())
6696            .lease_for_workspace(&spawn_workspace_id)
6697            .is_some()
6698        {
6699            let result = Err(failure(
6700                NodeFailureCode::ManagedWorktreeOwnershipConflict,
6701                "managed worktree targets may only be spawned through their lease request",
6702            ));
6703            if harness_mcp.is_none() {
6704                self.remember_spawn_spec(spec, result.clone(), accepted_at);
6705            }
6706            return result;
6707        }
6708        let result = self
6709            .spawn_session_with_deadline(
6710                spawn_workspace_id,
6711                resolved.provider.clone(),
6712                resolved.mode,
6713                resolved.terminal_size,
6714                resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
6715                environment_profile.clone(),
6716                bundle.clone(),
6717                context.clone(),
6718                None,
6719                None,
6720                SpawnRecordPolicy::Always,
6721                Some(deadline),
6722                &required_capabilities,
6723                harness_mcp,
6724                resolved.approval_level,
6725                resolved.network_allowlist.clone(),
6726                resolved.browser_profile_id.clone(),
6727            )
6728            .await
6729            .map(|(session, _runtime_policy)| {
6730                resolved.receipt_with_materialization(
6731                    self.incarnation_id,
6732                    session,
6733                    environment_profile,
6734                    bundle,
6735                    context,
6736                )
6737            });
6738        if harness_mcp.is_none() {
6739            self.remember_spawn_spec(spec, result.clone(), accepted_at);
6740        }
6741        result
6742    }
6743
6744    async fn spawn_from_spec_with_harness_mcp(
6745        &self,
6746        reservation_id: HarnessMcpReservationId,
6747        activation_digest: HarnessMcpActivationDigest,
6748        spec: SpawnSpec,
6749        deadline_unix_ms: u64,
6750    ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6751        let registry = self.harness_mcp_registry.as_ref().ok_or_else(|| {
6752            failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
6753        })?;
6754        if let Some(receipt) = registry
6755            .replay_spawn(&reservation_id, &activation_digest, &spec)
6756            .map_err(harness_mcp_failure)?
6757        {
6758            return Ok(receipt);
6759        }
6760        let resolved = self.resolve_spawn_spec(&spec)?;
6761        let prepared = registry
6762            .prepare_spawn(
6763                &reservation_id,
6764                &activation_digest,
6765                &spec,
6766                deadline_unix_ms,
6767                resolved.provider.clone(),
6768            )
6769            .map_err(harness_mcp_failure)?;
6770        self.claim_harness_mcp_spawn(
6771            &reservation_id,
6772            &activation_digest,
6773            &spec,
6774            Instant::now(),
6775        )?;
6776        let spawn = self.spawn_from_spec_with_proxy(spec, Some(&prepared)).await;
6777        let mut receipt = match spawn {
6778            Ok(receipt) => receipt,
6779            Err(error) => {
6780                let _ = registry.abort(&reservation_id, &activation_digest);
6781                return Err(error);
6782            }
6783        };
6784        let record_id = self
6785            .session_bindings
6786            .lock()
6787            .unwrap_or_else(|poisoned| poisoned.into_inner())
6788            .get(&receipt.session.session.instance_id)
6789            .and_then(|binding| binding.record_id.clone());
6790        let record_id = match record_id {
6791            Some(record_id) => record_id,
6792            None => {
6793                let rollback = self.remove_session(&receipt.session).await;
6794                let _ = registry.abort(&reservation_id, &activation_digest);
6795                if let Err(rollback_error) = rollback {
6796                    return Err(rollback_error);
6797                }
6798                return Err(failure(
6799                    NodeFailureCode::BindingMismatch,
6800                    "harness MCP spawn did not retain its exact managed record",
6801                ));
6802            }
6803        };
6804        receipt.harness_mcp_proxy = Some(ResolvedHarnessMcpProxyReceiptV1 {
6805            reservation_id: reservation_id.clone(),
6806            activation_digest: activation_digest.clone(),
6807        });
6808        if let Err(error) = registry.mark_spawned(
6809            &prepared,
6810            receipt.session.clone(),
6811            record_id,
6812            receipt.clone(),
6813        ) {
6814            let rollback = self.remove_session(&receipt.session).await;
6815            let _ = registry.abort(&reservation_id, &activation_digest);
6816            if let Err(rollback_error) = rollback {
6817                return Err(rollback_error);
6818            }
6819            return Err(harness_mcp_failure(error));
6820        }
6821        Ok(receipt)
6822    }
6823
6824    async fn spawn_managed_worktree(
6825        &self,
6826        request: ManagedWorktreeSpawnRequest,
6827    ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6828        self.spawn_managed_worktree_inner(request, None).await
6829    }
6830
6831    async fn spawn_managed_worktree_inner(
6832        &self,
6833        request: ManagedWorktreeSpawnRequest,
6834        durable_v2_key: Option<&SpawnIdempotencyKey>,
6835    ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6836        let accepted_at = Instant::now();
6837        if durable_v2_key.is_none() {
6838            if let Some(replayed) = self.replay_managed_spawn(&request, accepted_at)? {
6839                return replayed;
6840            }
6841        }
6842        let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
6843        if request.spawn_spec.target.worktree_id.is_some() {
6844            let result = Err(failure(
6845                NodeFailureCode::InvalidRequest,
6846                "managed worktree spawn must not select a caller-provided worktree",
6847            ));
6848            self.remember_managed_spawn_attempt(
6849                durable_v2_key,
6850                request,
6851                result.clone(),
6852                accepted_at,
6853            );
6854            return result;
6855        }
6856        let environment_profile = self.resolve_environment_profile(&resolved)?;
6857        let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6858        let context = self.resolve_context(&resolved)?;
6859        let source_workspace_id = resolved.target.workspace_id.clone();
6860        let profile = self.managed_profile(&source_workspace_id, &request.worktree_profile_id)?;
6861        let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6862        let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6863        let runtime_requirement = match (resolved.mode, resolved.prompt.is_some()) {
6864            (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
6865            (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
6866            (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
6867            (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
6868        };
6869        let runtime_policy = timeout(
6870            spawn_deadline_remaining(deadline)?,
6871            self.admit_provider_runtime(&resolved.provider, runtime_requirement),
6872        ).await.map_err(|_| failure(
6873            NodeFailureCode::SpawnDeadlineExceeded,
6874            "provider admission exceeded the managed spawn deadline",
6875        ))??;
6876        if required_capabilities.iter().any(|capability| !runtime_policy.admits(*capability)) {
6877            return Err(failure(
6878                NodeFailureCode::UnsupportedSpawnCapability,
6879                "provider runtime does not admit a required spawn capability",
6880            ));
6881        }
6882        self.ensure_binding_capacity()?;
6883        let source_root = self.workspace_root(&source_workspace_id)?;
6884        profile.validate_for_workspace(&source_root)
6885            .map_err(|message| failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message))?;
6886        let base_timeout_ms = u64::try_from(
6887            spawn_deadline_remaining(deadline)?.as_millis(),
6888        ).unwrap_or(u64::MAX).max(1);
6889        let base_commit = resolve_base_commit_with_timeout(
6890            &source_root,
6891            profile.base(),
6892            base_timeout_ms,
6893        ).await.map_err(|error| {
6894            if Instant::now() >= deadline || error.message.contains("timed out") {
6895                failure(
6896                    NodeFailureCode::SpawnDeadlineExceeded,
6897                    "managed worktree base resolution exceeded the spawn deadline",
6898                )
6899            } else {
6900                managed_git_worktree_failure(error)
6901            }
6902        })?;
6903
6904        let lease = {
6905            let _transaction = self.state_transaction.lock()
6906                .unwrap_or_else(|poisoned| poisoned.into_inner());
6907            let previous = self.managed_worktrees.lock()
6908                .unwrap_or_else(|poisoned| poisoned.into_inner()).clone();
6909            let lease = self.managed_worktrees.lock()
6910                .unwrap_or_else(|poisoned| poisoned.into_inner())
6911                .allocate(source_workspace_id.clone(), &profile, base_commit.clone(), unix_time_ms())
6912                .map_err(|message| failure(NodeFailureCode::BackendBusy, &message))?;
6913            let previous_replay = match durable_v2_key
6914                .map(|key| self.link_durable_managed_spawn_v2_lease_locked(key, &lease.lease_id))
6915                .transpose()
6916            {
6917                Ok(previous) => previous,
6918                Err(error) => {
6919                    *self.managed_worktrees.lock()
6920                        .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6921                    return Err(error);
6922                }
6923            };
6924            if let Err(error) = self.persist_state_locked() {
6925                *self.managed_worktrees.lock()
6926                    .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6927                if let (Some(key), Some(previous_replay)) =
6928                    (durable_v2_key, previous_replay)
6929                {
6930                    self.restore_durable_managed_spawn_v2_locked(key, previous_replay);
6931                }
6932                return Err(persistence_failure(error));
6933            }
6934            lease
6935        };
6936        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() });
6937        if let Err(message) = profile.validate_target_authority(&lease.lease_id, &lease.target_root) {
6938            self.managed_worktrees.lock()
6939                .unwrap_or_else(|poisoned| poisoned.into_inner())
6940                .remove_unmutated(&lease.lease_id);
6941            if self.persist_state().is_err() {
6942                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6943            }
6944            let result = Err(failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message));
6945            self.remember_managed_spawn_attempt(
6946                durable_v2_key,
6947                request,
6948                result.clone(),
6949                accepted_at,
6950            );
6951            return result;
6952        }
6953        let mutation_timeout_ms = match spawn_deadline_remaining(deadline) {
6954            Ok(remaining) => u64::try_from(remaining.as_millis()).unwrap_or(u64::MAX).max(1),
6955            Err(error) => {
6956                self.reconcile_failed_allocation(&lease, &source_root).await;
6957                let result = Err(error);
6958                self.remember_managed_spawn_attempt(
6959                    durable_v2_key,
6960                    request,
6961                    result.clone(),
6962                    accepted_at,
6963                );
6964                return result;
6965            }
6966        };
6967        let created = match create_git_worktree_with_timeout(
6968                &source_root,
6969                &lease.target_root,
6970                &lease.branch,
6971                Some(&base_commit),
6972                mutation_timeout_ms,
6973            ).await {
6974            Ok(created) => created,
6975            Err(error) => {
6976                self.reconcile_failed_allocation(&lease, &source_root).await;
6977                let result = Err(if Instant::now() >= deadline
6978                    || error.message.contains("timed out")
6979                    || error.message.contains("deadline")
6980                {
6981                    failure(
6982                        NodeFailureCode::SpawnDeadlineExceeded,
6983                        "managed worktree creation exceeded the spawn deadline",
6984                    )
6985                } else {
6986                    managed_git_worktree_failure(error)
6987                });
6988                self.remember_managed_spawn_attempt(
6989                    durable_v2_key,
6990                    request,
6991                    result.clone(),
6992                    accepted_at,
6993                );
6994                return result;
6995            }
6996        };
6997        if !exact_created_worktree(&lease, &created.path, created.branch.as_deref(), &created.head) {
6998            self.mark_managed_recovery_required(&lease.lease_id, ManagedWorktreeCleanupFailure::OwnershipConflict);
6999            let result = Err(failure(
7000                NodeFailureCode::ManagedWorktreeRecoveryRequired,
7001                "created worktree identity did not match its durable lease",
7002            ));
7003            self.remember_managed_spawn_attempt(
7004                durable_v2_key,
7005                request,
7006                result.clone(),
7007                accepted_at,
7008            );
7009            return result;
7010        }
7011        {
7012            let mut registry = self.managed_worktrees.lock()
7013                .unwrap_or_else(|poisoned| poisoned.into_inner());
7014            let current = registry.get_mut(&lease.lease_id)
7015                .expect("allocated managed worktree lease remains present");
7016            current.expected_head = Some(created.head.clone());
7017            current.state = ManagedWorktreeLeaseState::Ready;
7018            current.updated_at_unix_ms = unix_time_ms();
7019        }
7020        if let Err(error) = self.persist_state() {
7021            self.mark_managed_recovery_required(
7022                &lease.lease_id,
7023                ManagedWorktreeCleanupFailure::Backend,
7024            );
7025            let result = Err(persistence_failure(error));
7026            self.remember_managed_spawn_attempt(
7027                durable_v2_key,
7028                request,
7029                result.clone(),
7030                accepted_at,
7031            );
7032            return result;
7033        }
7034        let ready = self.managed_worktrees.lock()
7035            .unwrap_or_else(|poisoned| poisoned.into_inner())
7036            .get(&lease.lease_id)
7037            .expect("ready managed worktree lease remains present")
7038            .snapshot();
7039        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: ready });
7040        if let Err(error) = self.register_workspace(lease.workspace_id.clone(), created.path.clone()).await {
7041            let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
7042            let result = Err(error);
7043            self.remember_managed_spawn_attempt(
7044                durable_v2_key,
7045                request,
7046                result.clone(),
7047                accepted_at,
7048            );
7049            return result;
7050        }
7051        let spawn = self.spawn_session_with_deadline(
7052            lease.workspace_id.clone(),
7053            resolved.provider.clone(),
7054            resolved.mode,
7055            resolved.terminal_size,
7056            resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
7057            environment_profile.clone(),
7058            bundle.clone(),
7059            context.clone(),
7060            Some(lease.lease_id.clone()),
7061            Some(runtime_policy),
7062            SpawnRecordPolicy::Always,
7063            Some(deadline),
7064            &required_capabilities,
7065            None,
7066            resolved.approval_level,
7067            resolved.network_allowlist.clone(),
7068            resolved.browser_profile_id.clone(),
7069        ).await;
7070        let (session, effective_runtime_policy) = match spawn {
7071            Ok(spawned) => spawned,
7072            Err(error) => {
7073                if self
7074                    .cleanup_managed_worktree(&lease.lease_id, true)
7075                    .await
7076                    .is_err()
7077                {
7078                    self.mark_managed_recovery_required(
7079                        &lease.lease_id,
7080                        ManagedWorktreeCleanupFailure::Backend,
7081                    );
7082                }
7083                let result = Err(error);
7084                self.remember_managed_spawn_attempt(
7085                    durable_v2_key,
7086                    request,
7087                    result.clone(),
7088                    accepted_at,
7089                );
7090                return result;
7091            }
7092        };
7093        let (record_id, raw_inventory_record_id) = {
7094            let mut bindings = self.session_bindings.lock()
7095                .unwrap_or_else(|poisoned| poisoned.into_inner());
7096            let binding = bindings.get_mut(&session.session.instance_id)
7097                .expect("successful spawn retains its session binding");
7098            binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
7099            managed_spawn_record_ownership(binding, effective_runtime_policy)
7100        };
7101        let bound = self.managed_worktrees.lock()
7102            .unwrap_or_else(|poisoned| poisoned.into_inner())
7103            .bind_session(
7104                &lease.lease_id,
7105                ManagedWorktreeSessionHolder {
7106                    incarnation_id: self.incarnation_id,
7107                    instance_id: session.session.instance_id,
7108                    generation: session.session.generation,
7109                },
7110                record_id,
7111                unix_time_ms(),
7112            )
7113            .map_err(|message| failure(NodeFailureCode::ManagedWorktreeRecoveryRequired, &message));
7114        let snapshot = match bound {
7115            Ok(snapshot) => snapshot,
7116            Err(error) => {
7117                if raw_inventory_record_id
7118                    .as_ref()
7119                    .is_some_and(|record_id| self.discard_record(record_id).is_err())
7120                {
7121                    self.mark_managed_recovery_required(
7122                        &lease.lease_id,
7123                        ManagedWorktreeCleanupFailure::Backend,
7124                    );
7125                }
7126                let _ = self.remove_session(&session).await;
7127                self.mark_managed_recovery_required(
7128                    &lease.lease_id,
7129                    ManagedWorktreeCleanupFailure::Backend,
7130                );
7131                let result = Err(error);
7132                self.remember_managed_spawn_attempt(
7133                    durable_v2_key,
7134                    request,
7135                    result.clone(),
7136                    accepted_at,
7137                );
7138                return result;
7139            }
7140        };
7141        if let Err(error) = self.persist_state() {
7142            if raw_inventory_record_id
7143                .as_ref()
7144                .is_some_and(|record_id| self.discard_record(record_id).is_err())
7145            {
7146                self.mark_managed_recovery_required(
7147                    &lease.lease_id,
7148                    ManagedWorktreeCleanupFailure::Backend,
7149                );
7150            }
7151            let _ = self.remove_session(&session).await;
7152            if self
7153                .cleanup_managed_worktree(&lease.lease_id, true)
7154                .await
7155                .is_err()
7156            {
7157                self.mark_managed_recovery_required(
7158                    &lease.lease_id,
7159                    ManagedWorktreeCleanupFailure::Backend,
7160                );
7161            }
7162            let result = Err(persistence_failure(error));
7163            self.remember_managed_spawn_attempt(
7164                durable_v2_key,
7165                request,
7166                result.clone(),
7167                accepted_at,
7168            );
7169            return result;
7170        }
7171        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
7172        let receipt = managed_spawn_receipt(
7173            &resolved,
7174            self.incarnation_id,
7175            session,
7176            snapshot,
7177            environment_profile,
7178            bundle,
7179            context,
7180        );
7181        let result = Ok(receipt);
7182        self.remember_managed_spawn_attempt(
7183            durable_v2_key,
7184            request,
7185            result.clone(),
7186            accepted_at,
7187        );
7188        result
7189    }
7190
7191    async fn spawn_managed_worktree_v2(
7192        &self,
7193        request: ManagedWorktreeSpawnRequestV2,
7194    ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
7195        if request.spawn_spec.target.node_id != self.node_id {
7196            return Err(failure(
7197                NodeFailureCode::SpawnTargetMismatch,
7198                "spawn target does not match this node",
7199            ));
7200        }
7201        if request.spawn_spec.target.worktree_id.is_some() {
7202            return Err(failure(
7203                NodeFailureCode::InvalidRequest,
7204                "managed worktree spawn must not select a caller-provided worktree",
7205            ));
7206        }
7207        let request_digest = managed_spawn_request_digest_v2(&request)?;
7208        let replay = self.acquire_durable_managed_spawn_v2(&request, request_digest)?;
7209        match replay {
7210            DurableManagedSpawnReplayDecision::Committed(receipt) => return Ok(receipt),
7211            DurableManagedSpawnReplayDecision::PendingLinked => {
7212                return Err(failure(
7213                    NodeFailureCode::ManagedWorktreeRecoveryRequired,
7214                    "managed worktree V2 reservation has an incomplete durable allocation",
7215                ));
7216            }
7217            DurableManagedSpawnReplayDecision::Reserved => {}
7218        }
7219        let validated = (|| {
7220            let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
7221            let profile = self.managed_profile(
7222                &resolved.target.workspace_id,
7223                &request.worktree_profile_id,
7224            )?;
7225            if profile.revision() != &request.expected_profile_revision {
7226                return Err(failure(
7227                    NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
7228                    "managed worktree profile revision does not match the loaded profile",
7229                ));
7230            }
7231            Ok(())
7232        })();
7233        match validated {
7234            Ok(()) => {}
7235            Err(error) => {
7236                self.remove_unallocated_durable_managed_spawn_v2(&request)?;
7237                return Err(error);
7238            }
7239        }
7240        let legacy = ManagedWorktreeSpawnRequest {
7241            spawn_spec: request.spawn_spec.clone(),
7242            worktree_profile_id: request.worktree_profile_id.clone(),
7243        };
7244        match self
7245            .spawn_managed_worktree_inner(
7246                legacy,
7247                Some(&request.spawn_spec.idempotency_key),
7248            )
7249            .await
7250        {
7251            Ok(receipt) => {
7252                self.commit_durable_managed_spawn_v2(&request, receipt.clone())?;
7253                Ok(receipt)
7254            }
7255            Err(error) => {
7256                self.remove_unallocated_durable_managed_spawn_v2(&request)?;
7257                Err(error)
7258            }
7259        }
7260    }
7261
7262    fn managed_profile(
7263        &self,
7264        workspace_id: &WorkspaceId,
7265        profile_id: &WorktreeProfileId,
7266    ) -> Result<ManagedWorktreeProfile, NodeFailure> {
7267        if self.worktree_service_modes.get(workspace_id).copied()
7268            != Some(WorktreeServiceMode::Managed)
7269        {
7270            return Err(failure(
7271                NodeFailureCode::UnsupportedSpawnCapability,
7272                "workspace does not enable managed worktrees",
7273            ));
7274        }
7275        self.managed_worktree_profiles.get(workspace_id)
7276            .and_then(|profiles| profiles.get(profile_id))
7277            .cloned()
7278            .ok_or_else(|| failure(
7279                NodeFailureCode::InvalidRequest,
7280                "managed worktree profile is unavailable for this workspace",
7281            ))
7282    }
7283
7284    async fn reconcile_failed_allocation(
7285        &self,
7286        lease: &ManagedWorktreeLeaseRecord,
7287        source_root: &str,
7288    ) {
7289        match list_git_worktrees(source_root).await {
7290            Ok(worktrees) => {
7291                match worktrees.iter().find(|item| worktree_paths_equal(&item.path, &lease.target_root)) {
7292                    None => {
7293                        match std::fs::symlink_metadata(&lease.target_root) {
7294                            Err(error) if error.kind() == io::ErrorKind::NotFound => {
7295                                self.managed_worktrees.lock()
7296                                    .unwrap_or_else(|poisoned| poisoned.into_inner())
7297                                    .remove_unmutated(&lease.lease_id);
7298                                let _ = self.persist_state();
7299                            }
7300                            _ => self.mark_managed_recovery_required(
7301                                &lease.lease_id,
7302                                ManagedWorktreeCleanupFailure::OwnershipConflict,
7303                            ),
7304                        }
7305                    }
7306                    Some(item) if exact_owned_worktree(
7307                        lease,
7308                        &item.path,
7309                        item.branch.as_deref(),
7310                        &item.head,
7311                    ) => {
7312                        let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
7313                    }
7314                    Some(_) => self.mark_managed_recovery_required(
7315                        &lease.lease_id,
7316                        ManagedWorktreeCleanupFailure::OwnershipConflict,
7317                    ),
7318                }
7319            }
7320            Err(_) => self.mark_managed_recovery_required(
7321                &lease.lease_id,
7322                ManagedWorktreeCleanupFailure::Backend,
7323            ),
7324        }
7325    }
7326
7327    async fn reconcile_managed_worktrees(&self) {
7328        let leases = self.managed_worktrees.lock()
7329            .unwrap_or_else(|poisoned| poisoned.into_inner())
7330            .active_records()
7331            .cloned()
7332            .collect::<Vec<_>>();
7333        for lease in leases {
7334            let profile = match self.managed_profile(&lease.source_workspace_id, &lease.profile_id) {
7335                Ok(profile) if profile.revision() == &lease.profile_revision => profile,
7336                _ => {
7337                    self.mark_managed_recovery_required(
7338                        &lease.lease_id,
7339                        ManagedWorktreeCleanupFailure::OwnershipConflict,
7340                    );
7341                    continue;
7342                }
7343            };
7344            if profile.validate_target_authority(&lease.lease_id, &lease.target_root).is_err() {
7345                self.mark_managed_recovery_required(
7346                    &lease.lease_id,
7347                    ManagedWorktreeCleanupFailure::OwnershipConflict,
7348                );
7349                continue;
7350            }
7351            let source_root = match self.workspace_root(&lease.source_workspace_id) {
7352                Ok(root) => root,
7353                Err(_) => {
7354                    self.mark_managed_recovery_required(
7355                        &lease.lease_id,
7356                        ManagedWorktreeCleanupFailure::OwnershipConflict,
7357                    );
7358                    continue;
7359                }
7360            };
7361            let worktrees = match list_git_worktrees(&source_root).await {
7362                Ok(worktrees) => worktrees,
7363                Err(_) => {
7364                    self.mark_managed_recovery_required(
7365                        &lease.lease_id,
7366                        ManagedWorktreeCleanupFailure::Backend,
7367                    );
7368                    continue;
7369                }
7370            };
7371            let listed = worktrees.iter()
7372                .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
7373            let Some(listed) = listed else {
7374                match std::fs::symlink_metadata(&lease.target_root) {
7375                    Err(error) if error.kind() == io::ErrorKind::NotFound
7376                        && lease.state == ManagedWorktreeLeaseState::Allocating
7377                        && !lease.has_holders() => {
7378                            let removed = self.managed_worktrees.lock()
7379                                .unwrap_or_else(|poisoned| poisoned.into_inner())
7380                                .tombstone(&lease.lease_id, unix_time_ms());
7381                            if self.persist_state().is_ok() && removed.is_some() {
7382                                self.publish(NodeEvent::ManagedWorktreeRemoved {
7383                                    lease_id: lease.lease_id.clone(),
7384                                });
7385                            }
7386                        }
7387                    _ => self.mark_managed_recovery_required(
7388                        &lease.lease_id,
7389                        ManagedWorktreeCleanupFailure::OwnershipConflict,
7390                    ),
7391                }
7392                continue;
7393            };
7394            if listed.is_main || listed.is_bare || !exact_owned_worktree(
7395                &lease,
7396                &listed.path,
7397                listed.branch.as_deref(),
7398                &listed.head,
7399            ) {
7400                self.mark_managed_recovery_required(
7401                    &lease.lease_id,
7402                    ManagedWorktreeCleanupFailure::OwnershipConflict,
7403                );
7404                continue;
7405            }
7406            if !self.workspaces.read()
7407                .unwrap_or_else(|poisoned| poisoned.into_inner())
7408                .contains_key(&lease.workspace_id)
7409            {
7410                if self.register_workspace(lease.workspace_id.clone(), listed.path.clone()).await.is_err() {
7411                    self.mark_managed_recovery_required(
7412                        &lease.lease_id,
7413                        ManagedWorktreeCleanupFailure::Backend,
7414                    );
7415                    continue;
7416                }
7417            }
7418            let snapshot = {
7419                let mut registry = self.managed_worktrees.lock()
7420                    .unwrap_or_else(|poisoned| poisoned.into_inner());
7421                let current = registry.get_mut(&lease.lease_id)
7422                    .expect("reconciled managed lease remains present");
7423                current.expected_head = Some(listed.head.clone());
7424                current.cleanup_failure = None;
7425                current.state = if current.has_holders() {
7426                    ManagedWorktreeLeaseState::InUse
7427                } else if current.retention == ManagedWorktreeRetention::Retain {
7428                    ManagedWorktreeLeaseState::Retained
7429                } else {
7430                    ManagedWorktreeLeaseState::Ready
7431                };
7432                current.updated_at_unix_ms = unix_time_ms();
7433                current.snapshot()
7434            };
7435            if self.persist_state().is_err() {
7436                self.mark_managed_recovery_required(
7437                    &lease.lease_id,
7438                    ManagedWorktreeCleanupFailure::Backend,
7439                );
7440                continue;
7441            }
7442            self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
7443            if snapshot.state == ManagedWorktreeLeaseState::Ready {
7444                let _ = self.cleanup_managed_worktree(&lease.lease_id, false).await;
7445            }
7446        }
7447    }
7448
7449    /// Crash-recovery startup sweep: durably capture the pack for every
7450    /// `Live` managed record whose most recent session has not yet produced
7451    /// one (recovers a session whose `Exited{0}` event was observed but the
7452    /// Node crashed before the export finished). The reactive fast path for
7453    /// a single just-exited record does not go through this — see
7454    /// `drive_runtime_until_shutdown`, which resolves and spawns directly so
7455    /// it can capture the exact `SessionAddress` before `publish_control`
7456    /// clears it, rather than re-deriving it from (by then likely already
7457    /// `Dormant`) record state the way a rescan naturally would.
7458    ///
7459    /// Returns as soon as every eligible record's export has been *spawned*,
7460    /// not completed — each one runs on its own detached task (see
7461    /// `reconcile_context_pack_export_for_record`'s own doc comment for why:
7462    /// `NativeRuntime::tick()` has not even started its own loop yet at the
7463    /// point this is called from `NodeServer::run()`, so nothing could ever
7464    /// drain the history round trip if this awaited it inline here either).
7465    /// Best-effort throughout: never panics, no unwrap/expect; every failure
7466    /// is a silent skip for THIS record — an unusable source or a racing
7467    /// record is not surfaced to any caller.
7468    async fn reconcile_context_pack_exports(self: &Arc<Self>) {
7469        let candidates: Vec<(SessionRecordId, SessionAddress)> = self
7470            .session_records
7471            .lock()
7472            .unwrap_or_else(|poisoned| poisoned.into_inner())
7473            .records
7474            .values()
7475            .filter(|record| {
7476                record.state == ManagedSessionState::Live
7477                    && record.exported_context.is_none()
7478                    && record.provider_session.is_some()
7479            })
7480            .filter_map(|record| {
7481                record
7482                    .active_session
7483                    .clone()
7484                    .map(|session| (record.record_id.clone(), session))
7485            })
7486            .collect();
7487        for (record_id, session) in candidates {
7488            let shared = Arc::clone(self);
7489            tokio::spawn(async move {
7490                shared
7491                    .reconcile_context_pack_export_for_record(&record_id, session)
7492                    .await;
7493            });
7494        }
7495    }
7496
7497    /// Runs on its own detached task (`tokio::spawn`, see both call sites),
7498    /// never inline on `drive_runtime_until_shutdown`'s own task: the history
7499    /// round trip this performs (`discover_history`/`load_history`, via
7500    /// `export_context_pack_for_session_record`) can only ever settle once
7501    /// `NativeRuntime::tick()` runs again to drain the authority worker's
7502    /// completion — and `tick()` is exclusively owned by, and only ever
7503    /// called from, that same drive-loop task. Awaiting this inline there
7504    /// would starve `tick()` and this call could never observe its own
7505    /// command complete.
7506    ///
7507    /// Because this now genuinely races the drive loop's own
7508    /// `publish_control` (which downgrades this exact record from `Live` to
7509    /// `Dormant`, clearing `active_session`, for the very `Exited` event that
7510    /// triggered this reconcile), the record legitimately may already be
7511    /// `Dormant` by the time this reads it, or may transition mid-flight —
7512    /// `allow_clean_detachment: true` on the downstream export call is what
7513    /// tolerates exactly that transition (and only that transition; see
7514    /// `session_record_export_target_matches`).
7515    async fn reconcile_context_pack_export_for_record(
7516        &self,
7517        record_id: &SessionRecordId,
7518        session: SessionAddress,
7519    ) {
7520        let Ok(record) = self.record(record_id) else {
7521            return;
7522        };
7523        if !matches!(
7524            record.state,
7525            ManagedSessionState::Live | ManagedSessionState::Dormant
7526        ) || record.exported_context.is_some()
7527            || record.provider_session.is_none()
7528        {
7529            return;
7530        }
7531        // Pack bytes are already durably committed inside this call, via
7532        // commit_context_pack_for_session_record's own durable
7533        // write-through (context_pack_store) — no separate step here.
7534        // Unclean exits (non-zero/forced/no loadable history) are already
7535        // rejected by context_pack_source_status_is_usable inside
7536        // materialize_context_pack: this simply returns Err and is skipped.
7537        let Ok(receipt) = self
7538            .export_context_pack_for_session_record(&record.record_id, &session, true)
7539            .await
7540        else {
7541            return;
7542        };
7543        self.set_exported_context(&record.record_id, receipt);
7544    }
7545
7546    /// Records the record's most recent clean-exit pack. No-op (not an
7547    /// error) if the record disappeared, or if it already carries an
7548    /// `exported_context` — first-write-wins, matching the harness-side
7549    /// idempotent reconciliation that consumes this field.
7550    fn set_exported_context(&self, record_id: &SessionRecordId, receipt: ResolvedContextPackReceipt) {
7551        let transaction = self
7552            .state_transaction
7553            .lock()
7554            .unwrap_or_else(|poisoned| poisoned.into_inner());
7555        let (previous, updated) = {
7556            let mut records = self
7557                .session_records
7558                .lock()
7559                .unwrap_or_else(|poisoned| poisoned.into_inner());
7560            let Some(record) = records.records.get_mut(record_id) else {
7561                return;
7562            };
7563            if record.exported_context.is_some() {
7564                return;
7565            }
7566            let previous = record.clone();
7567            record.exported_context = Some(receipt);
7568            record.updated_at_unix_ms = unix_time_ms().max(record.updated_at_unix_ms);
7569            (previous, record.clone())
7570        };
7571        if self.persist_state_locked().is_err() {
7572            self.session_records
7573                .lock()
7574                .unwrap_or_else(|poisoned| poisoned.into_inner())
7575                .records
7576                .insert(record_id.clone(), previous);
7577            return;
7578        }
7579        drop(transaction);
7580        self.publish_record(updated);
7581    }
7582
7583    /// Resolves the managed record and its exact `SessionAddress` currently
7584    /// bound to `instance_id` at exactly `generation`, used by the reactive
7585    /// `Exited{0}` hook in `drive_runtime_until_shutdown` to target a single
7586    /// record without a full rescan. Mirrors the lookup already inlined in
7587    /// `publish_control`. Captured synchronously, before `publish_control`
7588    /// for the same event clears the record's own `active_session` — the
7589    /// caller passes the resolved address through to a detached
7590    /// reconciliation task rather than re-deriving it from the record later,
7591    /// after that race has necessarily already happened.
7592    fn managed_record_export_target_for_instance(
7593        &self,
7594        instance_id: AgentInstanceId,
7595        generation: SessionGeneration,
7596    ) -> Option<(SessionRecordId, SessionAddress)> {
7597        let bindings = self
7598            .session_bindings
7599            .lock()
7600            .unwrap_or_else(|poisoned| poisoned.into_inner());
7601        let binding = bindings.get(&instance_id).filter(|binding| binding.generation == generation)?;
7602        let record_id = binding.record_id.clone()?;
7603        Some((
7604            record_id,
7605            SessionAddress {
7606                workspace_id: binding.workspace_id.clone(),
7607                session: SessionKey { instance_id, generation },
7608            },
7609        ))
7610    }
7611
7612    fn mark_managed_recovery_required(
7613        &self,
7614        lease_id: &ManagedWorktreeLeaseId,
7615        failure_kind: ManagedWorktreeCleanupFailure,
7616    ) {
7617        let snapshot = {
7618            let mut registry = self.managed_worktrees.lock()
7619                .unwrap_or_else(|poisoned| poisoned.into_inner());
7620            let Some(lease) = registry.get_mut(lease_id) else { return };
7621            lease.state = ManagedWorktreeLeaseState::RecoveryRequired;
7622            lease.cleanup_failure = Some(failure_kind);
7623            lease.updated_at_unix_ms = unix_time_ms();
7624            lease.snapshot()
7625        };
7626        let _ = self.persist_state();
7627        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
7628    }
7629
7630    fn set_persistence_error(&self, error: Option<String>) {
7631        *self
7632            .persistence_error
7633            .write()
7634            .unwrap_or_else(|poisoned| poisoned.into_inner()) = error
7635            .map(|message| session_registry::sanitized_persistence_summary(&message));
7636    }
7637
7638    fn persist_state(&self) -> io::Result<()> {
7639        let _transaction = self
7640            .state_transaction
7641            .lock()
7642            .unwrap_or_else(|poisoned| poisoned.into_inner());
7643        self.persist_state_locked()
7644    }
7645
7646    fn persist_state_locked(&self) -> io::Result<()> {
7647        let workspaces = self
7648            .workspaces
7649            .read()
7650            .unwrap_or_else(|poisoned| poisoned.into_inner())
7651            .clone();
7652        let records = self
7653            .session_records
7654            .lock()
7655            .unwrap_or_else(|poisoned| poisoned.into_inner())
7656            .records
7657            .values()
7658            .cloned()
7659            .collect::<Vec<_>>();
7660        let mut managed = self.managed_worktrees
7661            .lock()
7662            .unwrap_or_else(|poisoned| poisoned.into_inner());
7663        managed.reattach_record_holders(
7664            &managed_worktree_record_holders(&records),
7665            unix_time_ms(),
7666        );
7667        let materializations = self
7668            .materializations
7669            .lock()
7670            .unwrap_or_else(|poisoned| poisoned.into_inner())
7671            .values()
7672            .cloned()
7673            .collect::<Vec<_>>();
7674        let managed_spawn_replays = self.managed_spawn_replays
7675            .lock()
7676            .unwrap_or_else(|poisoned| poisoned.into_inner())
7677            .values()
7678            .cloned()
7679            .collect::<Vec<_>>();
7680        let result = session_registry::save_v10(
7681            self.state_path.as_deref(),
7682            &self.node_id,
7683            &workspaces,
7684            &records,
7685            &managed.records(),
7686            &managed.tombstones(),
7687            &materializations,
7688            &managed_spawn_replays,
7689        );
7690        match &result {
7691            Ok(warning) => self.set_persistence_error(warning.clone()),
7692            Err(_) => self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned())),
7693        }
7694        result.map(|_| ())
7695    }
7696
7697    async fn begin_shutdown(&self) -> Result<(), NodeFailure> {
7698        let _mutation_guard = self.mutation_gate.lock().await;
7699        self.begin_shutdown_locked().await
7700    }
7701
7702    async fn begin_shutdown_locked(&self) -> Result<(), NodeFailure> {
7703        if self.shutdown.swap(true, Ordering::AcqRel) {
7704            return Ok(());
7705        }
7706        SessionLifecycleHook::on_shutdown(self);
7707        let deadline = Instant::now() + Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
7708        let mut first_error = None;
7709        for session in &self.handle.snapshot().sessions {
7710            let remaining = deadline.saturating_duration_since(Instant::now());
7711            if remaining.is_zero() {
7712                first_error.get_or_insert_with(|| {
7713                    failure(NodeFailureCode::BackendBusy, "stop-all dispatch exceeded its bounded deadline")
7714                });
7715                break;
7716            }
7717            if let Err(error) = self
7718                .dispatch_bounded(
7719                    ControlCommand::Stop {
7720                        instance_id: session.instance_id,
7721                        force: true,
7722                    },
7723                    remaining,
7724                )
7725                .await
7726            {
7727                first_error.get_or_insert(error);
7728            }
7729        }
7730        self.shutdown_notify.notify_waiters();
7731        match first_error {
7732            Some(error) => Err(error),
7733            None => Ok(()),
7734        }
7735    }
7736
7737    fn ensure_binding_capacity(&self) -> Result<(), NodeFailure> {
7738        let bindings = self
7739            .session_bindings
7740            .lock()
7741            .unwrap_or_else(|poisoned| poisoned.into_inner());
7742        if bindings.len() >= CONTROL_SESSIONS_MAX {
7743            Err(failure(
7744                NodeFailureCode::BackendBusy,
7745                "node session-binding capacity is full; remove an existing session first",
7746            ))
7747        } else {
7748            Ok(())
7749        }
7750    }
7751
7752    /// One-owner workspace/env collision guard: a live session on
7753    /// `workspace_id` with a different `environment_profile` (option
7754    /// equality on the resolved receipt, including Some vs None) blocks
7755    /// another spawn on that same cwd. Same profile may still share the
7756    /// workspace; distinct profiles need distinct workspace ids (managed
7757    /// worktree).
7758    fn ensure_workspace_environment_profile_compatible(
7759        &self,
7760        workspace_id: &WorkspaceId,
7761        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
7762    ) -> Result<(), NodeFailure> {
7763        let bindings = self
7764            .session_bindings
7765            .lock()
7766            .unwrap_or_else(|poisoned| poisoned.into_inner());
7767        for binding in bindings.values() {
7768            if &binding.workspace_id != workspace_id {
7769                continue;
7770            }
7771            if binding.environment_profile.as_ref() != environment_profile {
7772                return Err(failure(
7773                    NodeFailureCode::WorkspaceBusy,
7774                    "workspace already has a live session with a different environment profile; use a managed worktree for concurrent distinct ProviderHome/env bindings on one repo",
7775                ));
7776            }
7777        }
7778        Ok(())
7779    }
7780
7781    #[cfg(test)]
7782    fn bind_session(
7783        &self,
7784        instance_id: AgentInstanceId,
7785        workspace_id: WorkspaceId,
7786        generation: SessionGeneration,
7787    ) {
7788        self.bind_session_with_policy(
7789            &SessionAddress {
7790                workspace_id,
7791                session: SessionKey { instance_id, generation },
7792            },
7793            ProviderRuntimePolicy::raw_pty(),
7794            None,
7795        );
7796    }
7797
7798    #[cfg(test)]
7799    fn bind_session_with_policy(
7800        &self,
7801        address: &SessionAddress,
7802        runtime_policy: ProviderRuntimePolicy,
7803        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7804    ) {
7805        self.bind_session_with_materialization(
7806            address,
7807            runtime_policy,
7808            environment_profile,
7809            None,
7810            None,
7811            None,
7812        );
7813    }
7814
7815    fn bind_session_with_materialization(
7816        &self,
7817        address: &SessionAddress,
7818        runtime_policy: ProviderRuntimePolicy,
7819        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7820        bundle: Option<ResolvedBundleReceipt>,
7821        context: Option<ResolvedContextPackReceipt>,
7822        materialization_id: Option<MaterializationId>,
7823    ) {
7824        let mut bindings = self
7825            .session_bindings
7826            .lock()
7827            .unwrap_or_else(|poisoned| poisoned.into_inner());
7828        debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7829        debug_assert!(!bindings.contains_key(&address.session.instance_id));
7830        bindings.insert(address.session.instance_id, SessionBinding {
7831            workspace_id: address.workspace_id.clone(),
7832            generation: address.session.generation,
7833            runtime_policy,
7834            pending_resume: None,
7835            record_id: None,
7836            managed_worktree_lease_id: None,
7837            environment_profile,
7838            bundle,
7839            context,
7840            materialization_id,
7841        });
7842    }
7843
7844    #[cfg(test)]
7845    fn bind_managed_session(
7846        &self,
7847        address: &SessionAddress,
7848        record_id: SessionRecordId,
7849        runtime_policy: ProviderRuntimePolicy,
7850        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7851    ) {
7852        self.bind_managed_session_with_materialization(
7853            address,
7854            record_id,
7855            runtime_policy,
7856            environment_profile,
7857            None,
7858            None,
7859            None,
7860        );
7861    }
7862
7863    fn bind_managed_session_with_materialization(
7864        &self,
7865        address: &SessionAddress,
7866        record_id: SessionRecordId,
7867        runtime_policy: ProviderRuntimePolicy,
7868        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7869        bundle: Option<ResolvedBundleReceipt>,
7870        context: Option<ResolvedContextPackReceipt>,
7871        materialization_id: Option<MaterializationId>,
7872    ) {
7873        let mut bindings = self
7874            .session_bindings
7875            .lock()
7876            .unwrap_or_else(|poisoned| poisoned.into_inner());
7877        debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7878        debug_assert!(!bindings.contains_key(&address.session.instance_id));
7879        bindings.insert(
7880            address.session.instance_id,
7881            SessionBinding {
7882                workspace_id: address.workspace_id.clone(),
7883                generation: address.session.generation,
7884                runtime_policy,
7885                pending_resume: None,
7886                record_id: Some(record_id),
7887                managed_worktree_lease_id: None,
7888                environment_profile,
7889                bundle,
7890                context,
7891                materialization_id,
7892            },
7893        );
7894    }
7895
7896    #[cfg(test)]
7897    fn bind_spawn_session(
7898        &self,
7899        address: &SessionAddress,
7900        provider: AgentId,
7901        mode: SessionMode,
7902        runtime_policy: ProviderRuntimePolicy,
7903        record_policy: SpawnRecordPolicy,
7904        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7905    ) -> Result<Option<SessionRecordId>, NodeFailure> {
7906        self.bind_spawn_session_with_materialization(
7907            address,
7908            provider,
7909            mode,
7910            runtime_policy,
7911            record_policy,
7912            environment_profile,
7913            None,
7914            None,
7915            None,
7916        )
7917    }
7918
7919    fn bind_spawn_session_with_materialization(
7920        &self,
7921        address: &SessionAddress,
7922        provider: AgentId,
7923        mode: SessionMode,
7924        runtime_policy: ProviderRuntimePolicy,
7925        record_policy: SpawnRecordPolicy,
7926        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7927        bundle: Option<ResolvedBundleReceipt>,
7928        context: Option<ResolvedContextPackReceipt>,
7929        materialization_id: Option<MaterializationId>,
7930    ) -> Result<Option<SessionRecordId>, NodeFailure> {
7931        if record_policy == SpawnRecordPolicy::ProviderIdentityOnly
7932            && !runtime_policy.provider_session_identity
7933        {
7934            self.bind_session_with_materialization(
7935                address,
7936                runtime_policy,
7937                environment_profile,
7938                bundle,
7939                context,
7940                materialization_id,
7941            );
7942            return Ok(None);
7943        }
7944        let record_owns_lifecycle = runtime_policy.provider_session_identity;
7945        let record = self.new_record(
7946            address,
7947            provider,
7948            mode,
7949            active_record_state(runtime_policy.provider_session_identity, false),
7950            None,
7951            record_owns_lifecycle.then(|| environment_profile.clone()).flatten(),
7952            record_owns_lifecycle.then(|| bundle.clone()).flatten(),
7953            record_owns_lifecycle.then(|| context.clone()).flatten(),
7954        )?;
7955        let record_id = record.record_id.clone();
7956        let transaction = self
7957            .state_transaction
7958            .lock()
7959            .unwrap_or_else(|poisoned| poisoned.into_inner());
7960        self.insert_record(record.clone())?;
7961        let previous_materialization = if record_owns_lifecycle {
7962            if let Some(materialization_id) = materialization_id.as_ref() {
7963                let mut materializations = self
7964                    .materializations
7965                    .lock()
7966                    .unwrap_or_else(|poisoned| poisoned.into_inner());
7967                let Some(ownership) = materializations.get_mut(materialization_id) else {
7968                    drop(materializations);
7969                    self.remove_record_memory(&record_id);
7970                    return Err(failure(
7971                        NodeFailureCode::BackendOperationFailed,
7972                        "session environment ownership disappeared before binding",
7973                    ));
7974                };
7975                let previous = ownership.clone();
7976                if ownership
7977                    .transfer_to_record(record_id.clone(), unix_time_ms())
7978                    .is_err()
7979                {
7980                    drop(materializations);
7981                    self.remove_record_memory(&record_id);
7982                    return Err(failure(
7983                        NodeFailureCode::BackendOperationFailed,
7984                        "session environment ownership transfer failed",
7985                    ));
7986                }
7987                Some(previous)
7988            } else {
7989                None
7990            }
7991        } else {
7992            None
7993        };
7994        self.bind_managed_session_with_materialization(
7995            address,
7996            record_id.clone(),
7997            runtime_policy,
7998            environment_profile,
7999            bundle,
8000            context,
8001            materialization_id.clone(),
8002        );
8003        if let Err(error) = self.persist_state_locked() {
8004            self.remove_binding(address);
8005            self.remove_record_memory(&record_id);
8006            if let (Some(materialization_id), Some(previous)) =
8007                (materialization_id.as_ref(), previous_materialization)
8008            {
8009                self.materializations
8010                    .lock()
8011                    .unwrap_or_else(|poisoned| poisoned.into_inner())
8012                    .insert(materialization_id.clone(), previous);
8013            }
8014            return Err(persistence_failure(error));
8015        }
8016        drop(transaction);
8017        self.publish_record(record);
8018        Ok(Some(record_id))
8019    }
8020
8021    fn allocate_record_id(&self) -> Result<SessionRecordId, NodeFailure> {
8022        for _ in 0..8 {
8023            let bytes = random_nonce().map_err(|error| {
8024                failure(
8025                    NodeFailureCode::BackendOperationFailed,
8026                    &format!("session record identity generation failed: {error}"),
8027                )
8028            })?;
8029            let mut value = String::with_capacity(27);
8030            value.push_str("sr-");
8031            for byte in &bytes[..12] {
8032                use std::fmt::Write as _;
8033                let _ = write!(&mut value, "{byte:02x}");
8034            }
8035            let record_id = SessionRecordId::new(value).map_err(|error| {
8036                failure(NodeFailureCode::BackendOperationFailed, &error.to_string())
8037            })?;
8038            let records = self
8039                .session_records
8040                .lock()
8041                .unwrap_or_else(|poisoned| poisoned.into_inner());
8042            if !records.records.contains_key(&record_id) {
8043                return Ok(record_id);
8044            }
8045        }
8046        Err(failure(
8047            NodeFailureCode::BackendBusy,
8048            "could not allocate a unique session record ID",
8049        ))
8050    }
8051
8052    fn allocate_task_id(&self) -> Result<TaskId, NodeFailure> {
8053        for _ in 0..8 {
8054            let bytes = random_nonce().map_err(|error| {
8055                failure(
8056                    NodeFailureCode::BackendOperationFailed,
8057                    &format!("task identity generation failed: {error}"),
8058                )
8059            })?;
8060            let mut nonce = [0_u8; 12];
8061            nonce.copy_from_slice(&bytes[..12]);
8062            let task_id = TaskId::from_nonce(nonce);
8063            let records = self
8064                .session_records
8065                .lock()
8066                .unwrap_or_else(|poisoned| poisoned.into_inner());
8067            if !records.records.values().any(|record| {
8068                record.task_binding.as_ref().and_then(|binding| binding.task_id.as_ref())
8069                    == Some(&task_id)
8070            }) {
8071                return Ok(task_id);
8072            }
8073        }
8074        Err(failure(
8075            NodeFailureCode::BackendBusy,
8076            "could not allocate a unique task ID",
8077        ))
8078    }
8079
8080    fn default_record_name(&self, provider: &AgentId) -> String {
8081        let records = self
8082            .session_records
8083            .lock()
8084            .unwrap_or_else(|poisoned| poisoned.into_inner());
8085        let ordinal = records
8086            .records
8087            .values()
8088            .filter(|record| &record.provider == provider)
8089            .count()
8090            .saturating_add(1);
8091        format!("{} #{ordinal}", provider.as_str())
8092    }
8093
8094    fn new_record(
8095        &self,
8096        address: &SessionAddress,
8097        provider: AgentId,
8098        mode: SessionMode,
8099        state: ManagedSessionState,
8100        provider_session: Option<gate4agent_types::ProviderSessionIdentity>,
8101        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
8102        bundle: Option<ResolvedBundleReceipt>,
8103        context: Option<ResolvedContextPackReceipt>,
8104    ) -> Result<ManagedSessionRecord, NodeFailure> {
8105        let canonical_root = self.workspace_root(&address.workspace_id)?;
8106        let now = unix_time_ms();
8107        Ok(ManagedSessionRecord {
8108            record_id: self.allocate_record_id()?,
8109            display_name: self.m4a_nick_of(address.session.instance_id).unwrap_or_else(|| self.default_record_name(&provider)),
8110            provider,
8111            mode,
8112            state,
8113            workspace_id: address.workspace_id.clone(),
8114            canonical_root: opaque_windows_path(canonical_root),
8115            provider_session,
8116            active_session: Some(address.clone()),
8117            environment_profile,
8118            bundle,
8119            context_id: context.as_ref().map(|receipt| receipt.id.clone()),
8120            context,
8121            exported_context: None,
8122            task_binding: None,
8123            created_at_unix_ms: now,
8124            updated_at_unix_ms: now,
8125            last_error: None,
8126        })
8127    }
8128
8129    fn insert_record(&self, record: ManagedSessionRecord) -> Result<(), NodeFailure> {
8130        let mut records = self
8131            .session_records
8132            .lock()
8133            .unwrap_or_else(|poisoned| poisoned.into_inner());
8134        if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
8135            return Err(failure(
8136                NodeFailureCode::BackendBusy,
8137                "managed session record capacity is full; forget an inactive record first",
8138            ));
8139        }
8140        if let Some(identity) = record.provider_session.as_ref() {
8141            if records.records.values().any(|candidate| {
8142                candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8143                    session_registry::same_provider_session(
8144                        &candidate.provider,
8145                        candidate_identity,
8146                        &record.provider,
8147                        identity,
8148                    )
8149                })
8150            }) {
8151                return Err(failure(
8152                    NodeFailureCode::SessionRecordConflict,
8153                    "provider session is already represented by another managed record",
8154                ));
8155            }
8156        }
8157        match records.records.entry(record.record_id.clone()) {
8158            std::collections::btree_map::Entry::Vacant(entry) => {
8159                entry.insert(record);
8160            }
8161            std::collections::btree_map::Entry::Occupied(_) => {
8162                return Err(failure(
8163                    NodeFailureCode::SessionRecordConflict,
8164                    "session record ID collision",
8165                ));
8166            }
8167        }
8168        Ok(())
8169    }
8170
8171    fn record(&self, record_id: &SessionRecordId) -> Result<ManagedSessionRecord, NodeFailure> {
8172        self.session_records
8173            .lock()
8174            .unwrap_or_else(|poisoned| poisoned.into_inner())
8175            .records
8176            .get(record_id)
8177            .cloned()
8178            .ok_or_else(|| {
8179                failure(
8180                    NodeFailureCode::UnknownSessionRecord,
8181                    "managed session record does not exist",
8182                )
8183            })
8184    }
8185
8186    fn revalidate_session_record_preview(
8187        &self,
8188        expected: &ManagedSessionRecord,
8189        identity: &ProviderSessionIdentity,
8190    ) -> Result<(), NodeFailure> {
8191        let current = self.record(&expected.record_id)?;
8192        let current_root = self.workspace_root(&current.workspace_id)?;
8193        if current.provider != expected.provider
8194            || current.workspace_id != expected.workspace_id
8195            || current.canonical_root != expected.canonical_root
8196            || current.provider_session.as_ref() != Some(identity)
8197            || !platform::roots_equal(
8198                &current_root,
8199                windows_path_text(&current.canonical_root),
8200            )
8201        {
8202            return Err(failure(
8203                NodeFailureCode::SessionRecordConflict,
8204                "managed session changed while its preview was loading",
8205            ));
8206        }
8207        Ok(())
8208    }
8209
8210    fn revalidate_session_record_context_export(
8211        &self,
8212        expected: &ManagedSessionRecord,
8213        identity: Option<&ProviderSessionIdentity>,
8214        session: &SessionAddress,
8215        allow_clean_detachment: bool,
8216    ) -> Result<(), NodeFailure> {
8217        let current = self.record(&expected.record_id)?;
8218        let current_root = self.workspace_root(&current.workspace_id)?;
8219        let snapshot = self.internal_session_snapshot(session)?;
8220        let binding = self
8221            .session_bindings
8222            .lock()
8223            .unwrap_or_else(|poisoned| poisoned.into_inner())
8224            .get(&session.session.instance_id)
8225            .cloned()
8226            .ok_or_else(|| {
8227                failure(
8228                    NodeFailureCode::SessionRecordConflict,
8229                    "managed session runtime binding disappeared during context export",
8230                )
8231            })?;
8232        if !session_record_context_export_binding_is_exact(
8233            expected,
8234            &current,
8235            identity,
8236            session,
8237            &binding,
8238            &snapshot.agent_id,
8239            &current_root,
8240            allow_clean_detachment,
8241        ) || !session_record_context_export_source_is_usable(
8242            &current,
8243            &snapshot.status,
8244            allow_clean_detachment,
8245        ) {
8246            return Err(failure(
8247                NodeFailureCode::SessionRecordConflict,
8248                "managed session binding changed during context export",
8249            ));
8250        }
8251        Ok(())
8252    }
8253
8254    fn revalidate_loaded_history(
8255        &self,
8256        session: &SessionAddress,
8257        candidate_id: &str,
8258        loaded: &HistorySessionRecord,
8259    ) -> Result<(), NodeFailure> {
8260        let snapshot = self.internal_session_snapshot(session)?;
8261        if snapshot.history.pending.is_some()
8262            || snapshot.history.loaded_candidate_id.as_deref() != Some(candidate_id)
8263            || snapshot.history.loaded.as_ref() != Some(loaded)
8264        {
8265            return Err(failure(
8266                NodeFailureCode::SessionRecordConflict,
8267                "managed session history changed during context export",
8268            ));
8269        }
8270        Ok(())
8271    }
8272
8273    fn remove_record_memory(&self, record_id: &SessionRecordId) -> Option<ManagedSessionRecord> {
8274        self.session_records
8275            .lock()
8276            .unwrap_or_else(|poisoned| poisoned.into_inner())
8277            .records
8278            .remove(record_id)
8279    }
8280
8281    fn discard_record(&self, record_id: &SessionRecordId) -> Result<(), NodeFailure> {
8282        if self.destroy_record_materialization(record_id)? {
8283            self.publish(NodeEvent::SessionRecordRemoved {
8284                record_id: record_id.clone(),
8285            });
8286            return Ok(());
8287        }
8288        let transaction = self
8289            .state_transaction
8290            .lock()
8291            .unwrap_or_else(|poisoned| poisoned.into_inner());
8292        let Some(removed) = self.remove_record_memory(record_id) else {
8293            return Ok(());
8294        };
8295        if let Err(error) = self.persist_state_locked() {
8296            let _ = self.insert_record(removed);
8297            return Err(persistence_failure(error));
8298        }
8299        drop(transaction);
8300        self.publish(NodeEvent::SessionRecordRemoved {
8301            record_id: record_id.clone(),
8302        });
8303        Ok(())
8304    }
8305
8306    fn publish_record(&self, record: ManagedSessionRecord) {
8307        self.publish(NodeEvent::SessionRecordUpserted { record });
8308    }
8309
8310    fn mark_record_error(
8311        &self,
8312        record_id: &SessionRecordId,
8313        message: &str,
8314    ) -> Result<(), NodeFailure> {
8315        let transaction = self
8316            .state_transaction
8317            .lock()
8318            .unwrap_or_else(|poisoned| poisoned.into_inner());
8319        let previous = {
8320            let mut records = self
8321                .session_records
8322                .lock()
8323                .unwrap_or_else(|poisoned| poisoned.into_inner());
8324            let Some(record) = records.records.get_mut(record_id) else {
8325                return Ok(());
8326            };
8327            let previous = record.clone();
8328            record.state = ManagedSessionState::Unavailable;
8329            record.last_error = Some(session_registry::sanitized_record_error_summary(message));
8330            record.updated_at_unix_ms = unix_time_ms();
8331            previous
8332        };
8333        if let Err(error) = self.persist_state_locked() {
8334            self.session_records
8335                .lock()
8336                .unwrap_or_else(|poisoned| poisoned.into_inner())
8337                .records
8338                .insert(record_id.clone(), previous);
8339            return Err(persistence_failure(error));
8340        }
8341        let record = self.record(record_id)?;
8342        drop(transaction);
8343        self.publish_record(record);
8344        Ok(())
8345    }
8346
8347    fn rename_session_record(
8348        &self,
8349        record_id: &SessionRecordId,
8350        display_name: String,
8351    ) -> Result<ManagedSessionRecord, NodeFailure> {
8352        validate_display_name(&display_name)
8353            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8354        let _transaction = self
8355            .state_transaction
8356            .lock()
8357            .unwrap_or_else(|poisoned| poisoned.into_inner());
8358        let previous = {
8359            let mut records = self
8360                .session_records
8361                .lock()
8362                .unwrap_or_else(|poisoned| poisoned.into_inner());
8363            let record = records.records.get_mut(record_id).ok_or_else(|| {
8364                failure(
8365                    NodeFailureCode::UnknownSessionRecord,
8366                    "managed session record does not exist",
8367                )
8368            })?;
8369            let previous = record.clone();
8370            record.display_name = display_name;
8371            record.updated_at_unix_ms = unix_time_ms();
8372            previous
8373        };
8374        if let Err(error) = self.persist_state_locked() {
8375            self.session_records
8376                .lock()
8377                .unwrap_or_else(|poisoned| poisoned.into_inner())
8378                .records
8379                .insert(record_id.clone(), previous);
8380            return Err(persistence_failure(error));
8381        }
8382        let updated = self.record(record_id)?;
8383        self.publish_record(updated.clone());
8384        Ok(updated)
8385    }
8386
8387    fn set_session_task(
8388        &self,
8389        record_id: &SessionRecordId,
8390        expected_revision: u64,
8391        target: SessionTaskTargetV1,
8392    ) -> Result<ManagedSessionRecord, NodeFailure> {
8393        let transaction = self
8394            .state_transaction
8395            .lock()
8396            .unwrap_or_else(|poisoned| poisoned.into_inner());
8397        let current = self.record(record_id)?;
8398        let current_task_id = current
8399            .task_binding
8400            .as_ref()
8401            .and_then(|binding| binding.task_id.as_ref());
8402        let current_revision = current
8403            .task_binding
8404            .as_ref()
8405            .map_or(0, |binding| binding.revision);
8406        if expected_revision != current_revision {
8407            return Err(failure(
8408                NodeFailureCode::SessionRecordConflict,
8409                "managed session task binding revision changed",
8410            ));
8411        }
8412        let exact = match &target {
8413            SessionTaskTargetV1::Existing { task_id } => current_task_id == Some(task_id),
8414            SessionTaskTargetV1::Clear => current_task_id.is_none(),
8415            SessionTaskTargetV1::New => false,
8416        };
8417        if exact {
8418            return Ok(current);
8419        }
8420        let revision = current_revision.checked_add(1).ok_or_else(|| {
8421            failure(
8422                NodeFailureCode::SessionRecordConflict,
8423                "managed session task binding revision is exhausted",
8424            )
8425        })?;
8426        let task_id = match target {
8427            SessionTaskTargetV1::New => Some(self.allocate_task_id()?),
8428            SessionTaskTargetV1::Existing { task_id } => Some(task_id),
8429            SessionTaskTargetV1::Clear => None,
8430        };
8431        let changed_at_unix_ms = unix_time_ms()
8432            .max(current.updated_at_unix_ms.checked_add(1).ok_or_else(|| {
8433                failure(
8434                    NodeFailureCode::SessionRecordConflict,
8435                    "managed session record timestamp is exhausted",
8436                )
8437            })?);
8438        let updated = {
8439            let mut records = self
8440                .session_records
8441                .lock()
8442                .unwrap_or_else(|poisoned| poisoned.into_inner());
8443            let record = records.records.get_mut(record_id).ok_or_else(|| {
8444                failure(
8445                    NodeFailureCode::UnknownSessionRecord,
8446                    "managed session record does not exist",
8447                )
8448            })?;
8449            record.task_binding = Some(SessionTaskBindingV1 {
8450                revision,
8451                task_id,
8452                changed_at_unix_ms,
8453            });
8454            record.updated_at_unix_ms = changed_at_unix_ms;
8455            record.clone()
8456        };
8457        if let Err(error) = self.persist_state_locked() {
8458            self.session_records
8459                .lock()
8460                .unwrap_or_else(|poisoned| poisoned.into_inner())
8461                .records
8462                .insert(record_id.clone(), current);
8463            return Err(persistence_failure(error));
8464        }
8465        drop(transaction);
8466        self.publish_record(updated.clone());
8467        Ok(updated)
8468    }
8469
8470    fn index_provider_session(
8471        &self,
8472        workspace_id: WorkspaceId,
8473        provider: AgentId,
8474        identity: ProviderSessionIdentity,
8475        display_name: String,
8476    ) -> Result<ManagedSessionRecord, NodeFailure> {
8477        self.index_provider_session_with_policy(
8478            workspace_id,
8479            provider,
8480            identity,
8481            display_name,
8482            false,
8483        )
8484    }
8485
8486    fn index_provider_session_with_policy(
8487        &self,
8488        workspace_id: WorkspaceId,
8489        provider: AgentId,
8490        identity: ProviderSessionIdentity,
8491        display_name: String,
8492        allow_validated_transcript_path: bool,
8493    ) -> Result<ManagedSessionRecord, NodeFailure> {
8494        validate_display_name(&display_name)
8495            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8496        identity
8497            .validate()
8498            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8499        if identity.transcript_path.is_some() && !allow_validated_transcript_path {
8500            return Err(failure(
8501                NodeFailureCode::InvalidRequest,
8502                "provider session reference index v1 does not accept transcript paths",
8503            ));
8504        }
8505        let canonical_root = self.workspace_root(&workspace_id)?;
8506        if !self.enabled_providers.contains(&provider) {
8507            return Err(failure(
8508                NodeFailureCode::InvalidRequest,
8509                "provider is not enabled on this node",
8510            ));
8511        }
8512
8513        let transaction = self
8514            .state_transaction
8515            .lock()
8516            .unwrap_or_else(|poisoned| poisoned.into_inner());
8517        {
8518            let records = self
8519                .session_records
8520                .lock()
8521                .unwrap_or_else(|poisoned| poisoned.into_inner());
8522            if let Some(existing) = records.records.values().find(|candidate| {
8523                candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8524                    candidate_identity.key == identity.key && candidate_identity.id == identity.id
8525                })
8526            }) {
8527                if existing.provider == provider && existing.workspace_id == workspace_id {
8528                    return Ok(existing.clone());
8529                }
8530                return Err(failure(
8531                    NodeFailureCode::SessionRecordConflict,
8532                    "provider session reference conflicts with another provider or workspace",
8533                ));
8534            }
8535            if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
8536                return Err(failure(
8537                    NodeFailureCode::BackendBusy,
8538                    "managed session record capacity is full; forget an inactive record first",
8539                ));
8540            }
8541        }
8542
8543        let now = unix_time_ms();
8544        let record = ManagedSessionRecord {
8545            record_id: self.allocate_record_id()?,
8546            display_name,
8547            provider,
8548            mode: SessionMode::Pty,
8549            state: ManagedSessionState::Dormant,
8550            workspace_id,
8551            canonical_root: opaque_windows_path(canonical_root),
8552            provider_session: Some(identity),
8553            active_session: None,
8554            environment_profile: None,
8555            bundle: None,
8556            context_id: None,
8557            context: None,
8558            exported_context: None,
8559            task_binding: None,
8560            created_at_unix_ms: now,
8561            updated_at_unix_ms: now,
8562            last_error: None,
8563        };
8564        self.insert_record(record.clone())?;
8565        if let Err(error) = self.persist_state_locked() {
8566            self.remove_record_memory(&record.record_id);
8567            return Err(persistence_failure(error));
8568        }
8569        drop(transaction);
8570        self.publish_record(record.clone());
8571        Ok(record)
8572    }
8573
8574    fn bound_address_for_record(&self, record_id: &SessionRecordId) -> Option<SessionAddress> {
8575        self.session_bindings
8576            .lock()
8577            .unwrap_or_else(|poisoned| poisoned.into_inner())
8578            .iter()
8579            .find_map(|(instance_id, binding)| {
8580                (binding.record_id.as_ref() == Some(record_id)).then(|| SessionAddress {
8581                    workspace_id: binding.workspace_id.clone(),
8582                    session: SessionKey {
8583                        instance_id: *instance_id,
8584                        generation: binding.generation,
8585                    },
8586                })
8587            })
8588    }
8589
8590    async fn forget_session_record(
8591        &self,
8592        record_id: &SessionRecordId,
8593    ) -> Result<(), NodeFailure> {
8594        let record = self.record(record_id)?;
8595        if record.active_session.is_some()
8596            || matches!(
8597                record.state,
8598                ManagedSessionState::Live | ManagedSessionState::IdentityPending
8599            )
8600        {
8601            return Err(failure(
8602                NodeFailureCode::SessionRecordBusy,
8603                "stop the managed session before forgetting it",
8604            ));
8605        }
8606        if let Some(address) = self.bound_address_for_record(record_id) {
8607            self.remove_session(&address).await?;
8608        }
8609        self.remove_managed_session_record(record_id, &record.workspace_id).await
8610    }
8611
8612    /// Removes one managed session record from durable state and notifies
8613    /// every listener -- the shared transaction body behind both
8614    /// `forget_session_record`'s operator-driven path (busy-checked above
8615    /// it, and clears any still-live session binding before calling in
8616    /// here) and the retention sweep (`retire_unavailable_session_records`,
8617    /// which only ever selects `ManagedSessionState::Unavailable` records
8618    /// that are already known never-busy and never bound, so it calls
8619    /// straight into this with neither check). Cleans a managed-worktree
8620    /// lease if the record held one, removes the record's environment
8621    /// materialization (or, absent one, removes it from memory directly),
8622    /// persists under `state_transaction` with rollback on persist failure,
8623    /// then publishes `NodeEvent::SessionRecordRemoved` so c2's own
8624    /// legacy-provider bookkeeping (`record_is_legacy`) stays in sync
8625    /// exactly as it does for an operator-driven forget.
8626    async fn remove_managed_session_record(
8627        &self,
8628        record_id: &SessionRecordId,
8629        workspace_id: &WorkspaceId,
8630    ) -> Result<(), NodeFailure> {
8631        let managed_lease_id = self.managed_worktrees.lock()
8632            .unwrap_or_else(|poisoned| poisoned.into_inner())
8633            .lease_for_workspace(workspace_id);
8634        if !self.destroy_record_materialization(record_id)? {
8635            let transaction = self
8636                .state_transaction
8637                .lock()
8638                .unwrap_or_else(|poisoned| poisoned.into_inner());
8639            let removed = self.remove_record_memory(record_id).ok_or_else(|| {
8640                failure(
8641                    NodeFailureCode::UnknownSessionRecord,
8642                    "managed session record does not exist",
8643                )
8644            })?;
8645            if let Err(error) = self.persist_state_locked() {
8646                let _ = self.insert_record(removed);
8647                return Err(persistence_failure(error));
8648            }
8649            drop(transaction);
8650        }
8651        self.publish(NodeEvent::SessionRecordRemoved {
8652            record_id: record_id.clone(),
8653        });
8654        if let Some(lease_id) = managed_lease_id {
8655            if let Some(snapshot) = self.managed_worktrees.lock()
8656                .unwrap_or_else(|poisoned| poisoned.into_inner())
8657                .get(&lease_id)
8658                .map(ManagedWorktreeLeaseRecord::snapshot)
8659            {
8660                self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
8661            }
8662            let _ = self.cleanup_managed_worktree(&lease_id, false).await;
8663        }
8664        Ok(())
8665    }
8666
8667    /// Retires `ManagedSessionState::Unavailable` records against the
8668    /// node's configured `session_record_retention` age/keep-N policy.
8669    /// Never touches `Live`, `IdentityPending`, or `Dormant` records --
8670    /// `Dormant` is resumable and folding it into this sweep would delete
8671    /// records a caller can still act on. Both policy fields default to
8672    /// `0` (disabled); when both are `0` this is a no-op. When both are
8673    /// set, a record is retired only if it fails BOTH tests (older than
8674    /// `age_ms` AND beyond the newest `keep_per_workspace` in its
8675    /// workspace); when only one is set, that one alone decides. Called
8676    /// once at startup (alongside `reconcile_managed_worktrees`) and then
8677    /// on a `SESSION_RECORD_RETENTION_SWEEP_PERIOD` cadence from the drive
8678    /// loop -- see both call sites.
8679    async fn retire_unavailable_session_records(&self) {
8680        let policy = self.session_record_retention;
8681        let age_enabled = policy.age_ms > 0;
8682        let keep_enabled = policy.keep_per_workspace > 0;
8683        if !age_enabled && !keep_enabled {
8684            return;
8685        }
8686        let now = unix_time_ms();
8687        let candidates: Vec<(SessionRecordId, WorkspaceId, AgentId, u64)> = {
8688            self.session_records
8689                .lock()
8690                .unwrap_or_else(|poisoned| poisoned.into_inner())
8691                .records
8692                .values()
8693                .filter(|record| record.state == ManagedSessionState::Unavailable)
8694                .map(|record| {
8695                    (
8696                        record.record_id.clone(),
8697                        record.workspace_id.clone(),
8698                        record.provider.clone(),
8699                        record.updated_at_unix_ms,
8700                    )
8701                })
8702                .collect()
8703        };
8704        if candidates.is_empty() {
8705            return;
8706        }
8707        let mut by_workspace: BTreeMap<WorkspaceId, Vec<(SessionRecordId, AgentId, u64)>> =
8708            BTreeMap::new();
8709        for (record_id, workspace_id, provider, updated_at_unix_ms) in candidates {
8710            by_workspace
8711                .entry(workspace_id)
8712                .or_default()
8713                .push((record_id, provider, updated_at_unix_ms));
8714        }
8715        let reason = if age_enabled && keep_enabled {
8716            "age+keep-n"
8717        } else if age_enabled {
8718            "age"
8719        } else {
8720            "keep-n"
8721        };
8722        let mut retired: u64 = 0;
8723        for (workspace_id, mut records) in by_workspace {
8724            // Newest `updated_at_unix_ms` first, so index `>=
8725            // keep_per_workspace` names exactly "beyond the newest N".
8726            records.sort_by(|left, right| right.2.cmp(&left.2));
8727            for (index, (record_id, provider, updated_at_unix_ms)) in
8728                records.into_iter().enumerate()
8729            {
8730                let record_age_ms = now.saturating_sub(updated_at_unix_ms);
8731                let age_hit = age_enabled && record_age_ms >= policy.age_ms;
8732                let keep_hit = keep_enabled && index >= policy.keep_per_workspace as usize;
8733                let should_retire = if age_enabled && keep_enabled {
8734                    age_hit && keep_hit
8735                } else {
8736                    age_hit || keep_hit
8737                };
8738                if !should_retire {
8739                    continue;
8740                }
8741                match self.remove_managed_session_record(&record_id, &workspace_id).await {
8742                    Ok(()) => {
8743                        self.retired_records_total.fetch_add(1, Ordering::Relaxed);
8744                        retired += 1;
8745                        tracing::info!(
8746                            node_id = %self.node_id,
8747                            record_id = %record_id,
8748                            workspace_id = %workspace_id,
8749                            provider = %provider,
8750                            age_ms = record_age_ms,
8751                            reason,
8752                            "managed session record retired"
8753                        );
8754                    }
8755                    Err(error) => tracing::warn!(
8756                        node_id = %self.node_id,
8757                        record_id = %record_id,
8758                        workspace_id = %workspace_id,
8759                        code = ?error.code,
8760                        cause = %error.message,
8761                        "session record retention sweep failed to retire a record"
8762                    ),
8763                }
8764            }
8765        }
8766        if retired > 0 {
8767            let remaining_unavailable = self
8768                .session_records
8769                .lock()
8770                .unwrap_or_else(|poisoned| poisoned.into_inner())
8771                .records
8772                .values()
8773                .filter(|record| record.state == ManagedSessionState::Unavailable)
8774                .count();
8775            tracing::info!(
8776                node_id = %self.node_id,
8777                retired,
8778                remaining_unavailable,
8779                "session record retention sweep completed"
8780            );
8781        }
8782    }
8783
8784    async fn resume_session_record(
8785        &self,
8786        record_id: &SessionRecordId,
8787        terminal_size: gate4agent_types::TerminalSize,
8788        initial_prompt: Option<String>,
8789    ) -> Result<(ManagedSessionRecord, SessionAddress), NodeFailure> {
8790        let mut record = self.record(record_id)?;
8791        // claude's ACP agent has no session/load: an ACP claude conversation whose transcript
8792        // is on disk is resumed as a PTY `claude --resume <id>` of that same transcript.
8793        if record.mode == SessionMode::Acp
8794            && record.provider.as_str() == "claude"
8795            && record.provider_session.as_ref().is_some_and(|i| provider_session_exists("claude", &i.id))
8796        {
8797            record.mode = SessionMode::Pty;
8798            if let Some(r) = self.session_records.lock().unwrap_or_else(|p| p.into_inner()).records.get_mut(record_id) {
8799                r.mode = SessionMode::Pty;
8800            }
8801            tracing::info!(record = %record_id, "acp claude record resumed over pty (--resume of its transcript)");
8802        }
8803        if record.active_session.is_some()
8804            || matches!(
8805                record.state,
8806                ManagedSessionState::Live | ManagedSessionState::IdentityPending
8807            )
8808        {
8809            return Err(failure(
8810                NodeFailureCode::SessionRecordBusy,
8811                "managed session already has a live runtime binding",
8812            ));
8813        }
8814        let identity = record.provider_session.clone().ok_or_else(|| {
8815            failure(
8816                NodeFailureCode::SessionRecordNotResumable,
8817                "managed session has no verified provider session identity",
8818            )
8819        })?;
8820        let working_directory = self.workspace_root(&record.workspace_id)?;
8821        if !platform::roots_equal(
8822            &working_directory,
8823            windows_path_text(&record.canonical_root),
8824        ) {
8825            return Err(failure(
8826                NodeFailureCode::SessionRecordConflict,
8827                "managed session workspace root changed; refusing to resume in another directory",
8828            ));
8829        }
8830        {
8831            let records = self
8832                .session_records
8833                .lock()
8834                .unwrap_or_else(|poisoned| poisoned.into_inner());
8835            if records.records.values().any(|candidate| {
8836                candidate.record_id != *record_id
8837                    && candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8838                        session_registry::same_provider_session(
8839                            &candidate.provider,
8840                            candidate_identity,
8841                            &record.provider,
8842                            &identity,
8843                        )
8844                    })
8845                    && (candidate.active_session.is_some()
8846                        || matches!(
8847                            candidate.state,
8848                            ManagedSessionState::Live | ManagedSessionState::IdentityPending
8849                        ))
8850            }) {
8851                return Err(failure(
8852                    NodeFailureCode::SessionRecordConflict,
8853                    "provider session is already active through another managed record",
8854                ));
8855            }
8856        }
8857        let runtime_requirement = if initial_prompt.is_some() {
8858            ProviderRuntimeRequirement::ResumeWithPrompt
8859        } else {
8860            ProviderRuntimeRequirement::Resume
8861        };
8862        let runtime_policy = self.admit_provider_runtime(
8863            &record.provider,
8864            runtime_requirement,
8865        )
8866        .await?;
8867        // The resumed runtime runs in the session folder its record was named after.
8868        let working_directory = match self.m4a_accounts.as_ref() {
8869            Some(_) => {
8870                let dir = std::path::Path::new(&working_directory).join(".hatchery-sessions").join(&record.display_name);
8871                if !record.display_name.contains(' ') && dir.is_dir() { dir.to_string_lossy().into_owned() } else { working_directory }
8872            }
8873            None => working_directory,
8874        };
8875        let request = ResumeLaunchRequest {
8876            working_directory,
8877            terminal_size,
8878            initial_prompt,
8879        };
8880        request
8881            .validate()
8882            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8883        self.ensure_binding_capacity()?;
8884        let resolved_materialization = match self.resolve_record_materialization(
8885            record_id,
8886            &record.provider,
8887            record.mode,
8888            record.environment_profile.as_ref(),
8889            record.bundle.as_ref(),
8890            record.context.as_ref(),
8891        ) {
8892            Ok(materialization) => materialization,
8893            Err(error) => {
8894                let label = match error.code {
8895                    NodeFailureCode::UnknownBundle => "bundle-unavailable",
8896                    NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
8897                    NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
8898                    _ => "environment-profile-unavailable",
8899                };
8900                self.mark_record_error(record_id, label)?;
8901                return Err(error);
8902            }
8903        };
8904        let (materialization_id, environment_overlay) = match resolved_materialization {
8905            Some((id, overlay)) => (Some(id), overlay),
8906            None => (None, None),
8907        };
8908        let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
8909        // Resumed runtime: same auto-permit flags and launch options (model / effort) as a spawn.
8910        let environment_overlay = {
8911            let mut args: Vec<OsString> = Vec::new();
8912            if let Ok(cwd) = self.workspace_root(&record.workspace_id) {
8913                let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
8914                if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") { homes.extend(std::env::split_paths(&extra)); }
8915                crate::auto_permit::prepare(record.provider.as_str(), &homes, Some(std::path::Path::new(&cwd)));
8916            }
8917            args.extend(crate::auto_permit::launch_args(record.provider.as_str(), record.mode));
8918            let options = SPAWN_SESSION_OPTIONS.try_with(|o| o.clone()).ok().flatten();
8919            if record.mode == SessionMode::Pty && record.provider.as_str() == "claude" {
8920                if let Some(sel) = options.as_ref() {
8921                    args.push("--model".into()); args.push(sel.model.clone().into());
8922                    if let Some(effort) = sel.values.get("effort").and_then(|v| v.as_str()) {
8923                        args.push("--effort".into()); args.push(effort.to_owned().into());
8924                    }
8925                }
8926            }
8927            if matches!(record.mode, SessionMode::Pty | SessionMode::Acp) && crate::pty_resume::supported(record.provider.as_str()) {
8928                if let (Some(file), Some(identity)) = (self.session_resume_journal(), record.provider_session.as_ref()) {
8929                    crate::pty_resume::upsert(&file, crate::pty_resume::Entry {
8930                        instance_id: instance_id.0,
8931                        provider: record.provider.as_str().to_owned(),
8932                        workspace_id: record.workspace_id.as_str().to_owned(),
8933                        provider_session_id: identity.id.clone(),
8934                        session_key: self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).get(&instance_id).cloned().unwrap_or_default(),
8935                        cwd: String::new(),
8936                        acp: record.mode == SessionMode::Acp,
8937                        options,
8938                        cols: 0,
8939                        rows: 0,
8940                        origin: SPAWN_ORIGIN.try_with(|o| o.clone()).ok().flatten(),
8941                        parent_session_id: SPAWN_PARENT.try_with(|o| o.clone()).ok().flatten(),
8942                    });
8943                }
8944            }
8945            overlay_with_extra_args(&record.provider, record.mode, environment_overlay, args)?
8946        };
8947        // The resumed runtime keeps the m4a account of the runtime it replaces.
8948        let environment_overlay = match (self.m4a_accounts.as_ref(), crate::m4a_accounts::m4a_provider(record.provider.as_str())) {
8949            (Some(accounts), Some(m4a_provider)) if matches!(record.mode, SessionMode::Pty | SessionMode::Acp) => {
8950                if let Some(old) = self.bound_address_for_record(record_id) {
8951                    if let Some(key) = self.m4a_keys.lock().unwrap_or_else(|p| p.into_inner()).get(&old.session.instance_id).cloned() {
8952                        self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).insert(instance_id, key);
8953                    }
8954                }
8955                let session_key = self.m4a_session_key(instance_id);
8956                self.m4a_session_overlay(accounts, m4a_provider, &record.provider, instance_id, environment_overlay, &session_key, record.mode)?
8957            }
8958            _ => environment_overlay,
8959        };
8960        let mut environment_selection = match SpawnPreparer::select_environment(self, 
8961            instance_id,
8962            &record.provider,
8963            record.mode,
8964            record.environment_profile.as_ref(),
8965        ) {
8966            Ok(selection) => selection,
8967            Err(error) => {
8968                self.mark_record_error(record_id, "environment-profile-unavailable")?;
8969                return Err(error);
8970            }
8971        };
8972        let mut instance_overlay = environment_overlay
8973            .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
8974            .transpose()?
8975            .flatten();
8976        if let Some(stale_address) = self.bound_address_for_record(record_id) {
8977            self.remove_session(&stale_address).await?;
8978        }
8979        let address = SessionAddress {
8980            workspace_id: record.workspace_id.clone(),
8981            session: SessionKey {
8982                instance_id,
8983                generation: SessionGeneration(1),
8984            },
8985        };
8986        let transaction = self
8987            .state_transaction
8988            .lock()
8989            .unwrap_or_else(|poisoned| poisoned.into_inner());
8990        let current = self.record(record_id)?;
8991        if current.active_session.is_some()
8992            || matches!(
8993                current.state,
8994                ManagedSessionState::Live | ManagedSessionState::IdentityPending
8995            )
8996        {
8997            return Err(failure(
8998                NodeFailureCode::SessionRecordBusy,
8999                "managed session became active while resume was being prepared",
9000            ));
9001        }
9002        self.bind_managed_session_with_materialization(
9003            &address,
9004            record_id.clone(),
9005            runtime_policy,
9006            record.environment_profile.clone(),
9007            record.bundle.clone(),
9008            record.context.clone(),
9009            materialization_id,
9010        );
9011        if let Some(selection) = environment_selection.take() {
9012            selection.retain();
9013        }
9014        if let Some(overlay) = instance_overlay.take() {
9015            overlay.retain();
9016        }
9017        drop(transaction);
9018
9019        let transport = match record.mode {
9020            SessionMode::Pty => TransportKind::Pty,
9021            SessionMode::Inline => TransportKind::Pipe,
9022            SessionMode::Acp => TransportKind::Acp,
9023        };
9024        let agent_id = record.provider.clone();
9025        let dispatch_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
9026        if let Err(error) = self
9027            .dispatch_bounded(
9028                ControlCommand::Register {
9029                    instance_id,
9030                    agent_id,
9031                    transport,
9032                },
9033                dispatch_timeout,
9034            )
9035            .await
9036        {
9037            if let Some(binding) = self.remove_binding(&address) {
9038                SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
9039            }
9040            return Err(error);
9041        }
9042        let command = self.prepare_command(ControlCommand::Resume {
9043            instance_id,
9044            target: ResumeTarget::ProviderSession { identity },
9045            runtime_policy,
9046            request,
9047        });
9048        let started_after = self.current_sequence();
9049        if let Err(error) = self.arm_resume(&address, command.id, runtime_policy) {
9050            self.rollback_spawn(&address, dispatch_timeout).await?;
9051            return Err(error);
9052        }
9053        if let Err(error) = self.dispatch_envelope(command) {
9054            self.clear_armed_resume(&address);
9055            self.rollback_spawn(&address, dispatch_timeout).await?;
9056            return Err(error);
9057        }
9058        match self
9059            .wait_for_record_resume(&address, started_after)
9060            .await
9061        {
9062            Ok(settled_address) => {
9063                let managed_lease_id = {
9064                    self.managed_worktrees.lock()
9065                        .unwrap_or_else(|poisoned| poisoned.into_inner())
9066                        .lease_for_workspace(&record.workspace_id)
9067                };
9068                if let Some(lease_id) = managed_lease_id {
9069                    {
9070                        let mut bindings = self.session_bindings.lock()
9071                            .unwrap_or_else(|poisoned| poisoned.into_inner());
9072                        let binding = bindings.get_mut(&settled_address.session.instance_id)
9073                            .ok_or_else(|| failure(
9074                                NodeFailureCode::ManagedWorktreeRecoveryRequired,
9075                                "resumed managed session lost its exact binding",
9076                            ))?;
9077                        if binding.generation != settled_address.session.generation {
9078                            return Err(failure(
9079                                NodeFailureCode::ManagedWorktreeRecoveryRequired,
9080                                "resumed managed session generation diverged",
9081                            ));
9082                        }
9083                        binding.managed_worktree_lease_id = Some(lease_id.clone());
9084                    }
9085                    let snapshot = self.managed_worktrees.lock()
9086                        .unwrap_or_else(|poisoned| poisoned.into_inner())
9087                        .bind_session(
9088                            &lease_id,
9089                            ManagedWorktreeSessionHolder {
9090                                incarnation_id: self.incarnation_id,
9091                                instance_id: settled_address.session.instance_id,
9092                                generation: settled_address.session.generation,
9093                            },
9094                            Some(record_id.clone()),
9095                            unix_time_ms(),
9096                        ).map_err(|message| failure(
9097                            NodeFailureCode::ManagedWorktreeRecoveryRequired,
9098                            &message,
9099                        ))?;
9100                    if let Err(error) = self.persist_state() {
9101                        let _ = self.remove_session(&settled_address).await;
9102                        return Err(persistence_failure(error));
9103                    }
9104                    self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
9105                }
9106                Ok((self.record(record_id)?, settled_address))
9107            }
9108            Err(error) => {
9109                if matches!(
9110                    error.code,
9111                    NodeFailureCode::SessionRecordNotResumable
9112                        | NodeFailureCode::BackendOperationFailed
9113                ) {
9114                    if let Some(bound) = self.bound_address_for_record(record_id) {
9115                        let _ = self.remove_session(&bound).await;
9116                    }
9117                }
9118                Err(error)
9119            }
9120        }
9121    }
9122
9123    async fn wait_for_record_resume(
9124        &self,
9125        initial_address: &SessionAddress,
9126        after_sequence: u64,
9127    ) -> Result<SessionAddress, NodeFailure> {
9128        let deadline = Instant::now()
9129            + Duration::from_millis(MANAGED_RESUME_SETTLE_TIMEOUT_MS);
9130        let mut scan_after = after_sequence;
9131        loop {
9132            let events = self
9133                .history
9134                .lock()
9135                .unwrap_or_else(|poisoned| poisoned.into_inner())
9136                .events
9137                .iter()
9138                .filter(|event| event.sequence > scan_after)
9139                .cloned()
9140                .collect::<Vec<_>>();
9141            for envelope in events {
9142                scan_after = scan_after.max(envelope.sequence);
9143                let NodeEvent::Control { address, event } = envelope.event else {
9144                    continue;
9145                };
9146                if address.session.instance_id != initial_address.session.instance_id {
9147                    continue;
9148                }
9149                match event.event {
9150                    ControlEventKind::Resumed { .. } => return Ok(address),
9151                    ControlEventKind::ResumeDenied { reason } => {
9152                        return Err(failure(
9153                            NodeFailureCode::SessionRecordNotResumable,
9154                            &reason,
9155                        ));
9156                    }
9157                    ControlEventKind::ResumeFailed { message }
9158                    | ControlEventKind::CommandRejected { message } => {
9159                        return Err(failure(
9160                            NodeFailureCode::BackendOperationFailed,
9161                            &message,
9162                        ));
9163                    }
9164                    _ => {}
9165                }
9166            }
9167            if Instant::now() >= deadline {
9168                return Err(failure(
9169                    NodeFailureCode::BackendBusy,
9170                    "managed resume did not settle before the bounded deadline",
9171                ));
9172            }
9173            sleep(Duration::from_millis(5)).await;
9174        }
9175    }
9176
9177    fn workspace_root(&self, workspace_id: &WorkspaceId) -> Result<String, NodeFailure> {
9178        self.workspaces
9179            .read()
9180            .unwrap_or_else(|poisoned| poisoned.into_inner())
9181            .get(workspace_id)
9182            .cloned()
9183            .ok_or_else(|| failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"))
9184    }
9185
9186    fn workspace_roots(&self) -> Vec<PathBuf> {
9187        self.workspaces
9188            .read()
9189            .unwrap_or_else(|poisoned| poisoned.into_inner())
9190            .values()
9191            .map(PathBuf::from)
9192            .collect()
9193    }
9194
9195    async fn inspect_workspace(
9196        &self,
9197        workspace_id: WorkspaceId,
9198    ) -> Result<WorkspaceInspection, NodeFailure> {
9199        let _permit = self
9200            .inspection_slots
9201            .clone()
9202            .try_acquire_owned()
9203            .map_err(|_| {
9204                tracing::warn!(
9205                    workspace_id = %workspace_id,
9206                    "workspace inspection rejected: inspection capacity is busy",
9207                );
9208                failure(
9209                    NodeFailureCode::BackendBusy,
9210                    "workspace inspection capacity is busy",
9211                )
9212            })?;
9213        let canonical_root = self.workspace_root(&workspace_id).map_err(|error| {
9214            tracing::warn!(
9215                workspace_id = %workspace_id,
9216                code = ?error.code,
9217                "workspace inspection rejected: workspace is not registered",
9218            );
9219            error
9220        })?;
9221        let tree_root = canonical_root.clone();
9222        let started_at = Instant::now();
9223        let time_budget_ms = workspace_inspection_time_budget_ms();
9224        let entry_cap = workspace_inspection_entry_cap();
9225        let deadline = started_at + Duration::from_millis(time_budget_ms);
9226        let (entries, tree_truncated, walk_budget) = tokio::task::spawn_blocking(move || {
9227            collect_workspace_entries(Path::new(&tree_root), deadline, entry_cap)
9228        })
9229        .await
9230        .map_err(|error| {
9231            tracing::warn!(
9232                workspace_id = %workspace_id,
9233                error = %error,
9234                "workspace inspection rejected: walk task failed",
9235            );
9236            failure(
9237                NodeFailureCode::BackendOperationFailed,
9238                &format!("workspace inspection task failed: {error}"),
9239            )
9240        })?;
9241        let (mut git, git_time_budget_exceeded) =
9242            inspect_git_workspace(&canonical_root, deadline).await;
9243        if !git.worktrees.is_empty() {
9244            let registered = self
9245                .workspaces
9246                .read()
9247                .unwrap_or_else(|poisoned| poisoned.into_inner())
9248                .iter()
9249                .map(|(workspace_id, root)| (workspace_id.clone(), root.clone()))
9250                .collect::<Vec<_>>();
9251            for worktree in &mut git.worktrees {
9252                worktree.workspace_id = registered
9253                    .iter()
9254                    .find(|(_, root)| {
9255                        worktree_paths_equal(root, windows_path_text(&worktree.path))
9256                    })
9257                    .map(|(workspace_id, _)| workspace_id.clone());
9258            }
9259        }
9260        if git.is_repository {
9261            git.managed_worktree = self
9262                .managed_worktrees
9263                .lock()
9264                .unwrap_or_else(|poisoned| poisoned.into_inner())
9265                .git_scope(&workspace_id, git.branch.as_deref());
9266        }
9267        let truncation = workspace_inspection_truncation(
9268            &walk_budget,
9269            git_time_budget_exceeded,
9270            started_at.elapsed(),
9271        );
9272        if let Some(truncation) = &truncation {
9273            tracing::info!(
9274                workspace_id = %workspace_id,
9275                walk_time_budget_exceeded = truncation.walk_time_budget_exceeded,
9276                walk_entry_cap_exceeded = truncation.walk_entry_cap_exceeded,
9277                git_time_budget_exceeded = truncation.git_time_budget_exceeded,
9278                entries_visited = truncation.entries_visited,
9279                elapsed_ms = truncation.elapsed_ms,
9280                "workspace inspection truncated by its inner budget",
9281            );
9282        }
9283        Ok(WorkspaceInspection {
9284            workspace_id,
9285            entries,
9286            tree_truncated,
9287            git,
9288            truncation,
9289        })
9290    }
9291
9292    async fn browse_host_directories(
9293        &self,
9294        directory: Option<OpaqueHostPath>,
9295        after: Option<OpaqueHostPath>,
9296    ) -> Result<HostDirectoryListing, NodeFailure> {
9297        let permit = self
9298            .inspection_slots
9299            .clone()
9300            .try_acquire_owned()
9301            .map_err(|_| {
9302                failure(
9303                    NodeFailureCode::BackendBusy,
9304                    "host directory browse capacity is busy",
9305                )
9306            })?;
9307        let task = tokio::task::spawn_blocking(move || {
9308            let _permit = permit;
9309            browse_host_directories(directory, after)
9310        });
9311        timeout(Duration::from_millis(HOST_DIRECTORY_BROWSE_TIMEOUT_MS), task)
9312            .await
9313            .map_err(|_| {
9314                failure(
9315                    NodeFailureCode::HostDirectoryReadTimedOut,
9316                    "host directory browse exceeded its bounded deadline",
9317                )
9318            })?
9319            .map_err(|_| {
9320                failure(
9321                    NodeFailureCode::HostDirectoryReadFailed,
9322                    "host directory browse task failed",
9323                )
9324            })?
9325            .map_err(host_directory_failure)
9326    }
9327
9328    async fn read_workspace_file(
9329        &self,
9330        workspace_id: WorkspaceId,
9331        path: RepositoryPath,
9332    ) -> Result<WorkspaceFileRead, NodeFailure> {
9333        let canonical_root = self.workspace_root(&workspace_id)?;
9334        let permit = self
9335            .inspection_slots
9336            .clone()
9337            .try_acquire_owned()
9338            .map_err(|_| {
9339                failure(
9340                    NodeFailureCode::BackendBusy,
9341                    "workspace file read capacity is busy",
9342                )
9343            })?;
9344        let path_to_read = path.clone();
9345        let task = tokio::task::spawn_blocking(move || {
9346            let _permit = permit;
9347            read_workspace_file_from_disk(Path::new(&canonical_root), &path_to_read)
9348        });
9349        let content = timeout(
9350            Duration::from_millis(WORKSPACE_FILE_READ_TIMEOUT_MS),
9351            task,
9352        )
9353        .await
9354        .map_err(|_| {
9355            failure(
9356                NodeFailureCode::RepositoryFileReadTimedOut,
9357                "repository file read exceeded its bounded deadline",
9358            )
9359        })?
9360        .map_err(|_| {
9361            failure(
9362                NodeFailureCode::RepositoryFileReadFailed,
9363                "repository file read task failed",
9364            )
9365        })?
9366        .map_err(workspace_file_failure)?;
9367        let content = match content {
9368            WorkspaceFileBytes::Utf8(text) => {
9369                let revision = Some(workspace_file_revision(text.as_bytes()));
9370                return Ok(WorkspaceFileRead {
9371                    workspace_id,
9372                    path,
9373                    content: WorkspaceFileContent::Utf8 {
9374                        byte_len: u32::try_from(text.len())
9375                            .expect("bounded workspace text length must fit u32"),
9376                        text,
9377                    },
9378                    revision,
9379                });
9380            }
9381            WorkspaceFileBytes::NonUtf8 { byte_length } => {
9382                WorkspaceFileContent::NonUtf8 {
9383                    byte_len: u32::try_from(byte_length)
9384                        .expect("bounded workspace file length must fit u32"),
9385                }
9386            }
9387            WorkspaceFileBytes::TooLarge => WorkspaceFileContent::TooLarge {
9388                limit_bytes: MAX_WORKSPACE_FILE_BYTES as u32,
9389            },
9390        };
9391        Ok(WorkspaceFileRead {
9392            workspace_id,
9393            path,
9394            content,
9395            revision: None,
9396        })
9397    }
9398
9399    async fn write_workspace_file(
9400        &self,
9401        workspace_id: WorkspaceId,
9402        path: RepositoryPath,
9403        expected_revision: WorkspaceFileRevision,
9404        text: String,
9405    ) -> Result<WorkspaceFileRead, NodeFailure> {
9406        let canonical_root = self.workspace_root(&workspace_id)?;
9407        let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9408            failure(NodeFailureCode::BackendBusy, "workspace file write capacity is busy")
9409        })?;
9410        let path_to_write = path.clone();
9411        let expected = expected_revision.as_str().to_owned();
9412        let text_to_write = text.clone();
9413        let task = tokio::task::spawn_blocking(move || {
9414            let _permit = permit;
9415            write_workspace_file_to_disk(
9416                Path::new(&canonical_root),
9417                &path_to_write,
9418                &expected,
9419                &text_to_write,
9420            )
9421        });
9422        let revision = timeout(Duration::from_millis(WORKSPACE_FILE_WRITE_TIMEOUT_MS), task)
9423            .await
9424            .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteTimedOut, "repository file write exceeded its bounded deadline"))?
9425            .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteFailed, "repository file write task failed"))?
9426            .map_err(workspace_file_write_failure)?;
9427        Ok(WorkspaceFileRead {
9428            workspace_id,
9429            path,
9430            content: WorkspaceFileContent::Utf8 {
9431                byte_len: u32::try_from(text.len()).expect("bounded workspace text length must fit u32"),
9432                text,
9433            },
9434            revision: Some(WorkspaceFileRevision::new(revision)
9435                .expect("workspace writer returns a valid SHA-256 revision")),
9436        })
9437    }
9438
9439    async fn create_workspace_file(
9440        &self,
9441        workspace_id: WorkspaceId,
9442        path: RepositoryPath,
9443    ) -> Result<WorkspaceFileRead, NodeFailure> {
9444        let canonical_root = self.workspace_root(&workspace_id)?;
9445        let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9446            failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
9447        })?;
9448        let path_to_create = path.clone();
9449        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
9450        let task_commit_state = Arc::clone(&commit_state);
9451        let task = tokio::task::spawn_blocking(move || {
9452            let _permit = permit;
9453            create_workspace_file_on_disk(
9454                Path::new(&canonical_root),
9455                &path_to_create,
9456                &task_commit_state,
9457            )
9458        });
9459        let revision = settle_workspace_entry_create(
9460            task,
9461            commit_state,
9462            Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
9463            "repository file create exceeded its bounded deadline",
9464            "repository file create task failed",
9465        )
9466        .await?;
9467        Ok(WorkspaceFileRead {
9468            workspace_id,
9469            path,
9470            content: WorkspaceFileContent::Utf8 {
9471                text: String::new(),
9472                byte_len: 0,
9473            },
9474            revision: Some(WorkspaceFileRevision::new(revision)
9475                .expect("workspace creator returns a valid SHA-256 revision")),
9476        })
9477    }
9478
9479    async fn create_workspace_directory(
9480        &self,
9481        workspace_id: WorkspaceId,
9482        path: RepositoryPath,
9483    ) -> Result<WorkspaceEntry, NodeFailure> {
9484        let canonical_root = self.workspace_root(&workspace_id)?;
9485        let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9486            failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
9487        })?;
9488        let path_to_create = path.clone();
9489        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
9490        let task_commit_state = Arc::clone(&commit_state);
9491        let task = tokio::task::spawn_blocking(move || {
9492            let _permit = permit;
9493            create_workspace_directory_on_disk(
9494                Path::new(&canonical_root),
9495                &path_to_create,
9496                &task_commit_state,
9497            )
9498        });
9499        settle_workspace_entry_create(
9500            task,
9501            commit_state,
9502            Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
9503            "repository directory create exceeded its bounded deadline",
9504            "repository directory create task failed",
9505        )
9506        .await?;
9507        Ok(WorkspaceEntry {
9508            relative_path: path,
9509            kind: WorkspaceEntryKind::Directory,
9510        })
9511    }
9512
9513    async fn read_git_history(
9514        &self,
9515        workspace_id: WorkspaceId,
9516        path: Option<RepositoryPath>,
9517        before: Option<GitObjectId>,
9518        limit: u16,
9519    ) -> Result<GitHistoryPage, NodeFailure> {
9520        let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9521            failure(NodeFailureCode::BackendBusy, "git history capacity is busy")
9522        })?;
9523        let root = self.workspace_root(&workspace_id)?;
9524        read_git_history_bounded(&root, path.as_ref(), before.as_ref(), limit).await
9525    }
9526
9527    async fn read_git_diff(
9528        &self,
9529        workspace_id: WorkspaceId,
9530        request: GitDiffRequest,
9531    ) -> Result<GitDiff, NodeFailure> {
9532        let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9533            failure(NodeFailureCode::BackendBusy, "git diff capacity is busy")
9534        })?;
9535        let root = self.workspace_root(&workspace_id)?;
9536        read_git_diff_bounded(&root, request).await
9537    }
9538
9539    fn native_session_route_root(
9540        &self,
9541        route: &NativeSessionCatalogRoute,
9542    ) -> Result<Option<String>, NodeFailure> {
9543        route
9544            .validate()
9545            .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9546        match route.scope {
9547            gate4agent_types::NativeSessionCatalogScope::Workspace => route
9548                .workspace_id
9549                .as_ref()
9550                .map(|workspace_id| self.workspace_root(workspace_id))
9551                .transpose(),
9552            gate4agent_types::NativeSessionCatalogScope::Unregistered => Ok(None),
9553        }
9554    }
9555
9556    fn project_native_session_entries(
9557        &self,
9558        route: &NativeSessionCatalogRoute,
9559        entries: Vec<ScopedNativeSessionCatalogEntry>,
9560    ) -> Vec<crate::protocol::NativeSessionCatalogEntry> {
9561        let current_root = route
9562            .workspace_id
9563            .as_ref()
9564            .and_then(|workspace_id| self.workspace_root(workspace_id).ok());
9565        let records = self
9566            .session_records
9567            .lock()
9568            .unwrap_or_else(|poisoned| poisoned.into_inner());
9569        let mut seen_records = std::collections::HashSet::new();
9570        entries
9571            .into_iter()
9572            .map(|entry| {
9573                let record_id = route.workspace_id.as_ref().and_then(|workspace_id| {
9574                    let matches = records
9575                        .records
9576                        .values()
9577                        .filter(|record| {
9578                            record.provider == route.provider
9579                                && record.workspace_id == *workspace_id
9580                                && current_root.as_deref().is_some_and(|root| {
9581                                    platform::roots_equal(
9582                                        root,
9583                                        windows_path_text(&record.canonical_root),
9584                                    )
9585                                })
9586                                && record.provider_session.as_ref().is_some_and(|identity| {
9587                                    session_registry::same_provider_session(
9588                                        &record.provider,
9589                                        identity,
9590                                        &route.provider,
9591                                        &entry.provider_session,
9592                                    )
9593                                })
9594                        })
9595                        .map(|record| record.record_id.clone())
9596                        .collect::<Vec<_>>();
9597                    (matches.len() == 1
9598                        && seen_records.insert(matches[0].clone()))
9599                        .then(|| matches[0].clone())
9600                });
9601                let metadata = entry.metadata;
9602                crate::protocol::NativeSessionCatalogEntry {
9603                    selection_id: metadata.selection_id,
9604                    title: metadata.title,
9605                    modified_at_unix_ms: metadata.modified_at_unix_ms,
9606                    model: metadata.model,
9607                    message_count: metadata.message_count,
9608                    completed_turn_count: metadata.completed_turn_count,
9609                    external_group: entry.external_group,
9610                    record_id,
9611                }
9612            })
9613            .collect()
9614    }
9615
9616    async fn catalog_native_sessions(
9617        &self,
9618        route: NativeSessionCatalogRoute,
9619        limit: u16,
9620    ) -> Result<(
9621        Vec<crate::protocol::NativeSessionCatalogEntry>,
9622        crate::protocol::NativeSessionCatalogSummary,
9623    ), NodeFailure> {
9624        let canonical_root = self.native_session_route_root(&route)?;
9625        let registered_workspace_roots = self.workspace_roots();
9626        let provider = route.provider.clone();
9627        let scope = route.scope;
9628        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9629            failure(
9630                NodeFailureCode::InvalidRequest,
9631                "native session catalog is not configured",
9632            )
9633        })?;
9634        let _permit = Arc::clone(&self.inspection_slots)
9635            .try_acquire_owned()
9636            .map_err(|_| {
9637                failure(
9638                    NodeFailureCode::BackendBusy,
9639                    "native session catalog capacity is busy",
9640                )
9641            })?;
9642        let page = run_native_session_catalog_operation(
9643            catalog,
9644            "native session catalog",
9645            move |catalog| {
9646                catalog.catalog_initial_for_scope(
9647                    &provider,
9648                    scope,
9649                    canonical_root.as_deref().map(Path::new),
9650                    &registered_workspace_roots,
9651                    limit,
9652                )
9653            },
9654        )
9655        .await?;
9656        let summary = crate::protocol::NativeSessionCatalogSummary {
9657            catalog_revision: page.revision,
9658            recent_cutoff_unix_ms: page.cutoff_unix_ms,
9659            recent_total_count: page.recent_total_count,
9660            older_total_count: page.older_total_count,
9661            recent_next_after_selection_id: page.next_after_selection_id,
9662            recent_has_more: page.remaining_count > 0,
9663        };
9664        Ok((self.project_native_session_entries(&route, page.entries), summary))
9665    }
9666
9667    async fn page_native_sessions(
9668        &self,
9669        route: NativeSessionCatalogRoute,
9670        window: crate::protocol::NativeSessionCatalogWindow,
9671        catalog_revision: u64,
9672        recent_cutoff_unix_ms: u64,
9673        after_selection_id: Option<String>,
9674        limit: u16,
9675    ) -> Result<crate::protocol::NativeSessionCatalogPage, NodeFailure> {
9676        let canonical_root = self.native_session_route_root(&route)?;
9677        let registered_workspace_roots = self.workspace_roots();
9678        let provider = route.provider.clone();
9679        let scope = route.scope;
9680        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9681            failure(
9682                NodeFailureCode::InvalidRequest,
9683                "native session catalog is not configured",
9684            )
9685        })?;
9686        let _permit = Arc::clone(&self.inspection_slots)
9687            .try_acquire_owned()
9688            .map_err(|_| {
9689                failure(
9690                    NodeFailureCode::BackendBusy,
9691                    "native session catalog capacity is busy",
9692                )
9693            })?;
9694        let page = run_native_session_catalog_operation(
9695            catalog,
9696            "native session catalog page",
9697            move |catalog| {
9698                Ok::<_, std::convert::Infallible>(catalog.catalog_page_for_scope(
9699                    &provider,
9700                    scope,
9701                    canonical_root.as_deref().map(Path::new),
9702                    &registered_workspace_roots,
9703                    window,
9704                    catalog_revision,
9705                    recent_cutoff_unix_ms,
9706                    after_selection_id.as_deref(),
9707                    limit,
9708                ))
9709            },
9710        )
9711        .await?;
9712        let page = page.map_err(|error| match error {
9713            NativeSessionCatalogError::StaleCatalog => failure(
9714                NodeFailureCode::StaleNativeSessionCatalog,
9715                "native session catalog revision or cursor is stale",
9716            ),
9717            _ => failure(
9718                NodeFailureCode::BackendOperationFailed,
9719                "native session catalog page failed",
9720            ),
9721        })?;
9722        let entries = self.project_native_session_entries(&route, page.entries);
9723        Ok(crate::protocol::NativeSessionCatalogPage {
9724            window: page.window,
9725            revision: page.revision,
9726            entries,
9727            next_after_selection_id: page.next_after_selection_id,
9728            remaining_count: page.remaining_count,
9729            has_more: page.remaining_count > 0,
9730        })
9731    }
9732
9733    async fn preview_native_session(
9734        &self,
9735        selection: NativeSessionSelection,
9736        message_limit: u16,
9737    ) -> Result<crate::protocol::NativeSessionPreview, NodeFailure> {
9738        selection
9739            .validate()
9740            .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9741        let canonical_root = self.native_session_route_root(&selection.route)?;
9742        let registered_workspace_roots = self.workspace_roots();
9743        let provider = selection.route.provider.clone();
9744        let scope = selection.route.scope;
9745        let catalog_revision = selection.catalog_revision;
9746        let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9747        let selection_id = selection.selection_id.clone();
9748        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9749            failure(
9750                NodeFailureCode::InvalidRequest,
9751                "native session preview is not configured",
9752            )
9753        })?;
9754        let _permit = Arc::clone(&self.inspection_slots)
9755            .try_acquire_owned()
9756            .map_err(|_| {
9757                failure(
9758                    NodeFailureCode::BackendBusy,
9759                    "native session preview capacity is busy",
9760                )
9761            })?;
9762        let preview = run_native_session_catalog_operation(
9763            catalog,
9764            "native session preview",
9765            move |catalog| {
9766                Ok::<_, std::convert::Infallible>(catalog.preview_for_scope(
9767                    &provider,
9768                    scope,
9769                    canonical_root.as_deref().map(Path::new),
9770                    &registered_workspace_roots,
9771                    catalog_revision,
9772                    recent_cutoff_unix_ms,
9773                    &selection_id,
9774                    message_limit,
9775                ))
9776            },
9777        )
9778        .await?;
9779        preview
9780            .map(Into::into)
9781            .map_err(native_session_preview_failure)
9782    }
9783
9784    async fn index_native_session(
9785        &self,
9786        selection: NativeSessionSelection,
9787        display_name: String,
9788    ) -> Result<ManagedSessionRecord, NodeFailure> {
9789        selection
9790            .validate()
9791            .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9792        if selection.route.scope
9793            != gate4agent_types::NativeSessionCatalogScope::Workspace
9794        {
9795            return Err(failure(
9796                NodeFailureCode::WorkspaceRegistrationRequired,
9797                "register the external project as a workspace before indexing or resuming it",
9798            ));
9799        }
9800        let workspace_id = selection
9801            .route
9802            .workspace_id
9803            .clone()
9804            .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9805        let canonical_root = self
9806            .native_session_route_root(&selection.route)?
9807            .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9808        let registered_workspace_roots = self.workspace_roots();
9809        let provider = selection.route.provider.clone();
9810        let catalog_revision = selection.catalog_revision;
9811        let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9812        let selection_id = selection.selection_id.clone();
9813        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9814            failure(
9815                NodeFailureCode::InvalidRequest,
9816                "native session index is not configured",
9817            )
9818        })?;
9819        let _permit = Arc::clone(&self.inspection_slots)
9820            .try_acquire_owned()
9821            .map_err(|_| {
9822                failure(
9823                    NodeFailureCode::BackendBusy,
9824                    "native session index capacity is busy",
9825                )
9826            })?;
9827        let resolved = run_native_session_catalog_operation(
9828            catalog,
9829            "native session index resolution",
9830            move |catalog| {
9831                Ok::<_, std::convert::Infallible>(catalog.resolve_selection_for_scope(
9832                    &provider,
9833                    gate4agent_types::NativeSessionCatalogScope::Workspace,
9834                    Some(Path::new(&canonical_root)),
9835                    &registered_workspace_roots,
9836                    catalog_revision,
9837                    recent_cutoff_unix_ms,
9838                    &selection_id,
9839                ))
9840            },
9841        )
9842        .await?
9843        .map_err(native_session_preview_failure)?;
9844        self.index_provider_session_with_policy(
9845            workspace_id,
9846            selection.route.provider,
9847            resolved.identity,
9848            display_name,
9849            true,
9850        )
9851    }
9852
9853    async fn preview_native_session_by_session_id(
9854        &self,
9855        workspace_id: WorkspaceId,
9856        provider: AgentId,
9857        session_id: String,
9858        message_limit: u16,
9859    ) -> Result<gate4agent_types::NativeSessionPreview, NodeFailure> {
9860        let canonical_root = self.workspace_root(&workspace_id)?;
9861        let registered_workspace_roots = self.workspace_roots();
9862        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9863            failure(
9864                NodeFailureCode::InvalidRequest,
9865                "native session preview is not configured",
9866            )
9867        })?;
9868        let _permit = Arc::clone(&self.inspection_slots)
9869            .try_acquire_owned()
9870            .map_err(|_| {
9871                failure(
9872                    NodeFailureCode::BackendBusy,
9873                    "native session preview capacity is busy",
9874                )
9875            })?;
9876        run_native_session_catalog_operation(
9877            catalog,
9878            "native session preview",
9879            move |catalog| {
9880                catalog.preview_session_id_for_workspace(
9881                    &provider,
9882                    Path::new(&canonical_root),
9883                    &registered_workspace_roots,
9884                    &session_id,
9885                    message_limit,
9886                )
9887            },
9888        )
9889        .await
9890    }
9891
9892    async fn register_workspace(
9893        &self,
9894        workspace_id: WorkspaceId,
9895        root: String,
9896    ) -> Result<WorkspaceSnapshot, NodeFailure> {
9897        validate_workspace_request_root(&root)?;
9898        let canonicalize_workspace_id = workspace_id.clone();
9899        let workspace = tokio::task::spawn_blocking(move || {
9900            WorkspaceConfig::new(canonicalize_workspace_id, root)
9901        })
9902        .await
9903        .map_err(|error| {
9904            failure(
9905                NodeFailureCode::BackendOperationFailed,
9906                &format!("workspace canonicalization task failed: {error}"),
9907            )
9908        })?
9909        .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
9910        let transaction = self
9911            .state_transaction
9912            .lock()
9913            .unwrap_or_else(|poisoned| poisoned.into_inner());
9914        let mut workspaces = self
9915            .workspaces
9916            .write()
9917            .unwrap_or_else(|poisoned| poisoned.into_inner());
9918        if workspaces.contains_key(&workspace_id) {
9919            return Err(failure(
9920                NodeFailureCode::DuplicateWorkspaceId,
9921                "workspace ID is already registered",
9922            ));
9923        }
9924        if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
9925            platform::roots_equal(existing_root, workspace.canonical_root())
9926        }) {
9927            return Err(failure(
9928                NodeFailureCode::DuplicateWorkspaceRoot,
9929                &format!("workspace root is already registered as '{existing_id}'"),
9930            ));
9931        }
9932        {
9933            let records = self
9934                .session_records
9935                .lock()
9936                .unwrap_or_else(|poisoned| poisoned.into_inner());
9937            if let Some(record) = records.records.values().find(|record| {
9938                record.workspace_id == workspace_id
9939                    && !record
9940                        .canonical_root
9941                        .as_utf8()
9942                        .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9943            }) {
9944                return Err(failure(
9945                    NodeFailureCode::SessionRecordConflict,
9946                    &format!(
9947                        "workspace ID is retained by session record '{}' at another root",
9948                        record.record_id,
9949                    ),
9950                ));
9951            }
9952        }
9953        let snapshot = WorkspaceSnapshot {
9954            workspace_id: workspace_id.clone(),
9955            canonical_root: opaque_windows_path(workspace.canonical_root().to_owned()),
9956            sessions: Vec::new(),
9957            worktree_service_mode: Some(WorktreeServiceMode::Manual),
9958            managed_worktree_profiles: Some(WorktreeProfileInventory {
9959                profiles: Vec::new(),
9960            }),
9961        };
9962        workspaces.insert(workspace_id.clone(), workspace.canonical_root().to_owned());
9963        drop(workspaces);
9964        let previous_records = {
9965            let mut records = self
9966                .session_records
9967                .lock()
9968                .unwrap_or_else(|poisoned| poisoned.into_inner());
9969            let previous = records.records.clone();
9970            for record in records.records.values_mut().filter(|record| {
9971                record.workspace_id == workspace_id
9972                    && record
9973                        .canonical_root
9974                        .as_utf8()
9975                        .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9976            }) {
9977                if record.active_session.is_none() {
9978                    record.state = detached_record_state(record);
9979                }
9980                if record.last_error.as_deref() == Some(WORKSPACE_UNAVAILABLE_ERROR) {
9981                    record.last_error = None;
9982                }
9983                record.updated_at_unix_ms = unix_time_ms();
9984            }
9985            previous
9986        };
9987        if let Err(error) = self.persist_state_locked() {
9988            self.workspaces
9989                .write()
9990                .unwrap_or_else(|poisoned| poisoned.into_inner())
9991                .remove(&workspace_id);
9992            self.session_records
9993                .lock()
9994                .unwrap_or_else(|poisoned| poisoned.into_inner())
9995                .records = previous_records;
9996            return Err(persistence_failure(error));
9997        }
9998        let restored = self
9999            .session_records
10000            .lock()
10001            .unwrap_or_else(|poisoned| poisoned.into_inner())
10002            .records
10003            .values()
10004            .filter(|record| record.workspace_id == workspace_id)
10005            .cloned()
10006            .collect::<Vec<_>>();
10007        drop(transaction);
10008        self.publish(NodeEvent::WorkspaceAdded {
10009            workspace: snapshot.clone(),
10010        });
10011        for record in restored {
10012            self.publish_record(record);
10013        }
10014        Ok(snapshot)
10015    }
10016
10017    async fn create_standalone_workspace(
10018        &self,
10019        workspace_id: WorkspaceId,
10020        root: String,
10021        initial_branch: Option<String>,
10022    ) -> Result<WorkspaceSnapshot, NodeFailure> {
10023        validate_workspace_request_root(&root)?;
10024        validate_git_revision("initial branch", initial_branch.as_deref())?;
10025        let preflight_workspace_id = workspace_id.clone();
10026        let preflight_root = root.clone();
10027        let canonical_candidate = tokio::task::spawn_blocking(move || {
10028            canonical_standalone_workspace_candidate(&preflight_workspace_id, &preflight_root)
10029        })
10030        .await
10031        .map_err(|_| failure(
10032            NodeFailureCode::BackendOperationFailed,
10033            "standalone workspace root preflight task failed",
10034        ))?
10035        .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
10036        {
10037            let workspaces = self
10038                .workspaces
10039                .read()
10040                .unwrap_or_else(|poisoned| poisoned.into_inner());
10041            if workspaces.contains_key(&workspace_id) {
10042                return Err(failure(
10043                    NodeFailureCode::DuplicateWorkspaceId,
10044                    "workspace ID is already registered",
10045                ));
10046            }
10047            if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
10048                platform::roots_equal(existing_root, &canonical_candidate)
10049            }) {
10050                return Err(failure(
10051                    NodeFailureCode::DuplicateWorkspaceRoot,
10052                    &format!("workspace root is already registered as '{existing_id}'"),
10053                ));
10054            }
10055        }
10056        let prepared = prepare_standalone_workspace(root, initial_branch.as_deref())
10057            .await
10058            .map_err(standalone_workspace_failure)?;
10059        prepared
10060            .verify_for_registration()
10061            .await
10062            .map_err(standalone_workspace_failure)?;
10063        match self
10064            .register_workspace(workspace_id, prepared.root().to_owned())
10065            .await
10066        {
10067            Ok(workspace) => Ok(workspace),
10068            Err(registration_error) => {
10069                if prepared.compensate_registration_failure().await {
10070                    Err(registration_error)
10071                } else {
10072                    Err(failure(
10073                        NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
10074                        "standalone workspace initialization succeeded but registration failed; recovery is required",
10075                    ))
10076                }
10077            }
10078        }
10079    }
10080
10081    fn unregister_workspace(&self, workspace_id: &WorkspaceId) -> Result<(), NodeFailure> {
10082        let bindings = self
10083            .session_bindings
10084            .lock()
10085            .unwrap_or_else(|poisoned| poisoned.into_inner());
10086        if bindings.values().any(|binding| &binding.workspace_id == workspace_id) {
10087            return Err(failure(
10088                NodeFailureCode::WorkspaceBusy,
10089                "workspace retains a bound session; remove every session before unregistering it",
10090            ));
10091        }
10092        drop(bindings);
10093        let transaction = self
10094            .state_transaction
10095            .lock()
10096            .unwrap_or_else(|poisoned| poisoned.into_inner());
10097        let mut workspaces = self
10098            .workspaces
10099            .write()
10100            .unwrap_or_else(|poisoned| poisoned.into_inner());
10101        if !workspaces.contains_key(workspace_id) {
10102            return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
10103        }
10104        if workspaces.len() == 1 {
10105            return Err(failure(
10106                NodeFailureCode::LastWorkspace,
10107                "node must retain at least one workspace",
10108            ));
10109        }
10110        let removed_root = workspaces
10111            .remove(workspace_id)
10112            .expect("workspace presence was validated");
10113        drop(workspaces);
10114        let previous_records = {
10115            let mut records = self
10116                .session_records
10117                .lock()
10118                .unwrap_or_else(|poisoned| poisoned.into_inner());
10119            let previous = records.records.clone();
10120            for record in records
10121                .records
10122                .values_mut()
10123                .filter(|record| &record.workspace_id == workspace_id)
10124            {
10125                record.active_session = None;
10126                record.state = ManagedSessionState::Unavailable;
10127                record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
10128                record.updated_at_unix_ms = unix_time_ms();
10129            }
10130            previous
10131        };
10132        if let Err(error) = self.persist_state_locked() {
10133            self.workspaces
10134                .write()
10135                .unwrap_or_else(|poisoned| poisoned.into_inner())
10136                .insert(workspace_id.clone(), removed_root);
10137            self.session_records
10138                .lock()
10139                .unwrap_or_else(|poisoned| poisoned.into_inner())
10140                .records = previous_records;
10141            return Err(persistence_failure(error));
10142        }
10143        let unavailable = self
10144            .session_records
10145            .lock()
10146            .unwrap_or_else(|poisoned| poisoned.into_inner())
10147            .records
10148            .values()
10149            .filter(|record| &record.workspace_id == workspace_id)
10150            .cloned()
10151            .collect::<Vec<_>>();
10152        drop(transaction);
10153        self.publish(NodeEvent::WorkspaceRemoved {
10154            workspace_id: workspace_id.clone(),
10155        });
10156        for record in unavailable {
10157            self.publish_record(record);
10158        }
10159        Ok(())
10160    }
10161
10162    async fn create_worktree(
10163        &self,
10164        source_workspace_id: WorkspaceId,
10165        workspace_id: WorkspaceId,
10166        target_root: String,
10167        branch: String,
10168        base: Option<String>,
10169    ) -> Result<(GitWorktreeSnapshot, WorkspaceSnapshot), NodeFailure> {
10170        self.require_worktree_service(&source_workspace_id)?;
10171        validate_workspace_request_root(&target_root)?;
10172        validate_git_revision("worktree base", base.as_deref())?;
10173        let source_root = self.workspace_root(&source_workspace_id)?;
10174        {
10175            let workspaces = self
10176                .workspaces
10177                .read()
10178                .unwrap_or_else(|poisoned| poisoned.into_inner());
10179            if workspaces.contains_key(&workspace_id) {
10180                return Err(failure(
10181                    NodeFailureCode::DuplicateWorkspaceId,
10182                    "workspace ID is already registered",
10183                ));
10184            }
10185            if let Some((existing_id, _)) = workspaces
10186                .iter()
10187                .find(|(_, root)| worktree_paths_equal(root, &target_root))
10188            {
10189                return Err(failure(
10190                    NodeFailureCode::DuplicateWorkspaceRoot,
10191                    &format!("workspace root is already registered as '{existing_id}'"),
10192                ));
10193            }
10194        }
10195        let mut worktree = create_git_worktree(
10196            &source_root,
10197            &target_root,
10198            &branch,
10199            base.as_deref(),
10200        )
10201        .await
10202        .map_err(git_worktree_failure)?;
10203        let workspace = match self
10204            .register_workspace(workspace_id, worktree.path.clone())
10205            .await
10206        {
10207            Ok(workspace) => workspace,
10208            Err(registration_error) => {
10209                if remove_git_worktree(&source_root, &worktree.path).await.is_err() {
10210                    return Err(failure(
10211                        NodeFailureCode::ManagedWorktreeRecoveryRequired,
10212                        "worktree registration failed and compensation requires recovery",
10213                    ));
10214                }
10215                return Err(failure(
10216                    registration_error.code,
10217                    &registration_error.message,
10218                ));
10219            }
10220        };
10221        worktree.workspace_id = Some(workspace.workspace_id.clone());
10222        Ok((protocol_worktree(worktree), workspace))
10223    }
10224
10225    async fn remove_worktree(
10226        &self,
10227        source_workspace_id: WorkspaceId,
10228        target_root: String,
10229    ) -> Result<Option<WorkspaceId>, NodeFailure> {
10230        self.require_worktree_service(&source_workspace_id)?;
10231        validate_workspace_request_root(&target_root)?;
10232        let source_root = self.workspace_root(&source_workspace_id)?;
10233        let target_root = normalize_worktree_removal_target(&target_root)
10234            .map_err(git_worktree_failure)?;
10235        let target = list_git_worktrees(&source_root)
10236            .await
10237            .map_err(git_worktree_failure)?
10238            .into_iter()
10239            .find(|worktree| worktree_paths_equal(&worktree.path, &target_root))
10240            .ok_or_else(|| {
10241                failure(
10242                    NodeFailureCode::WorktreeProtected,
10243                    "refusing to remove a path that is not in Git's worktree listing",
10244                )
10245            })?;
10246        let registered_workspace_id = {
10247            let workspaces = self
10248                .workspaces
10249                .read()
10250                .unwrap_or_else(|poisoned| poisoned.into_inner());
10251            let matched = workspaces
10252                .iter()
10253                .find(|(_, root)| worktree_paths_equal(root, &target.path))
10254                .map(|(workspace_id, _)| workspace_id.clone());
10255            if matched.is_some() && workspaces.len() == 1 {
10256                return Err(failure(
10257                    NodeFailureCode::LastWorkspace,
10258                    "node must retain at least one workspace",
10259                ));
10260            }
10261            matched
10262        };
10263        if let Some(workspace_id) = registered_workspace_id.as_ref() {
10264            let bindings = self
10265                .session_bindings
10266                .lock()
10267                .unwrap_or_else(|poisoned| poisoned.into_inner());
10268            if bindings
10269                .values()
10270                .any(|binding| &binding.workspace_id == workspace_id)
10271            {
10272                return Err(failure(
10273                    NodeFailureCode::WorkspaceBusy,
10274                    "worktree workspace retains a bound session; remove every session first",
10275                ));
10276            }
10277        }
10278        remove_git_worktree(&source_root, &target.path)
10279            .await
10280            .map_err(git_worktree_failure)?;
10281        if let Some(workspace_id) = registered_workspace_id.as_ref() {
10282            self.unregister_workspace(workspace_id)
10283                .map_err(|error| failure(error.code, &error.message))?;
10284        }
10285        Ok(registered_workspace_id)
10286    }
10287
10288    async fn cleanup_managed_worktree(
10289        &self,
10290        lease_id: &ManagedWorktreeLeaseId,
10291        explicit: bool,
10292    ) -> Result<ManagedWorktreeLeaseSnapshot, NodeFailure> {
10293        let lease = self.managed_worktrees.lock()
10294            .unwrap_or_else(|poisoned| poisoned.into_inner())
10295            .get(lease_id)
10296            .cloned()
10297            .ok_or_else(|| failure(
10298                NodeFailureCode::UnknownManagedWorktreeLease,
10299                "managed worktree lease does not exist",
10300            ))?;
10301        if lease.state == ManagedWorktreeLeaseState::Removed {
10302            return Ok(lease.snapshot());
10303        }
10304        if lease.has_holders() {
10305            return Err(failure(
10306                NodeFailureCode::ManagedWorktreeBusy,
10307                "managed worktree lease still has a session or durable-record holder",
10308            ));
10309        }
10310        if self
10311            .materializations
10312            .lock()
10313            .unwrap_or_else(|poisoned| poisoned.into_inner())
10314            .values()
10315            .any(|record| record.managed_lease_id() == Some(lease_id))
10316        {
10317            return Err(failure(
10318                NodeFailureCode::ManagedWorktreeRecoveryRequired,
10319                "managed worktree cleanup is blocked by session environment ownership",
10320            ));
10321        }
10322        if !explicit && lease.retention == ManagedWorktreeRetention::Retain {
10323            let snapshot = {
10324                let mut registry = self.managed_worktrees.lock()
10325                    .unwrap_or_else(|poisoned| poisoned.into_inner());
10326                let current = registry.get_mut(lease_id)
10327                    .expect("managed worktree lease remains present");
10328                current.state = ManagedWorktreeLeaseState::Retained;
10329                current.cleanup_failure = None;
10330                current.updated_at_unix_ms = unix_time_ms();
10331                current.snapshot()
10332            };
10333            if self.persist_state().is_err() {
10334                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
10335            }
10336            self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
10337            return Ok(snapshot);
10338        }
10339        let profile = self.managed_profile(&lease.source_workspace_id, &lease.profile_id)?;
10340        if profile.revision() != &lease.profile_revision {
10341            self.mark_managed_recovery_required(
10342                lease_id,
10343                ManagedWorktreeCleanupFailure::OwnershipConflict,
10344            );
10345            return Err(failure(
10346                NodeFailureCode::ManagedWorktreeRecoveryRequired,
10347                "managed worktree profile revision changed",
10348            ));
10349        }
10350        profile.validate_target_authority(&lease.lease_id, &lease.target_root).map_err(|message| {
10351            self.mark_managed_recovery_required(
10352                lease_id,
10353                ManagedWorktreeCleanupFailure::OwnershipConflict,
10354            );
10355            failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message)
10356        })?;
10357        let source_root = self.workspace_root(&lease.source_workspace_id)?;
10358        let listed = list_git_worktrees(&source_root).await.map_err(|error| {
10359            self.mark_managed_cleanup_blocked(lease_id, ManagedWorktreeCleanupFailure::Backend);
10360            managed_git_worktree_failure(error)
10361        })?;
10362        let target = listed.iter()
10363            .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
10364        if let Some(target) = target {
10365            if target.is_main || target.is_bare || !exact_owned_worktree(
10366                &lease,
10367                &target.path,
10368                target.branch.as_deref(),
10369                &target.head,
10370            ) {
10371                self.mark_managed_recovery_required(
10372                    lease_id,
10373                    ManagedWorktreeCleanupFailure::OwnershipConflict,
10374                );
10375                return Err(failure(
10376                    NodeFailureCode::ManagedWorktreeOwnershipConflict,
10377                    "Git worktree identity no longer matches the managed lease",
10378                ));
10379            }
10380            if target.prunable {
10381                self.mark_managed_cleanup_blocked(
10382                    lease_id,
10383                    ManagedWorktreeCleanupFailure::Prunable,
10384                );
10385                return Err(failure(
10386                    NodeFailureCode::ManagedWorktreeRecoveryRequired,
10387                    "managed worktree is prunable and requires recovery",
10388                ));
10389            }
10390            if let Err(error) = remove_git_worktree(&source_root, &target.path).await {
10391                let failure_kind = match error.kind {
10392                    GitWorktreeErrorKind::Dirty => ManagedWorktreeCleanupFailure::Dirty,
10393                    GitWorktreeErrorKind::Locked => ManagedWorktreeCleanupFailure::Locked,
10394                    GitWorktreeErrorKind::Protected => ManagedWorktreeCleanupFailure::OwnershipConflict,
10395                    GitWorktreeErrorKind::Conflict => ManagedWorktreeCleanupFailure::Busy,
10396                    GitWorktreeErrorKind::Invalid | GitWorktreeErrorKind::NotRepository
10397                    | GitWorktreeErrorKind::Failed => ManagedWorktreeCleanupFailure::Backend,
10398                };
10399                if failure_kind == ManagedWorktreeCleanupFailure::OwnershipConflict {
10400                    self.mark_managed_recovery_required(lease_id, failure_kind);
10401                } else {
10402                    self.mark_managed_cleanup_blocked(lease_id, failure_kind);
10403                }
10404                return Err(managed_git_worktree_failure(error));
10405            }
10406        } else {
10407            match std::fs::symlink_metadata(&lease.target_root) {
10408                Err(error) if error.kind() == io::ErrorKind::NotFound => {}
10409                Ok(_) => {
10410                    self.mark_managed_recovery_required(
10411                        lease_id,
10412                        ManagedWorktreeCleanupFailure::OwnershipConflict,
10413                    );
10414                    return Err(failure(
10415                        NodeFailureCode::ManagedWorktreeOwnershipConflict,
10416                        "managed target exists but is not owned by Git",
10417                    ));
10418                }
10419                Err(_) => {
10420                    self.mark_managed_cleanup_blocked(
10421                        lease_id,
10422                        ManagedWorktreeCleanupFailure::Backend,
10423                    );
10424                    return Err(failure(
10425                        NodeFailureCode::BackendOperationFailed,
10426                        "managed target identity could not be inspected",
10427                    ));
10428                }
10429            }
10430        }
10431        let registered = self.workspaces.read()
10432            .unwrap_or_else(|poisoned| poisoned.into_inner())
10433            .contains_key(&lease.workspace_id);
10434        if registered {
10435            self.unregister_workspace(&lease.workspace_id)?;
10436        }
10437        let removed = {
10438            self.managed_worktrees.lock()
10439                .unwrap_or_else(|poisoned| poisoned.into_inner())
10440                .tombstone(lease_id, unix_time_ms())
10441                .expect("managed worktree lease remains present during cleanup")
10442        };
10443        self.persist_state().map_err(persistence_failure)?;
10444        self.publish(NodeEvent::ManagedWorktreeRemoved { lease_id: lease_id.clone() });
10445        Ok(removed.snapshot())
10446    }
10447
10448    fn mark_managed_cleanup_blocked(
10449        &self,
10450        lease_id: &ManagedWorktreeLeaseId,
10451        failure_kind: ManagedWorktreeCleanupFailure,
10452    ) {
10453        let snapshot = {
10454            let mut registry = self.managed_worktrees.lock()
10455                .unwrap_or_else(|poisoned| poisoned.into_inner());
10456            let Some(lease) = registry.get_mut(lease_id) else { return };
10457            lease.state = ManagedWorktreeLeaseState::CleanupBlocked;
10458            lease.cleanup_failure = Some(failure_kind);
10459            lease.updated_at_unix_ms = unix_time_ms();
10460            lease.snapshot()
10461        };
10462        let _ = self.persist_state();
10463        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
10464    }
10465
10466    fn require_worktree_service(
10467        &self,
10468        source_workspace_id: &WorkspaceId,
10469    ) -> Result<(), NodeFailure> {
10470        if self.managed_worktrees.lock()
10471            .unwrap_or_else(|poisoned| poisoned.into_inner())
10472            .lease_for_workspace(source_workspace_id)
10473            .is_some()
10474        {
10475            return Err(failure(
10476                NodeFailureCode::ManagedWorktreeOwnershipConflict,
10477                "managed worktree targets are internal-only workspaces",
10478            ));
10479        }
10480        match self
10481            .worktree_service_modes
10482            .get(source_workspace_id)
10483            .copied()
10484            .unwrap_or(WorktreeServiceMode::Manual)
10485        {
10486            WorktreeServiceMode::Manual => Ok(()),
10487            WorktreeServiceMode::Managed | WorktreeServiceMode::Off => Err(failure(
10488                NodeFailureCode::UnsupportedSpawnCapability,
10489                "Git worktree service mode is unavailable in this node build",
10490            )),
10491        }
10492    }
10493
10494    fn managed_reservation_conflict(
10495        &self,
10496        workspace_id: Option<&WorkspaceId>,
10497        root: Option<&str>,
10498    ) -> bool {
10499        self.managed_worktrees.lock()
10500            .unwrap_or_else(|poisoned| poisoned.into_inner())
10501            .active_records()
10502            .any(|lease| {
10503                workspace_id.is_some_and(|workspace_id| {
10504                    workspace_id == &lease.source_workspace_id
10505                        || workspace_id == &lease.workspace_id
10506                }) || root.is_some_and(|root| worktree_paths_equal(root, &lease.target_root))
10507            })
10508    }
10509
10510    fn reject_managed_reservation(
10511        &self,
10512        workspace_id: Option<&WorkspaceId>,
10513        root: Option<&str>,
10514    ) -> Result<(), NodeFailure> {
10515        if self.managed_reservation_conflict(workspace_id, root) {
10516            Err(failure(
10517                NodeFailureCode::ManagedWorktreeOwnershipConflict,
10518                "managed worktree ownership may only be mutated through its lease cleanup",
10519            ))
10520        } else {
10521            Ok(())
10522        }
10523    }
10524
10525    fn bound_workspace_root(&self, address: &SessionAddress) -> Result<String, NodeFailure> {
10526        self.validate_address(address)?;
10527        self.workspace_root(&address.workspace_id)
10528    }
10529
10530    /// `Resume`/`ResumeWithPrompt` are always a raw-PTY relaunch (see
10531    /// `require_policy`'s own doc on those two requirements), so they keep
10532    /// gating on `binding.runtime_policy`'s PTY-terminal-text-inference
10533    /// flags exactly as before; `pipe_transport`/`acp_transport` stay
10534    /// `false` on the `require_policy` call below for them because a PTY
10535    /// resume never needs those facts, not because this method cannot look
10536    /// them up.
10537    ///
10538    /// `SemanticPrompt` -- the requirement `Prompt`/`Paste` share -- reads
10539    /// the SAME `binding.runtime_policy` every other requirement reads here
10540    /// (one computed policy, read by every gate). That policy is no longer
10541    /// PTY-terminal-verification-shaped for an ACP session --
10542    /// `spawn_session_with_deadline` computes it BY TRANSPORT now, through
10543    /// `provider_runtime::policy_for_transport` -- so consulting it for
10544    /// `Prompt`/`Paste` on ACP is sound where it used to gate the wrong fact
10545    /// (this is what made an ACP `Prompt` come back `UnsupportedCapability`
10546    /// even though a prompt is an ACP session's only interface). Pipe keeps
10547    /// reading the same PTY-probe-derived value it always has --
10548    /// `policy_for_transport` passes Pipe through unchanged this pass (see
10549    /// its own doc for why).
10550    ///
10551    /// What this method still adds on top, for ACP/Pipe only, is a
10552    /// turn-in-flight PRECONDITION ahead of that policy check: a session
10553    /// takes one turn at a time, so a `Prompt`/`Paste` is refused by name
10554    /// (`NodeFailureCode::TurnInFlight`) while a turn is already running,
10555    /// read off the same live `SessionSnapshot` state `require_acp_session`
10556    /// already reads for the ACP control verbs -- `TurnStarted` observed
10557    /// with no matching `TurnCompleted` yet is exactly `ProviderSnapshot::
10558    /// lead_activity != Idle`. Sending a second prompt mid-turn would put
10559    /// its fate in vendor-specific behaviour this node cannot observe (one
10560    /// agent may queue it, another drop it, another interleave it), so this
10561    /// refuses it outright rather than gambling on that -- a rule with no
10562    /// PTY analogue, which is why PTY does not take this extra precondition.
10563    fn require_session_runtime_policy(
10564        &self,
10565        address: &SessionAddress,
10566        requirement: ProviderRuntimeRequirement,
10567    ) -> Result<ProviderRuntimePolicy, NodeFailure> {
10568        let runtime_policy = {
10569            let bindings = self
10570                .session_bindings
10571                .lock()
10572                .unwrap_or_else(|poisoned| poisoned.into_inner());
10573            let binding = bindings
10574                .get(&address.session.instance_id)
10575                .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10576            if binding.workspace_id != address.workspace_id {
10577                return Err(failure(
10578                    NodeFailureCode::SessionWorkspaceMismatch,
10579                    "session belongs to another workspace",
10580                ));
10581            }
10582            if binding.generation != address.session.generation {
10583                return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10584            }
10585            binding.runtime_policy
10586        };
10587        if requirement == ProviderRuntimeRequirement::SemanticPrompt {
10588            let snapshot = self.internal_session_snapshot(address)?;
10589            if matches!(snapshot.transport, TransportKind::Acp | TransportKind::Pipe)
10590                && snapshot.provider.lead_activity != ProviderActivity::Idle
10591            {
10592                return Err(failure(
10593                    NodeFailureCode::TurnInFlight,
10594                    "session already has a turn in flight; wait for it to complete before sending another prompt",
10595                ));
10596            }
10597        }
10598        require_policy(runtime_policy, false, false, requirement)
10599            .map(|()| runtime_policy)
10600            .map_err(|_| failure(
10601                NodeFailureCode::UnsupportedCapability,
10602                "session runtime policy does not admit this semantic operation",
10603            ))
10604    }
10605
10606    fn remove_binding(&self, address: &SessionAddress) -> Option<SessionBinding> {
10607        let mut bindings = self
10608            .session_bindings
10609            .lock()
10610            .unwrap_or_else(|poisoned| poisoned.into_inner());
10611        if bindings.get(&address.session.instance_id).is_some_and(|binding| {
10612            binding.workspace_id == address.workspace_id
10613                && binding.generation == address.session.generation
10614        }) {
10615            let removed = bindings.remove(&address.session.instance_id);
10616            self.clear_terminal_frame_watermark(address);
10617            SessionLifecycleHook::on_binding_removed(self, address, removed.as_ref());
10618            return removed;
10619        }
10620        None
10621    }
10622
10623    fn cleanup_session_owned_materialization(
10624        &self,
10625        address: &SessionAddress,
10626        binding: &SessionBinding,
10627    ) -> Result<(), NodeFailure> {
10628        let Some(id) = binding.materialization_id.as_ref() else {
10629            return Ok(());
10630        };
10631        let owner = self
10632            .materializations
10633            .lock()
10634            .unwrap_or_else(|poisoned| poisoned.into_inner())
10635            .get(id)
10636            .map(|record| record.owner().clone());
10637        match owner {
10638            None | Some(MaterializationOwner::Record { .. }) => Ok(()),
10639            Some(MaterializationOwner::Session {
10640                incarnation_id,
10641                instance_id,
10642                generation,
10643            }) if incarnation_id == self.incarnation_id
10644                && instance_id == address.session.instance_id
10645                && generation == address.session.generation =>
10646            {
10647                self.cleanup_materialization(id)
10648            }
10649            Some(MaterializationOwner::Session { .. }) => Err(failure(
10650                NodeFailureCode::BackendOperationFailed,
10651                "session environment ownership requires recovery",
10652            )),
10653        }
10654    }
10655
10656    fn clear_terminal_frame_watermark(&self, address: &SessionAddress) {
10657        let mut watermarks = self
10658            .terminal_frame_watermarks
10659            .lock()
10660            .unwrap_or_else(|poisoned| poisoned.into_inner());
10661        if watermarks
10662            .get(&address.session.instance_id)
10663            .is_some_and(|(current, _)| current == address)
10664        {
10665            watermarks.remove(&address.session.instance_id);
10666        }
10667    }
10668
10669    #[cfg(test)]
10670    fn address_for(
10671        &self,
10672        instance_id: AgentInstanceId,
10673        generation: SessionGeneration,
10674    ) -> Option<SessionAddress> {
10675        let binding = self.session_bindings
10676            .lock()
10677            .unwrap_or_else(|poisoned| poisoned.into_inner())
10678            .get(&instance_id)
10679            .cloned()?;
10680        (binding.generation == generation).then_some(SessionAddress {
10681            workspace_id: binding.workspace_id,
10682            session: SessionKey { instance_id, generation },
10683        })
10684    }
10685
10686    fn arm_resume(
10687        &self,
10688        address: &SessionAddress,
10689        command_id: CommandId,
10690        runtime_policy: ProviderRuntimePolicy,
10691    ) -> Result<(), NodeFailure> {
10692        let mut bindings = self
10693            .session_bindings
10694            .lock()
10695            .unwrap_or_else(|poisoned| poisoned.into_inner());
10696        let binding = bindings
10697            .get_mut(&address.session.instance_id)
10698            .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10699        if binding.workspace_id != address.workspace_id {
10700            return Err(failure(
10701                NodeFailureCode::SessionWorkspaceMismatch,
10702                "session belongs to another workspace",
10703            ));
10704        }
10705        if binding.generation != address.session.generation {
10706            return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10707        }
10708        binding.pending_resume = Some((binding.generation, command_id, runtime_policy));
10709        Ok(())
10710    }
10711
10712    fn clear_armed_resume(&self, address: &SessionAddress) {
10713        let mut bindings = self
10714            .session_bindings
10715            .lock()
10716            .unwrap_or_else(|poisoned| poisoned.into_inner());
10717        if let Some(binding) = bindings.get_mut(&address.session.instance_id) {
10718            if binding.workspace_id == address.workspace_id
10719                && binding.generation == address.session.generation
10720            {
10721                binding.pending_resume = None;
10722            }
10723        }
10724    }
10725
10726    fn publish_control(&self, event: ControlEvent) {
10727        let revokes_harness_mcp = matches!(
10728            &event.event,
10729            ControlEventKind::Exited { .. }
10730                | ControlEventKind::Failed { .. }
10731                | ControlEventKind::Removed
10732        );
10733        let (address, previous_address, record_id, identity_admitted, resume_command_rejected) = {
10734            let mut bindings = self
10735                .session_bindings
10736                .lock()
10737                .unwrap_or_else(|poisoned| poisoned.into_inner());
10738            let Some(binding) = bindings.get_mut(&event.instance_id) else {
10739                return;
10740            };
10741            let previous_address = SessionAddress {
10742                workspace_id: binding.workspace_id.clone(),
10743                session: SessionKey {
10744                    instance_id: event.instance_id,
10745                    generation: binding.generation,
10746                },
10747            };
10748            let mut resume_command_rejected = false;
10749            if binding.generation == event.generation {
10750                if let Some((pending_generation, pending_command_id, _)) = binding.pending_resume {
10751                    let rejected_resume = matches!(
10752                        &event.event,
10753                        ControlEventKind::CommandRejected { .. }
10754                    ) && event.command_id == Some(pending_command_id);
10755                    let resume_failed = matches!(
10756                        &event.event,
10757                        ControlEventKind::ResumeDenied { .. }
10758                            | ControlEventKind::ResumeFailed { .. }
10759                    );
10760                    if pending_generation == binding.generation
10761                        && (rejected_resume || resume_failed)
10762                    {
10763                        resume_command_rejected = rejected_resume;
10764                        binding.pending_resume = None;
10765                    }
10766                }
10767            } else {
10768                let expected = binding.generation.0.checked_add(1);
10769                let authorized = matches!(&event.event, ControlEventKind::ResumeAuthorized { .. });
10770                if !binding.pending_resume.is_some_and(|(generation, _, _)| {
10771                    generation == binding.generation
10772                })
10773                    || expected != Some(event.generation.0)
10774                    || !authorized
10775                {
10776                    return;
10777                }
10778                let (_, _, runtime_policy) = binding
10779                    .pending_resume
10780                    .expect("authorized resume retains its pending runtime policy");
10781                binding.generation = event.generation;
10782                binding.runtime_policy = runtime_policy;
10783                binding.pending_resume = None;
10784            }
10785            (
10786                SessionAddress {
10787                    workspace_id: binding.workspace_id.clone(),
10788                    session: SessionKey {
10789                        instance_id: event.instance_id,
10790                        generation: event.generation,
10791                    },
10792                },
10793                (previous_address.session.generation != event.generation)
10794                    .then_some(previous_address),
10795                binding.record_id.clone(),
10796                binding.runtime_policy.provider_session_identity,
10797                resume_command_rejected,
10798            )
10799        };
10800        if let Some(previous_address) = previous_address.as_ref() {
10801            self.clear_terminal_frame_watermark(previous_address);
10802        }
10803        if let Some(record_id) = record_id {
10804            self.reconcile_managed_record(
10805                &record_id,
10806                &address,
10807                &event,
10808                identity_admitted,
10809                resume_command_rejected,
10810            );
10811        }
10812        // Every state change a session's process lifecycle can reach gets a
10813        // line here: this is the single chokepoint every `ControlEventKind`
10814        // passes through regardless of transport (PTY/pipe/ACP) or whether it
10815        // came from a fresh spawn or a resume. Per-message provider chatter
10816        // (`ProviderEvent`/`ProviderGap`/terminal frames) is deliberately not
10817        // matched here -- that is exactly the per-frame noise this crate
10818        // family's level discipline forbids at INFO.
10819        match &event.event {
10820            ControlEventKind::Running { process_id } => {
10821                if let Some(pid) = process_id {
10822                    crate::git_watch::running(address.workspace_id.as_str(), address.session.instance_id.0, "", *pid);
10823                }
10824                #[cfg(unix)]
10825                if let (Some(proxy), Some(pid)) = (self.signer_proxy.get(), process_id) {
10826                    proxy.attach_pid(&format!("{}-{}", self.incarnation_id, address.session.instance_id.0), *pid);
10827                }
10828                tracing::info!(
10829                    node_id = %self.node_id,
10830                    workspace_id = %address.workspace_id,
10831                    session = ?address.session,
10832                    pid = ?process_id,
10833                    "session process is running",
10834                );
10835            }
10836            ControlEventKind::Exited { exit_code, forced } => {
10837                crate::git_watch::exited(address.workspace_id.as_str(), address.session.instance_id.0);
10838                #[cfg(unix)]
10839                if let Some(proxy) = self.signer_proxy.get().cloned() {
10840                    let key = format!("{}-{}", self.incarnation_id, address.session.instance_id.0);
10841                    if proxy.has_session(&key) {
10842                        // Revoke at core off the event path (blocking uplink call).
10843                        std::thread::spawn(move || proxy.release(&key));
10844                    }
10845                }
10846                tracing::info!(
10847                    node_id = %self.node_id,
10848                    workspace_id = %address.workspace_id,
10849                    session = ?address.session,
10850                    exit_code = ?exit_code,
10851                    forced = forced,
10852                    "session process exited",
10853                );
10854            }
10855            ControlEventKind::Failed { message } => {
10856                tracing::warn!(
10857                    node_id = %self.node_id,
10858                    workspace_id = %address.workspace_id,
10859                    session = ?address.session,
10860                    cause = %message,
10861                    "session failed",
10862                );
10863            }
10864            ControlEventKind::CommandRejected { message } => {
10865                tracing::warn!(
10866                    node_id = %self.node_id,
10867                    workspace_id = %address.workspace_id,
10868                    session = ?address.session,
10869                    cause = %message,
10870                    "session command rejected",
10871                );
10872            }
10873            ControlEventKind::Removed => {
10874                tracing::info!(
10875                    node_id = %self.node_id,
10876                    workspace_id = %address.workspace_id,
10877                    session = ?address.session,
10878                    "session removed",
10879                );
10880            }
10881            ControlEventKind::ResumeAuthorized { session: provider_session } => {
10882                tracing::info!(
10883                    node_id = %self.node_id,
10884                    workspace_id = %address.workspace_id,
10885                    session = ?address.session,
10886                    provider_session = ?provider_session,
10887                    "session resume authorized",
10888                );
10889            }
10890            ControlEventKind::Resumed { session: provider_session, process_id } => {
10891                tracing::info!(
10892                    node_id = %self.node_id,
10893                    workspace_id = %address.workspace_id,
10894                    session = ?address.session,
10895                    provider_session = ?provider_session,
10896                    pid = ?process_id,
10897                    "session resumed",
10898                );
10899            }
10900            ControlEventKind::ResumeDenied { reason } => {
10901                tracing::warn!(
10902                    node_id = %self.node_id,
10903                    workspace_id = %address.workspace_id,
10904                    session = ?address.session,
10905                    cause = %reason,
10906                    "session resume denied",
10907                );
10908            }
10909            ControlEventKind::ResumeFailed { message } => {
10910                tracing::warn!(
10911                    node_id = %self.node_id,
10912                    workspace_id = %address.workspace_id,
10913                    session = ?address.session,
10914                    cause = %message,
10915                    "session resume failed",
10916                );
10917            }
10918            _ => {}
10919        }
10920        self.apply_pending_acp_options(&address, &event);
10921        self.apply_acp_auto_permit(&address, &event);
10922        trace_acp_turn(&address, &event);
10923        let stream_chunk = agent_stream_chunk(&event);
10924        self.publish(NodeEvent::Control {
10925            address: address.clone(),
10926            event,
10927        });
10928        if let Some(chunk) = stream_chunk {
10929            self.publish_agent_stream_chunk(address.clone(), chunk);
10930        }
10931        if revokes_harness_mcp {
10932            SessionLifecycleHook::on_session_ended(self, &address);
10933        }
10934    }
10935
10936    fn reconcile_managed_record(
10937        &self,
10938        record_id: &SessionRecordId,
10939        address: &SessionAddress,
10940        event: &ControlEvent,
10941        identity_admitted: bool,
10942        resume_command_rejected: bool,
10943    ) {
10944        let _transaction = self
10945            .state_transaction
10946            .lock()
10947            .unwrap_or_else(|poisoned| poisoned.into_inner());
10948        let observed_identity = match &event.event {
10949            ControlEventKind::ProviderEvent {
10950                event: gate4agent_types::ProviderEvent::SessionIdentityObserved { identity },
10951                ..
10952            } if identity_admitted => Some(identity.clone()),
10953            _ => None,
10954        };
10955        let identity_was_observed = observed_identity.is_some();
10956        let replacement_id = observed_identity
10957            .as_ref()
10958            .and_then(|_| self.allocate_record_id().ok());
10959        let mut upserts = Vec::new();
10960        let mut removals = Vec::new();
10961        let mut rebind = None;
10962        let original_records;
10963        let original_materializations = self
10964            .materializations
10965            .lock()
10966            .unwrap_or_else(|poisoned| poisoned.into_inner())
10967            .clone();
10968        {
10969            let mut records = self
10970                .session_records
10971                .lock()
10972                .unwrap_or_else(|poisoned| poisoned.into_inner());
10973            original_records = records.records.clone();
10974            let Some(current_snapshot) = records.records.get(record_id).cloned() else {
10975                return;
10976            };
10977            if let Some(identity) = observed_identity {
10978                let existing_id = records
10979                    .records
10980                    .values()
10981                    .find(|record| {
10982                        record.record_id != *record_id
10983                            && record.provider_session.as_ref().is_some_and(|record_identity| {
10984                                session_registry::same_provider_session(
10985                                    &record.provider,
10986                                    record_identity,
10987                                    &current_snapshot.provider,
10988                                    &identity,
10989                                )
10990                            })
10991                    })
10992                    .map(|record| record.record_id.clone());
10993                if let Some(existing_id) = existing_id {
10994                    let scope_matches = records.records.get(&existing_id).is_some_and(|record| {
10995                        record.mode == current_snapshot.mode
10996                            && record.workspace_id == current_snapshot.workspace_id
10997                            && record.environment_profile == current_snapshot.environment_profile
10998                            && record.bundle == current_snapshot.bundle
10999                            && record.context_id == current_snapshot.context_id
11000                            && record.context == current_snapshot.context
11001                            && record
11002                                .canonical_root
11003                                .as_utf8()
11004                                .zip(current_snapshot.canonical_root.as_utf8())
11005                                .is_some_and(|(left, right)| platform::roots_equal(left, right))
11006                    });
11007                    if !scope_matches {
11008                        if let Some(current) = records.records.get_mut(record_id) {
11009                            current.state = ManagedSessionState::Unavailable;
11010                            current.last_error =
11011                                Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR.to_owned());
11012                            current.updated_at_unix_ms = unix_time_ms();
11013                            upserts.push(current.clone());
11014                        }
11015                    } else {
11016                        let existing_is_active = records
11017                            .records
11018                            .get(&existing_id)
11019                            .is_some_and(|record| record.active_session.is_some());
11020                        if existing_is_active {
11021                            if let Some(current) = records.records.get_mut(record_id) {
11022                                current.state = ManagedSessionState::Unavailable;
11023                                current.last_error =
11024                                    Some(PROVIDER_SESSION_LIVE_CONFLICT_ERROR.to_owned());
11025                                current.updated_at_unix_ms = unix_time_ms();
11026                                upserts.push(current.clone());
11027                            }
11028                        } else {
11029                            if current_snapshot.provider_session.is_some() {
11030                                if let Some(current) = records.records.get_mut(record_id) {
11031                                    current.active_session = None;
11032                                    current.state = ManagedSessionState::Dormant;
11033                                    current.updated_at_unix_ms = unix_time_ms();
11034                                    upserts.push(current.clone());
11035                                }
11036                            } else {
11037                                records.records.remove(record_id);
11038                                removals.push(record_id.clone());
11039                            }
11040                            if let Some(existing) = records.records.get_mut(&existing_id) {
11041                                existing.provider_session = Some(identity.clone());
11042                                existing.active_session = Some(address.clone());
11043                                existing.state = ManagedSessionState::Live;
11044                                existing.updated_at_unix_ms = unix_time_ms();
11045                                existing.last_error = None;
11046                                upserts.push(existing.clone());
11047                            }
11048                            rebind = Some(existing_id);
11049                        }
11050                    }
11051                } else if current_snapshot
11052                    .provider_session
11053                    .as_ref()
11054                    .is_some_and(|current| {
11055                        !session_registry::same_provider_session(
11056                            &current_snapshot.provider,
11057                            current,
11058                            &current_snapshot.provider,
11059                            &identity,
11060                        )
11061                    })
11062                {
11063                    if let Some(current) = records.records.get_mut(record_id) {
11064                        current.active_session = None;
11065                        if current.bundle.is_some() || current.context.is_some() {
11066                            let had_bundle = current.bundle.is_some();
11067                            current.provider_session = None;
11068                            current.bundle = None;
11069                            current.context_id = None;
11070                            current.context = None;
11071                            current.state = ManagedSessionState::Unavailable;
11072                            current.last_error = Some(if had_bundle {
11073                                "bundle-unavailable".to_owned()
11074                            } else {
11075                                "context-unavailable".to_owned()
11076                            });
11077                        } else {
11078                            current.state = ManagedSessionState::Dormant;
11079                        }
11080                        current.updated_at_unix_ms = unix_time_ms();
11081                        upserts.push(current.clone());
11082                    }
11083                    if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
11084                        if let Some(current) = records.records.get_mut(record_id) {
11085                            current.state = ManagedSessionState::Unavailable;
11086                            current.last_error =
11087                                Some(MANAGED_SESSION_CAPACITY_ERROR.to_owned());
11088                            current.updated_at_unix_ms = unix_time_ms();
11089                            upserts.push(current.clone());
11090                        }
11091                    } else if let Some(new_record_id) = replacement_id {
11092                        let now = unix_time_ms();
11093                        let replacement = ManagedSessionRecord {
11094                            record_id: new_record_id.clone(),
11095                            display_name: format!(
11096                                "{} #{}",
11097                                current_snapshot.provider.as_str(),
11098                                records
11099                                    .records
11100                                    .values()
11101                                    .filter(|record| {
11102                                        record.provider == current_snapshot.provider
11103                                    })
11104                                    .count()
11105                                    .saturating_add(1),
11106                            ),
11107                            provider: current_snapshot.provider.clone(),
11108                            mode: current_snapshot.mode,
11109                            state: ManagedSessionState::Live,
11110                            workspace_id: current_snapshot.workspace_id,
11111                            canonical_root: current_snapshot.canonical_root,
11112                            provider_session: Some(identity),
11113                            active_session: Some(address.clone()),
11114                            environment_profile: current_snapshot.environment_profile,
11115                            bundle: current_snapshot.bundle,
11116                            context_id: current_snapshot.context_id,
11117                            context: current_snapshot.context,
11118                            // This is a fresh record_id representing the session under its
11119                            // newly-observed identity; it has not yet produced any clean
11120                            // exit of its own, so it starts with no exported pack (the old
11121                            // record, still addressable under its own record_id, keeps
11122                            // whatever exported_context it already had).
11123                            exported_context: None,
11124                            task_binding: current_snapshot.task_binding,
11125                            created_at_unix_ms: now,
11126                            updated_at_unix_ms: now,
11127                            last_error: None,
11128                        };
11129                        records
11130                            .records
11131                            .insert(new_record_id.clone(), replacement.clone());
11132                        upserts.push(replacement);
11133                        rebind = Some(new_record_id);
11134                    } else if let Some(current) = records.records.get_mut(record_id) {
11135                        current.state = ManagedSessionState::Unavailable;
11136                        current.last_error =
11137                            Some(PROVIDER_IDENTITY_ALLOCATION_ERROR.to_owned());
11138                        upserts.push(current.clone());
11139                    }
11140                } else if let Some(current) = records.records.get_mut(record_id) {
11141                    current.provider_session = Some(identity);
11142                    current.active_session = Some(address.clone());
11143                    current.state = ManagedSessionState::Live;
11144                    current.updated_at_unix_ms = unix_time_ms();
11145                    current.last_error = None;
11146                    upserts.push(current.clone());
11147                }
11148            } else if let Some(current) = records.records.get_mut(record_id) {
11149                if resume_command_rejected {
11150                    current.active_session = None;
11151                    current.state = detached_record_state(current);
11152                    current.updated_at_unix_ms = unix_time_ms();
11153                    current.last_error =
11154                        Some(PROVIDER_RESUME_REJECTED_ERROR.to_owned());
11155                    upserts.push(current.clone());
11156                } else {
11157                    match &event.event {
11158                    ControlEventKind::Running { .. }
11159                    | ControlEventKind::ResumeAuthorized { .. }
11160                    | ControlEventKind::Resumed { .. } => {
11161                        current.active_session = Some(address.clone());
11162                        current.state = active_record_state(
11163                            identity_admitted,
11164                            current.provider_session.is_some(),
11165                        );
11166                        current.updated_at_unix_ms = unix_time_ms();
11167                        current.last_error = None;
11168                        upserts.push(current.clone());
11169                    }
11170                    ControlEventKind::Exited { .. } | ControlEventKind::Removed => {
11171                        current.active_session = None;
11172                        current.state = detached_record_state(current);
11173                        current.updated_at_unix_ms = unix_time_ms();
11174                        upserts.push(current.clone());
11175                    }
11176                    ControlEventKind::Failed { message }
11177                    | ControlEventKind::ResumeFailed { message } => {
11178                        current.active_session = None;
11179                        current.state = detached_record_state(current);
11180                        current.updated_at_unix_ms = unix_time_ms();
11181                        current.last_error = Some(
11182                            session_registry::sanitized_record_error_summary(message),
11183                        );
11184                        upserts.push(current.clone());
11185                    }
11186                    ControlEventKind::ResumeDenied { reason } => {
11187                        current.active_session = None;
11188                        current.state = detached_record_state(current);
11189                        current.updated_at_unix_ms = unix_time_ms();
11190                        current.last_error = Some(
11191                            session_registry::sanitized_record_error_summary(reason),
11192                        );
11193                        upserts.push(current.clone());
11194                    }
11195                        _ => {}
11196                    }
11197                }
11198            }
11199        }
11200        let materialization_owner_record = identity_was_observed.then(|| {
11201            rebind.clone().unwrap_or_else(|| record_id.clone())
11202        }).and_then(|candidate| {
11203            self.session_records
11204                .lock()
11205                .unwrap_or_else(|poisoned| poisoned.into_inner())
11206                .records
11207                .get(&candidate)
11208                .filter(|record| {
11209                    record.provider_session.is_some()
11210                        && record.active_session.as_ref() == Some(address)
11211                })
11212                .map(|_| candidate)
11213        });
11214        if let Some(owner_record_id) = materialization_owner_record {
11215            let materialization_id = self
11216                .session_bindings
11217                .lock()
11218                .unwrap_or_else(|poisoned| poisoned.into_inner())
11219                .get(&address.session.instance_id)
11220                .and_then(|binding| binding.materialization_id.clone());
11221            if let Some(materialization_id) = materialization_id {
11222                let mut transfer_failed = false;
11223                {
11224                    let mut materializations = self
11225                        .materializations
11226                        .lock()
11227                        .unwrap_or_else(|poisoned| poisoned.into_inner());
11228                    if let Some(ownership) = materializations.get_mut(&materialization_id) {
11229                        match ownership.owner() {
11230                            MaterializationOwner::Session {
11231                                incarnation_id,
11232                                instance_id,
11233                                generation,
11234                            } if *incarnation_id == self.incarnation_id
11235                                && *instance_id == address.session.instance_id
11236                                && *generation == address.session.generation =>
11237                            {
11238                                transfer_failed = ownership
11239                                    .transfer_to_record(owner_record_id.clone(), unix_time_ms())
11240                                    .is_err();
11241                            }
11242                            MaterializationOwner::Record { record_id }
11243                                if record_id == &owner_record_id => {}
11244                            MaterializationOwner::Record { record_id } => {
11245                                let expected = record_id.clone();
11246                                transfer_failed = ownership
11247                                    .transfer_record_owner(
11248                                        &expected,
11249                                        owner_record_id.clone(),
11250                                        unix_time_ms(),
11251                                    )
11252                                    .is_err();
11253                            }
11254                            _ => transfer_failed = true,
11255                        }
11256                        if transfer_failed {
11257                            let _ = ownership.mark_recovery_required(unix_time_ms());
11258                        }
11259                    } else {
11260                        transfer_failed = true;
11261                    }
11262                }
11263                if transfer_failed {
11264                    if let Some(record) = self
11265                        .session_records
11266                        .lock()
11267                        .unwrap_or_else(|poisoned| poisoned.into_inner())
11268                        .records
11269                        .get_mut(&owner_record_id)
11270                    {
11271                        record.state = ManagedSessionState::Unavailable;
11272                        record.last_error = Some("environment-profile-unavailable".to_owned());
11273                        record.updated_at_unix_ms = unix_time_ms();
11274                        upserts.push(record.clone());
11275                    }
11276                }
11277            }
11278        }
11279        if let Some(new_record_id) = rebind.clone() {
11280            if let Some(binding) = self
11281                .session_bindings
11282                .lock()
11283                .unwrap_or_else(|poisoned| poisoned.into_inner())
11284                .get_mut(&address.session.instance_id)
11285            {
11286                binding.record_id = Some(new_record_id);
11287            }
11288        }
11289        if upserts.is_empty() && removals.is_empty() {
11290            return;
11291        }
11292        if self.persist_state_locked().is_err() {
11293            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
11294            let mut records = self
11295                .session_records
11296                .lock()
11297                .unwrap_or_else(|poisoned| poisoned.into_inner());
11298            records.records = original_records;
11299            *self
11300                .materializations
11301                .lock()
11302                .unwrap_or_else(|poisoned| poisoned.into_inner()) = original_materializations;
11303            if let Some(current) = records.records.get_mut(record_id) {
11304                current.state = ManagedSessionState::Unavailable;
11305                current.last_error = Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned());
11306                current.updated_at_unix_ms = unix_time_ms();
11307                upserts = vec![current.clone()];
11308            } else {
11309                upserts.clear();
11310            }
11311            removals.clear();
11312            drop(records);
11313            if let Some(binding) = self
11314                .session_bindings
11315                .lock()
11316                .unwrap_or_else(|poisoned| poisoned.into_inner())
11317                .get_mut(&address.session.instance_id)
11318            {
11319                binding.record_id = Some(record_id.clone());
11320            }
11321            eprintln!("gate4agent-node: {DURABLE_STATE_COMMIT_FAILED_ERROR}");
11322        }
11323        for record in upserts {
11324            self.publish_record(record);
11325        }
11326        for record_id in removals {
11327            self.publish(NodeEvent::SessionRecordRemoved { record_id });
11328        }
11329    }
11330
11331    fn snapshot(&self) -> NodeSnapshot {
11332        let control = self.handle.snapshot();
11333        let bindings = self
11334            .session_bindings
11335            .lock()
11336            .unwrap_or_else(|poisoned| poisoned.into_inner())
11337            .clone();
11338        let workspace_roots = self
11339            .workspaces
11340            .read()
11341            .unwrap_or_else(|poisoned| poisoned.into_inner())
11342            .clone();
11343        let environment_profiles = self
11344            .environment_profiles
11345            .read()
11346            .unwrap_or_else(|poisoned| poisoned.into_inner());
11347        let mut workspaces = workspace_roots
11348            .iter()
11349            .map(|(workspace_id, canonical_root)| {
11350                (
11351                    workspace_id.clone(),
11352                    WorkspaceSnapshot {
11353                        workspace_id: workspace_id.clone(),
11354                        canonical_root: opaque_windows_path(canonical_root.clone()),
11355                        sessions: Vec::new(),
11356                        worktree_service_mode: Some(
11357                            self.worktree_service_modes
11358                                .get(workspace_id)
11359                                .copied()
11360                                .unwrap_or(WorktreeServiceMode::Manual),
11361                        ),
11362                        managed_worktree_profiles: Some(WorktreeProfileInventory {
11363                            profiles: self
11364                                .managed_worktree_profiles
11365                                .get(workspace_id)
11366                                .into_iter()
11367                                .flat_map(BTreeMap::values)
11368                                .map(|profile| ManagedWorktreeProfileSummary {
11369                                    id: profile.profile_id().clone(),
11370                                    revision: profile.revision().clone(),
11371                                    retention: profile.retention(),
11372                                })
11373                                .collect(),
11374                        }),
11375                    },
11376                )
11377            })
11378            .collect::<BTreeMap<_, _>>();
11379        let mut agent_progress = Vec::new();
11380        for session in &control.sessions {
11381            let Some(binding) = bindings.get(&session.instance_id) else {
11382                continue;
11383            };
11384            if binding.generation != session.generation {
11385                continue;
11386            }
11387            if let Some(workspace) = workspaces.get_mut(&binding.workspace_id) {
11388                let address = SessionAddress {
11389                    workspace_id: binding.workspace_id.clone(),
11390                    session: SessionKey {
11391                        instance_id: session.instance_id,
11392                        generation: session.generation,
11393                    },
11394                };
11395                if let Some(progress) =
11396                    agent_progress_from_provider_snapshot(address, &session.provider)
11397                {
11398                    agent_progress.push(progress);
11399                }
11400                workspace.sessions.push(session.clone());
11401            }
11402        }
11403        NodeSnapshot {
11404            node_id: self.node_id.clone(),
11405            enabled_providers: self.enabled_providers.clone(),
11406            provider_runtime_statuses: {
11407                let updates = self
11408                    .provider_runtime_status_updates
11409                    .read()
11410                    .unwrap_or_else(|poisoned| poisoned.into_inner());
11411                ProviderRuntimeStatuses::new(
11412                    self.provider_runtime_statuses
11413                        .iter()
11414                        .filter(|status| !updates.contains_key(status.provider()))
11415                        .cloned()
11416                        .chain(updates.values().cloned()),
11417                )
11418                .expect("refreshed provider runtime inventory remains bounded and unique")
11419            },
11420            workspaces: workspaces.into_values().collect(),
11421            session_records: self
11422                .session_records
11423                .lock()
11424                .unwrap_or_else(|poisoned| poisoned.into_inner())
11425                .records
11426                .values()
11427                .cloned()
11428                .collect(),
11429            managed_worktrees: self.managed_worktrees
11430                .lock()
11431                .unwrap_or_else(|poisoned| poisoned.into_inner())
11432                .snapshots(),
11433            launch_inventory: Some(LaunchInventory {
11434                spawn_profiles: Some(
11435                    self.spawn_profiles
11436                        .iter()
11437                        .filter_map(|profile| {
11438                            resolved_spawn_profile_summary(profile, &environment_profiles)
11439                        })
11440                        .collect(),
11441                ),
11442                bundles: Some(
11443                    self.bundle_catalog
11444                        .read()
11445                        .unwrap_or_else(|poisoned| poisoned.into_inner())
11446                        .iter()
11447                        .map(NodeBundle::receipt)
11448                        .collect(),
11449                ),
11450                // Opaque station allowlist ids from empty-default catalog /
11451                // optional startup loader. Never secrets. Dig2 probe stays stub.
11452                network_allowlists: {
11453                    let catalog = self
11454                        .network_allowlist_catalog
11455                        .read()
11456                        .unwrap_or_else(|poisoned| poisoned.into_inner());
11457                    if catalog.is_empty() {
11458                        None
11459                    } else {
11460                        Some(catalog.ids().cloned().collect())
11461                    }
11462                },
11463            }),
11464            agent_progress,
11465        }
11466    }
11467
11468    fn publish(&self, event: NodeEvent) -> NodeEventEnvelope {
11469        assert!(
11470            !matches!(
11471                &event,
11472                NodeEvent::TerminalFrame { .. } | NodeEvent::AgentStream { .. }
11473            ),
11474            "terminal frame and agent stream events must use their own dedicated live channel",
11475        );
11476        let mut history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11477        let sequence = history
11478            .last_sequence
11479            .checked_add(1)
11480            .expect("node event sequence exhausted");
11481        if history.events.len() == NODE_EVENT_HISTORY_MAX {
11482            if let Some(evicted) = history.events.pop_front() {
11483                history.replay_floor_sequence = evicted
11484                    .sequence
11485                    .checked_add(1)
11486                    .expect("node replay floor sequence exhausted");
11487                history.removed_record_providers.remove(&evicted.sequence);
11488            }
11489        }
11490        match &event {
11491            NodeEvent::HarnessMcpReadCall { .. } => {
11492                panic!("transient harness MCP calls must not enter durable event history")
11493            }
11494            NodeEvent::SessionRecordUpserted { record } => {
11495                history
11496                    .record_providers
11497                    .insert(record.record_id.clone(), record.provider.clone());
11498            }
11499            NodeEvent::SessionRecordRemoved { record_id } => {
11500                if let Some(provider) = history.record_providers.remove(record_id) {
11501                    history.removed_record_providers.insert(sequence, provider);
11502                }
11503            }
11504            NodeEvent::ManagedWorktreeUpserted { .. }
11505            | NodeEvent::ManagedWorktreeRemoved { .. }
11506            | NodeEvent::Control { .. }
11507            | NodeEvent::ControllerChanged { .. }
11508            | NodeEvent::SessionRecordHistorySummarized { .. }
11509            | NodeEvent::WorkspaceAdded { .. }
11510            | NodeEvent::WorkspaceRemoved { .. }
11511            | NodeEvent::ResyncRequired { .. }
11512            | NodeEvent::AgentStream { .. }
11513            | NodeEvent::TerminalFrame { .. } => {}
11514        }
11515        let envelope = NodeEventEnvelope { sequence, event };
11516        history.events.push_back(envelope.clone());
11517        history.last_sequence = sequence;
11518        let _ = self.event_tx.send(envelope.clone());
11519        drop(history);
11520        envelope
11521    }
11522
11523    /// Publishes a transient (never durable) `NodeEvent` -- today only
11524    /// `NodeEvent::HarnessMcpReadCall` -- onto the dedicated
11525    /// `harness_mcp_proxy_event_tx` broadcast (see that field's own doc for
11526    /// why this must never ride the shared `event_tx` bus). Returns whether
11527    /// the send reached at least one subscriber: `broadcast::Sender::send`
11528    /// fails in exactly one way, no active receiver at that instant, and
11529    /// the caller (`HarnessMcpProxyRegistry::begin_call`) uses a `false`
11530    /// return to fail the pending call immediately rather than leave it to
11531    /// expire silently at `LOCAL_READ_TIMEOUT`/
11532    /// `MAX_HARNESS_MCP_CALL_DEADLINE_MS`.
11533    fn publish_transient(&self, event: NodeEvent) -> bool {
11534        assert!(event.requires_harness_mcp_proxy_capability());
11535        assert!(event.harness_mcp_contract_is_valid_at(unix_time_ms()));
11536        let envelope = NodeEventEnvelope { sequence: 0, event };
11537        match self.harness_mcp_proxy_event_tx.send(Arc::new(vec![envelope])) {
11538            Ok(_) => true,
11539            Err(broadcast::error::SendError(undelivered)) => {
11540                if let Some(NodeEventEnvelope {
11541                    event: NodeEvent::HarnessMcpReadCall { call_id, request, .. },
11542                    ..
11543                }) = undelivered.first() {
11544                    // `request.content_type` only, never a per-tool label --
11545                    // this crate does not decode `request.body` and does not
11546                    // hold `hatchery-harness-api` (Nested Control Plane
11547                    // doctrine, Law 3; see this crate's dependency on
11548                    // `gate4agent-node-protocol`'s own `Forbidden:` line).
11549                    tracing::warn!(
11550                        call_id = call_id.as_str(),
11551                        content_type = ?request.content_type,
11552                        cause = "no-subscribers",
11553                        "harness MCP proxy event could not be published",
11554                    );
11555                }
11556                false
11557            }
11558        }
11559    }
11560
11561    fn publish_terminal_frames(&self) {
11562        let snapshot = self.handle.snapshot();
11563        let bindings = self
11564            .session_bindings
11565            .lock()
11566            .unwrap_or_else(|poisoned| poisoned.into_inner());
11567        let candidates = snapshot
11568            .sessions
11569            .iter()
11570            .filter_map(|session| {
11571                let binding = bindings.get(&session.instance_id)?;
11572                if binding.generation != session.generation {
11573                    return None;
11574                }
11575                let frame = session.terminal_frame.clone()?;
11576                crate::session_signals::feed(
11577                    binding.workspace_id.as_str(),
11578                    session.instance_id.0,
11579                    session.agent_id.as_str(),
11580                    &frame.contents,
11581                );
11582                crate::provider_limits::observe_screen(session.agent_id.as_str(), &frame.contents);
11583                crate::git_watch::provider_hint(binding.workspace_id.as_str(), session.instance_id.0, session.agent_id.as_str());
11584                Some((
11585                    SessionAddress {
11586                        workspace_id: binding.workspace_id.clone(),
11587                        session: SessionKey {
11588                            instance_id: session.instance_id,
11589                            generation: session.generation,
11590                        },
11591                    },
11592                    frame,
11593                ))
11594            })
11595            .collect();
11596        drop(bindings);
11597        self.publish_terminal_frame_candidates(candidates);
11598    }
11599
11600    fn publish_terminal_frame_candidates(
11601        &self,
11602        candidates: Vec<(SessionAddress, TerminalFrame)>,
11603    ) {
11604        let mut watermarks = self
11605            .terminal_frame_watermarks
11606            .lock()
11607            .unwrap_or_else(|poisoned| poisoned.into_inner());
11608        let mut events = Vec::with_capacity(candidates.len());
11609        for (address, frame) in candidates {
11610            let is_first_frame = !watermarks.contains_key(&address.session.instance_id);
11611            let advanced = watermarks
11612                .get(&address.session.instance_id)
11613                .map_or(true, |(current_address, sequence)| {
11614                    current_address != &address || frame.sequence > *sequence
11615                });
11616            if !advanced {
11617                continue;
11618            }
11619            if is_first_frame {
11620                // Do NOT sessionize on first PTY output: a session is a
11621                // provider conversation id, not bytes on the wire. Drafts
11622                // leave the provisional map only when HQ attaches
11623                // (Resize / TerminalBytes). The attach-never-arrived timer
11624                // (PROVISIONAL_SPAWN_GRACE_MS) covers deploy lag; HQ's
11625                // disconnect grace starts only on a real client WS close.
11626                // This is the first PTY output this node has observed for
11627                // this session -- a one-time event per spawn, not per-frame
11628                // chatter (the watermark makes sure of that). A session that
11629                // reaches this line with no controller attached anywhere on
11630                // the node is running unobserved: exactly how processes from
11631                // repeated spawn attempts went orphaned for hours without a
11632                // single line about it.
11633                if self.controller_state().is_none() {
11634                    tracing::warn!(
11635                        node_id = %self.node_id,
11636                        workspace_id = %address.workspace_id,
11637                        session = ?address.session,
11638                        terminal_size = ?frame.size,
11639                        "session producing output with no controller attached",
11640                    );
11641                } else {
11642                    tracing::info!(
11643                        node_id = %self.node_id,
11644                        workspace_id = %address.workspace_id,
11645                        session = ?address.session,
11646                        terminal_size = ?frame.size,
11647                        "session produced its first output",
11648                    );
11649                }
11650            }
11651            watermarks.insert(
11652                address.session.instance_id,
11653                (address.clone(), frame.sequence),
11654            );
11655            if let Some((at, reported)) = self
11656                .pty_input_marks
11657                .lock()
11658                .unwrap_or_else(|poisoned| poisoned.into_inner())
11659                .get_mut(&address.session.instance_id)
11660            {
11661                // First frame after an input: PTY write -> output published.
11662                if !*reported && at.elapsed() < Duration::from_secs(5) {
11663                    *reported = true;
11664                    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");
11665                }
11666            }
11667            events.push(NodeEvent::TerminalFrame { address, frame });
11668        }
11669        drop(watermarks);
11670        if events.is_empty() {
11671            return;
11672        }
11673        let mut history = self
11674            .history
11675            .lock()
11676            .unwrap_or_else(|poisoned| poisoned.into_inner());
11677        let mut envelopes = Vec::with_capacity(events.len());
11678        for event in events {
11679            let sequence = history
11680                .last_sequence
11681                .checked_add(1)
11682                .expect("node event sequence exhausted");
11683            history.last_sequence = sequence;
11684            envelopes.push(NodeEventEnvelope { sequence, event });
11685        }
11686        let _ = self.terminal_event_tx.send(Arc::new(envelopes));
11687        drop(history);
11688    }
11689
11690    /// Publishes one `AgentStreamChunkV1` onto the dedicated agent-stream
11691    /// channel, mirroring `publish_terminal_frame_candidates` above: the
11692    /// envelope's `sequence` is drawn from the same `history.last_sequence`
11693    /// counter every other event kind shares, never a counter of its own --
11694    /// `queue_connection_event`/`queue_connection_event_batch` compare
11695    /// `envelope.sequence` against one `discard_events_through` watermark
11696    /// regardless of which broadcast channel it arrived on, so a
11697    /// per-channel counter would desynchronize that comparison.
11698    fn publish_agent_stream_chunk(&self, address: SessionAddress, chunk: AgentStreamChunkV1) {
11699        let mut history = self
11700            .history
11701            .lock()
11702            .unwrap_or_else(|poisoned| poisoned.into_inner());
11703        let sequence = history
11704            .last_sequence
11705            .checked_add(1)
11706            .expect("node event sequence exhausted");
11707        history.last_sequence = sequence;
11708        drop(history);
11709        // Debug, not info/warn -- this fires on every single chunk, but it
11710        // is exactly the counter the next drop-measurement pass needs: what
11711        // the node actually emitted (`source_sequence`, `kind`) versus what
11712        // a harness subscriber's own forwarder later logs having received
11713        // (`hatchery-harness-service::runtime`'s "forwarding agent stream
11714        // event"), so a gap between the two logs names itself instead of
11715        // needing a new counter added ad hoc.
11716        tracing::debug!(
11717            source_sequence = chunk.source_sequence,
11718            kind = agent_stream_chunk_kind_label(&chunk.kind),
11719            envelope_sequence = sequence,
11720            "publishing agent stream chunk",
11721        );
11722        let envelope = NodeEventEnvelope {
11723            sequence,
11724            event: NodeEvent::AgentStream { address, chunk },
11725        };
11726        let _ = self.agent_stream_event_tx.send(Arc::new(vec![envelope]));
11727    }
11728
11729    fn current_sequence(&self) -> u64 {
11730        self.history
11731            .lock()
11732            .unwrap_or_else(|poisoned| poisoned.into_inner())
11733            .last_sequence
11734    }
11735
11736    fn controller_state(&self) -> Option<ControllerState> {
11737        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11738        let lease = controller.as_ref().copied()?;
11739        let remaining = lease.expires_at.saturating_duration_since(Instant::now());
11740        if remaining.is_zero() {
11741            *controller = None;
11742            return None;
11743        }
11744        Some(ControllerState {
11745            connection_id: lease.connection_id,
11746            lease_remaining_ms: remaining.as_millis().min(u64::MAX as u128) as u64,
11747        })
11748    }
11749
11750    fn acquire_controller(&self, connection_id: u64, role: ClientRole, lease_ms: u64) -> Result<ControllerState, NodeFailure> {
11751        if role != ClientRole::Operator {
11752            tracing::warn!(
11753                node_id = %self.node_id,
11754                connection_id,
11755                "controller lease rejected: observer connections cannot acquire control",
11756            );
11757            return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections cannot acquire control"));
11758        }
11759        let lease_ms = if lease_ms == 0 {
11760            DEFAULT_CONTROLLER_LEASE_MS
11761        } else {
11762            lease_ms.clamp(MIN_CONTROLLER_LEASE_MS, MAX_CONTROLLER_LEASE_MS)
11763        };
11764        let now = Instant::now();
11765        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11766        if let Some(active) = controller.as_ref() {
11767            if active.connection_id != connection_id && active.expires_at > now {
11768                tracing::warn!(
11769                    node_id = %self.node_id,
11770                    connection_id,
11771                    holder_connection_id = active.connection_id,
11772                    "controller lease rejected: another operator holds it",
11773                );
11774                return Err(failure(NodeFailureCode::ControllerBusy, "another operator holds the controller lease"));
11775            }
11776        }
11777        // A reconnect (this connection already holds the lease, renewing
11778        // before expiry) happens on a periodic cadence; logging it at INFO
11779        // every renewal would be exactly the heartbeat chatter this crate
11780        // family's level discipline forbids. A genuine attach -- the
11781        // controller identity actually changing -- is a state change and
11782        // gets INFO; a same-connection renewal gets DEBUG.
11783        let is_new_attach = match controller.as_ref() {
11784            Some(active) => active.connection_id != connection_id,
11785            None => true,
11786        };
11787        *controller = Some(ControllerLease {
11788            connection_id,
11789            expires_at: now + Duration::from_millis(lease_ms),
11790        });
11791        drop(controller);
11792        let state = ControllerState { connection_id, lease_remaining_ms: lease_ms };
11793        if is_new_attach {
11794            tracing::info!(
11795                node_id = %self.node_id,
11796                connection_id,
11797                lease_ms,
11798                "controller lease acquired",
11799            );
11800        } else {
11801            tracing::debug!(
11802                node_id = %self.node_id,
11803                connection_id,
11804                lease_ms,
11805                "controller lease renewed",
11806            );
11807        }
11808        self.publish(NodeEvent::ControllerChanged { controller: Some(state.clone()) });
11809        Ok(state)
11810    }
11811
11812    fn release_controller(&self, connection_id: u64) -> bool {
11813        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11814        let released = controller.as_ref().is_some_and(|active| active.connection_id == connection_id);
11815        if released {
11816            *controller = None;
11817        }
11818        drop(controller);
11819        if released {
11820            tracing::info!(
11821                node_id = %self.node_id,
11822                connection_id,
11823                "controller lease released",
11824            );
11825            self.publish(NodeEvent::ControllerChanged { controller: None });
11826        }
11827        released
11828    }
11829
11830    /// Mark a fresh Spawn as provisional and arm the attach-never-arrived
11831    /// reaper (PROVISIONAL_SPAWN_GRACE_MS from spawn). Removed from this map
11832    /// only when HQ attaches (Resize / TerminalBytes) — that is *not* a
11833    /// provider session; it only cancels the orphan timer. Disconnect/reload
11834    /// draft kills are owned by HQ and arm only on a real client WS close.
11835    /// ACP path of launch options: once the agent advertises its config options, write the
11836    /// pending model / effort through session/set_config_option (session/set_model when the
11837    /// agent has no model option). Gaps are logged, never forced.
11838    fn apply_pending_acp_options(&self, address: &SessionAddress, event: &ControlEvent) {
11839        let ControlEventKind::ProviderEvent { event: ProviderEvent::ConfigOptionsUpdated { options }, .. } = &event.event else {
11840            return;
11841        };
11842        let id = address.session.instance_id;
11843        let Some((model, effort)) = self.pending_acp_options.lock().unwrap_or_else(|p| p.into_inner()).remove(&id) else {
11844            return;
11845        };
11846        let advertised: Vec<(String, Option<String>, Vec<String>)> = options
11847            .iter()
11848            .map(|o| (o.id.clone(), o.category.clone(), o.choices.iter().map(|c| c.value_json.clone()).collect()))
11849            .collect();
11850        let (writes, set_model, effort_gap) =
11851            backend_paths::acp_option_writes(&advertised, model.as_deref(), effort.as_deref());
11852        if set_model {
11853            if let Some(model_id) = model.clone() {
11854                if let Err(e) = self.dispatch(ControlCommand::SetSessionModel { instance_id: id, model_id }) {
11855                    tracing::warn!(instance_id = id.0, message = %e.message, "acp set_model failed");
11856                }
11857            }
11858        }
11859        for (option_id, value_json) in writes {
11860            if let Err(e) = self.dispatch(ControlCommand::SetSessionConfigOption { instance_id: id, option_id, value_json }) {
11861                tracing::warn!(instance_id = id.0, message = %e.message, "acp set_config_option failed");
11862            }
11863        }
11864        if effort_gap {
11865            tracing::warn!(instance_id = id.0, effort = ?effort, "acp agent advertises no matching thought_level option; effort not applied");
11866        }
11867        tracing::info!(instance_id = id.0, model = ?model, effort = ?effort, "launch options applied over acp");
11868    }
11869
11870    /// ACP auto-permit: switch to the provider's no-ask mode once modes are advertised, and
11871    /// answer every session/request_permission with approve.
11872    fn apply_acp_auto_permit(&self, address: &SessionAddress, event: &ControlEvent) {
11873        let id = address.session.instance_id;
11874        let Some(provider) = crate::auto_permit::acp_provider(id.0) else { return };
11875        if matches!(&event.event, ControlEventKind::Exited { .. } | ControlEventKind::Failed { .. } | ControlEventKind::Removed) {
11876            crate::auto_permit::forget_acp(id.0);
11877            return;
11878        }
11879        let ControlEventKind::ProviderEvent { sequence, event: provider_event, .. } = &event.event else { return };
11880        if let ProviderEvent::InteractionRequested { request_id, interaction_kind, options, .. } = provider_event {
11881            tracing::debug!(instance_id = id.0, sequence = *sequence, request_id = ?request_id, kind = ?interaction_kind, options = options.len(), "acp interaction requested");
11882        }
11883        match provider_event {
11884            ProviderEvent::ModeChanged { mode_id, available } => {
11885                if let Some(want) = crate::auto_permit::pick_acp_mode(provider, mode_id, available.iter().map(|m| m.id.as_str())) {
11886                    match self.dispatch(ControlCommand::SetSessionMode { instance_id: id, mode_id: want.clone() }) {
11887                        Ok(_) => tracing::info!(instance_id = id.0, provider, mode = %want, "acp auto-permit mode set"),
11888                        Err(e) => tracing::warn!(instance_id = id.0, provider, message = %e.message, "acp auto-permit set_mode failed"),
11889                    }
11890                }
11891            }
11892            ProviderEvent::InteractionRequested { interaction_kind: gate4agent_types::ProviderInteractionKind::Approval, tool_name, options, .. } if !options.is_empty() => {
11893                match self.dispatch(ControlCommand::ResolveInteraction {
11894                    instance_id: id,
11895                    generation: address.session.generation,
11896                    interaction_id: gate4agent_types::ProviderInteractionId(*sequence),
11897                    response: gate4agent_types::ProviderInteractionResponse::ApproveOnce,
11898                }) {
11899                    Ok(_) => tracing::info!(instance_id = id.0, provider, tool = %tool_name, "acp permission auto-approved"),
11900                    Err(e) => tracing::warn!(instance_id = id.0, provider, message = %e.message, "acp auto-approve failed"),
11901                }
11902            }
11903            // NB: ACP emits SessionEnded at the end of every *turn* (prompt stopReason), so it
11904            // must not drop the auto-permit registration; that happens on process exit below.
11905            _ => {}
11906        }
11907    }
11908
11909    fn mark_spawn_provisional(&self, address: SessionAddress) {
11910        let id = address.session.instance_id;
11911        self.provisional_spawns
11912            .lock()
11913            .unwrap_or_else(|poisoned| poisoned.into_inner())
11914            .insert(id, (address, Instant::now()));
11915        tracing::info!(
11916            node_id = %self.node_id,
11917            instance_id = id.0,
11918            grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
11919            "draft spawn; waiting for HQ view",
11920        );
11921    }
11922
11923    fn sessionize_spawn(&self, instance_id: AgentInstanceId) {
11924        let removed = self
11925            .provisional_spawns
11926            .lock()
11927            .unwrap_or_else(|poisoned| poisoned.into_inner())
11928            .remove(&instance_id);
11929        if removed.is_some() {
11930            tracing::info!(
11931                node_id = %self.node_id,
11932                instance_id = instance_id.0,
11933                "draft spawn claimed by HQ view",
11934            );
11935        }
11936    }
11937
11938    fn take_due_provisional_spawns(&self) -> Vec<SessionAddress> {
11939        let grace = Duration::from_millis(PROVISIONAL_SPAWN_GRACE_MS);
11940        let mut map = self
11941            .provisional_spawns
11942            .lock()
11943            .unwrap_or_else(|poisoned| poisoned.into_inner());
11944        let due: Vec<AgentInstanceId> = map
11945            .iter()
11946            .filter(|(_, (_, at))| at.elapsed() >= grace)
11947            .map(|(id, _)| *id)
11948            .collect();
11949        due.into_iter()
11950            .filter_map(|id| map.remove(&id).map(|(addr, _)| addr))
11951            .collect()
11952    }
11953
11954    async fn reap_provisional_spawn(&self, address: SessionAddress) {
11955        let instance_id = address.session.instance_id;
11956        tracing::warn!(
11957            node_id = %self.node_id,
11958            instance_id = instance_id.0,
11959            workspace = %address.workspace_id,
11960            reason = "attach_never_arrived",
11961            grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
11962            "draft spawn never viewed; stopping PTY",
11963        );
11964        let _ = self.dispatch(ControlCommand::Stop {
11965            instance_id,
11966            force: true,
11967        });
11968        let _ = self.remove_session(&address).await;
11969    }
11970
11971    fn require_controller(&self, connection_id: u64, role: ClientRole) -> Result<(), NodeFailure> {
11972        if role != ClientRole::Operator {
11973            return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections are read-only"));
11974        }
11975        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11976        let now = Instant::now();
11977        match controller.as_ref().copied() {
11978            Some(active) if active.connection_id == connection_id && active.expires_at > now => Ok(()),
11979            Some(active) if active.expires_at <= now => {
11980                *controller = None;
11981                Err(failure(NodeFailureCode::ControllerRequired, "controller lease expired"))
11982            }
11983            _ => Err(failure(NodeFailureCode::ControllerRequired, "controller lease is required")),
11984        }
11985    }
11986
11987    fn validate_address(&self, address: &SessionAddress) -> Result<AgentId, NodeFailure> {
11988        if !self
11989            .workspaces
11990            .read()
11991            .unwrap_or_else(|poisoned| poisoned.into_inner())
11992            .contains_key(&address.workspace_id)
11993        {
11994            return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
11995        }
11996        let binding = self
11997            .session_bindings
11998            .lock()
11999            .unwrap_or_else(|poisoned| poisoned.into_inner())
12000            .get(&address.session.instance_id)
12001            .cloned()
12002            .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
12003        if binding.workspace_id != address.workspace_id {
12004            return Err(failure(
12005                NodeFailureCode::SessionWorkspaceMismatch,
12006                "session belongs to another workspace",
12007            ));
12008        }
12009        if binding.generation != address.session.generation {
12010            return Err(failure(
12011                NodeFailureCode::StaleGeneration,
12012                &format!(
12013                    "stale session generation: expected {}, received {}",
12014                    binding.generation.0,
12015                    address.session.generation.0,
12016                ),
12017            ));
12018        }
12019        let snapshot = self.handle.snapshot();
12020        let Some(current) = snapshot
12021            .sessions
12022            .iter()
12023            .find(|item| item.instance_id == address.session.instance_id)
12024        else {
12025            return Err(failure(NodeFailureCode::UnknownSession, "session instance does not exist"));
12026        };
12027        if current.generation != address.session.generation {
12028            return Err(failure(
12029                NodeFailureCode::StaleGeneration,
12030                &format!(
12031                    "stale session generation: expected {}, received {}",
12032                    current.generation.0,
12033                    address.session.generation.0,
12034                ),
12035            ));
12036        }
12037        Ok(current.agent_id.clone())
12038    }
12039
12040    fn dispatch(&self, command: ControlCommand) -> Result<CommandId, NodeFailure> {
12041        self.dispatch_envelope(self.prepare_command(command))
12042    }
12043
12044    fn prepare_command(&self, command: ControlCommand) -> CommandEnvelope {
12045        let id = self.next_command_id.fetch_add(1, Ordering::AcqRel);
12046        CommandEnvelope {
12047            id: CommandId(id),
12048            command,
12049        }
12050    }
12051
12052    fn dispatch_envelope(&self, envelope: CommandEnvelope) -> Result<CommandId, NodeFailure> {
12053        let command_id = envelope.id;
12054        self.handle
12055            .dispatch(envelope)
12056            .map_err(|error| match error {
12057                PortDispatchError::Full => failure(NodeFailureCode::BackendBusy, "backend command ingress is full"),
12058                PortDispatchError::Disconnected => failure(NodeFailureCode::BackendDisconnected, "backend command ingress is disconnected"),
12059            })?;
12060        Ok(command_id)
12061    }
12062
12063    /// Records the kernel's own reason for asynchronously refusing
12064    /// `command_id` -- see `rejected_command_reasons`'s doc for why a
12065    /// spawn-dispatch waiter otherwise has no way to learn it. Bounded by
12066    /// `REJECTED_COMMAND_REASON_MAX_ENTRIES`: expired entries are swept
12067    /// first, and if the map is still at capacity after that, the single
12068    /// oldest surviving entry is evicted -- `CommandId` is monotonically
12069    /// assigned (`next_command_id.fetch_add`), so the `BTreeMap`'s first key
12070    /// is always the oldest.
12071    fn record_rejected_command(
12072        &self,
12073        command_id: CommandId,
12074        message: String,
12075        unsupported_transport: bool,
12076    ) {
12077        let now = Instant::now();
12078        let mut reasons = self.rejected_command_reasons.lock()
12079            .unwrap_or_else(|poisoned| poisoned.into_inner());
12080        reasons.retain(|_, reason| reason.expires_at > now);
12081        if reasons.len() >= REJECTED_COMMAND_REASON_MAX_ENTRIES {
12082            if let Some(&oldest) = reasons.keys().next() {
12083                reasons.remove(&oldest);
12084            }
12085        }
12086        reasons.insert(
12087            command_id,
12088            RejectedCommandReason {
12089                message,
12090                unsupported_transport,
12091                expires_at: now + Duration::from_millis(REJECTED_COMMAND_REASON_TTL_MS),
12092            },
12093        );
12094    }
12095
12096    /// Removes and returns the kernel's reason for refusing `command_id`,
12097    /// if the drive loop has recorded one and it has not expired -- see
12098    /// `rejected_command_reasons`'s doc. Consumed at most once: a spawn
12099    /// wait loop calls this instead of blindly polling out its whole
12100    /// deadline when the command it is waiting on turns out to have been
12101    /// refused rather than merely slow.
12102    fn take_rejected_command_reason(&self, command_id: CommandId) -> Option<RejectedCommandReason> {
12103        let now = Instant::now();
12104        let mut reasons = self.rejected_command_reasons.lock()
12105            .unwrap_or_else(|poisoned| poisoned.into_inner());
12106        match reasons.remove(&command_id) {
12107            Some(reason) if reason.expires_at > now => Some(reason),
12108            _ => None,
12109        }
12110    }
12111
12112    async fn dispatch_bounded(
12113        &self,
12114        command: ControlCommand,
12115        timeout_duration: Duration,
12116    ) -> Result<CommandId, NodeFailure> {
12117        let deadline = Instant::now() + timeout_duration;
12118        loop {
12119            match self.dispatch(command.clone()) {
12120                Ok(command_id) => return Ok(command_id),
12121                Err(error) if error.code == NodeFailureCode::BackendBusy && Instant::now() < deadline => {
12122                    sleep(Duration::from_millis(2)).await;
12123                }
12124                Err(error) => return Err(error),
12125            }
12126        }
12127    }
12128
12129    fn internal_session_snapshot(
12130        &self,
12131        address: &SessionAddress,
12132    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
12133        self.validate_address(address)?;
12134        self.handle
12135            .snapshot()
12136            .sessions
12137            .clone()
12138            .into_iter()
12139            .find(|session| {
12140                session.instance_id == address.session.instance_id
12141                    && session.generation == address.session.generation
12142            })
12143            .ok_or_else(|| {
12144                failure(
12145                    NodeFailureCode::UnknownSession,
12146                    "session snapshot is unavailable",
12147                )
12148            })
12149    }
12150
12151    /// Confirms `address` names a live session spawned over the ACP
12152    /// transport and returns its current snapshot -- the gate every
12153    /// `acp-control-v1` verb shares. `require_session_runtime_policy`
12154    /// cannot answer this by itself: `ProviderRuntimePolicy` carries no
12155    /// transport bit for `Acp`/`Inline` (see that method's own doc), only
12156    /// the session's own recorded `TransportKind` does, and that lives on
12157    /// the live snapshot, not the binding.
12158    fn require_acp_session(
12159        &self,
12160        address: &SessionAddress,
12161    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
12162        let snapshot = self.internal_session_snapshot(address)?;
12163        if snapshot.transport != TransportKind::Acp {
12164            return Err(failure(
12165                NodeFailureCode::UnsupportedTransport,
12166                "this verb requires a session spawned over the ACP transport",
12167            ));
12168        }
12169        Ok(snapshot)
12170    }
12171
12172    async fn dispatch_history_bounded(
12173        &self,
12174        address: &SessionAddress,
12175        command: ControlCommand,
12176        operation: HistoryOperation,
12177    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
12178        self.validate_address(address)?;
12179        let started_after = self.current_sequence();
12180        let command_id = self
12181            .dispatch_bounded(
12182                command,
12183                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12184            )
12185            .await?;
12186        let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
12187        let mut scan_after = started_after;
12188        let mut accepted_after = None;
12189        loop {
12190            let events = self
12191                .history
12192                .lock()
12193                .unwrap_or_else(|poisoned| poisoned.into_inner())
12194                .events
12195                .iter()
12196                .filter(|envelope| envelope.sequence > scan_after)
12197                .cloned()
12198                .collect::<Vec<_>>();
12199            for envelope in events {
12200                scan_after = scan_after.max(envelope.sequence);
12201                let NodeEvent::Control {
12202                    address: event_address,
12203                    event,
12204                } = envelope.event
12205                else {
12206                    continue;
12207                };
12208                if event_address != *address {
12209                    continue;
12210                }
12211                if event.command_id == Some(command_id) {
12212                    match &event.event {
12213                        ControlEventKind::CommandRejected { .. } => {
12214                            return Err(failure(
12215                                NodeFailureCode::InvalidRequest,
12216                                "history request was rejected",
12217                            ));
12218                        }
12219                        ControlEventKind::HistoryRequested {
12220                            operation: requested,
12221                            ..
12222                        } if requested == &operation => {
12223                            accepted_after = Some(envelope.sequence);
12224                            continue;
12225                        }
12226                        _ => {}
12227                    }
12228                }
12229                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12230                    match (&operation, &event.event) {
12231                        (
12232                            HistoryOperation::Discover { .. },
12233                            ControlEventKind::HistoryDiscovered { .. },
12234                        )
12235                        | (
12236                            HistoryOperation::Load { .. },
12237                            ControlEventKind::HistoryLoaded { .. },
12238                        ) => return self.internal_session_snapshot(address),
12239                        (_, ControlEventKind::HistoryFailed { .. }) => {
12240                            return Err(failure(
12241                                NodeFailureCode::BackendOperationFailed,
12242                                "history operation failed",
12243                            ));
12244                        }
12245                        _ => {}
12246                    }
12247                }
12248            }
12249            if Instant::now() >= deadline {
12250                return Err(failure(
12251                    NodeFailureCode::BackendBusy,
12252                    if accepted_after.is_some() {
12253                        "history operation did not settle before the bounded deadline"
12254                    } else {
12255                        "history operation was not accepted before the bounded deadline"
12256                    },
12257                ));
12258            }
12259            sleep(Duration::from_millis(2)).await;
12260        }
12261    }
12262
12263    async fn discover_history(
12264        &self,
12265        address: &SessionAddress,
12266        limit: u16,
12267    ) -> Result<Vec<HistoryCandidateSummary>, NodeFailure> {
12268        let query = HistoryQuery {
12269            working_directory: Some(self.workspace_root(&address.workspace_id)?),
12270            limit,
12271        };
12272        query
12273            .validate()
12274            .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history query"))?;
12275        let snapshot = self
12276            .dispatch_history_bounded(
12277                address,
12278                ControlCommand::DiscoverHistory {
12279                    instance_id: address.session.instance_id,
12280                    query: query.clone(),
12281                },
12282                HistoryOperation::Discover { query },
12283            )
12284            .await?;
12285        if snapshot.history.pending.is_some() || snapshot.history.loaded.is_some() {
12286            return Err(failure(
12287                NodeFailureCode::BackendOperationFailed,
12288                "history discovery settled into an invalid state",
12289            ));
12290        }
12291        Ok(snapshot.history.candidates)
12292    }
12293
12294    async fn load_history(
12295        &self,
12296        address: &SessionAddress,
12297        candidate_id: String,
12298    ) -> Result<HistorySessionRecord, NodeFailure> {
12299        validate_candidate_id(&candidate_id)
12300            .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history candidate"))?;
12301        let snapshot = self
12302            .dispatch_history_bounded(
12303                address,
12304                ControlCommand::LoadHistory {
12305                    instance_id: address.session.instance_id,
12306                    candidate_id: candidate_id.clone(),
12307                },
12308                HistoryOperation::Load {
12309                    candidate_id: candidate_id.clone(),
12310                },
12311            )
12312            .await?;
12313        if snapshot.history.pending.is_some()
12314            || snapshot.history.loaded_candidate_id.as_ref() != Some(&candidate_id)
12315        {
12316            return Err(failure(
12317                NodeFailureCode::BackendOperationFailed,
12318                "history load settled into an invalid state",
12319            ));
12320        }
12321        snapshot.history.loaded.ok_or_else(|| {
12322            failure(
12323                NodeFailureCode::BackendOperationFailed,
12324                "history load produced no bounded session",
12325            )
12326        })
12327    }
12328
12329    /// `allow_clean_detachment`: `false` for the on-demand
12330    /// `NodeRequest::ExportContextPackForSessionRecord` API path (the
12331    /// managed record must stay untouched, still live, for the whole call);
12332    /// `true` only for the reactive auto-export-at-exit path (`§2.1`), which
12333    /// reacts to the very clean-exit event that detaches the record out from
12334    /// under this same call — see `session_record_export_target_matches`.
12335    async fn export_context_pack_for_session_record(
12336        &self,
12337        record_id: &SessionRecordId,
12338        session: &SessionAddress,
12339        allow_clean_detachment: bool,
12340    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12341        // C2 replaces a relayed failure's prose with a canned per-code
12342        // string (`C2NodeFailure`'s own `From`), so this path's dozen
12343        // distinct refusals are indistinguishable to anyone downstream --
12344        // and a refusal here expires a continuation, which keeps no field
12345        // for why. The prose stays on the node, where it was written.
12346        let outcome = self
12347            .export_context_pack_for_session_record_inner(record_id, session, allow_clean_detachment)
12348            .await;
12349        if let Err(failure) = &outcome {
12350            tracing::warn!(
12351                record = %record_id,
12352                code = ?failure.code,
12353                reason = %failure.message,
12354                "session-record context-pack export refused",
12355            );
12356        }
12357        outcome
12358    }
12359
12360    async fn export_context_pack_for_session_record_inner(
12361        &self,
12362        record_id: &SessionRecordId,
12363        session: &SessionAddress,
12364        allow_clean_detachment: bool,
12365    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12366        // Several steps here refuse with the same BackendOperationFailed
12367        // code and no prose of their own, so which step failed is
12368        // otherwise unrecoverable -- and a refusal here expires a
12369        // continuation.
12370        let step = |name: &'static str| move |failure: NodeFailure| {
12371            tracing::warn!(step = name, code = ?failure.code, "context-pack export step refused");
12372            failure
12373        };
12374        let record = self.record(record_id).map_err(step("record-lookup"))?;
12375        // `provider_session` is `Some` only for a provider whose resolved
12376        // runtime policy admits live identity verification (raw PTY plus a
12377        // semantic PTY adapter -- `provider_runtime::policy_from_capability_flags`).
12378        // The PTY-sidecar-observation shape (no fleet provider today, but
12379        // capability-keyed rather than vendor-locked) hardcodes
12380        // `provider_session_identity: false` in `provider_runtime::evaluate`,
12381        // so its records go straight to `Live` with `provider_session: None`
12382        // and stay that way for the record's entire life -- that is this
12383        // shape's permanent resting state, not a race with observation, and
12384        // `discover_history`/`load_history` below do not need an identity to
12385        // do their job for it. `IdentityPending` is the actual race (an
12386        // identity-admitted provider whose observation has not landed yet):
12387        // it keeps refusing here rather than falling back, because that
12388        // provider's own history the fallback would otherwise trust is not
12389        // guaranteed to be exact without the identity it is still waiting on.
12390        let identity = match record.provider_session.clone() {
12391            Some(identity) => {
12392                if identity.key != ProviderSessionKey::SessionId {
12393                    return Err(step("provider-identity-key")(failure(
12394                        NodeFailureCode::BackendOperationFailed,
12395                        "managed session provider identity cannot bind history exactly",
12396                    )));
12397                }
12398                Some(identity)
12399            }
12400            None if record.state == ManagedSessionState::IdentityPending => {
12401                return Err(step("provider-session-identity")(failure(
12402                    NodeFailureCode::BackendOperationFailed,
12403                    "managed session has not yet observed its provider session identity",
12404                )));
12405            }
12406            None => None,
12407        };
12408        self.revalidate_session_record_context_export(
12409            &record,
12410            identity.as_ref(),
12411            session,
12412            allow_clean_detachment,
12413        ).map_err(step("revalidate-before-discovery"))?;
12414        let candidates = self
12415            .discover_history(session, HISTORY_DISCOVERY_LIMIT_MAX)
12416            .await
12417            .map_err(step("discover-history"))?;
12418        // With no verified identity to filter by, the discovered set must
12419        // still resolve to exactly one candidate: the same ambiguity refusal
12420        // an identity filter would otherwise produce is the only safety
12421        // property left available for this shape.
12422        let candidate_id = select_history_candidate(
12423            &candidates,
12424            identity.as_ref().map(|identity| identity.id.as_str()),
12425        ).map_err(step("unique-candidate"))?;
12426        let loaded = self.load_history(session, candidate_id.clone()).await
12427            .map_err(step("load-history"))?;
12428        if let Some(identity) = identity.as_ref() {
12429            if loaded.session_id != identity.id {
12430                return Err(failure(
12431                    NodeFailureCode::SessionRecordConflict,
12432                    "loaded history identity does not match the managed session record",
12433                ));
12434            }
12435        }
12436        self.revalidate_session_record_context_export(
12437            &record,
12438            identity.as_ref(),
12439            session,
12440            allow_clean_detachment,
12441        )?;
12442        self.revalidate_loaded_history(session, &candidate_id, &loaded)
12443            .map_err(step("revalidate-loaded-history"))?;
12444        let pack = self.materialize_context_pack(session).await
12445            .map_err(step("materialize-context-pack"))?;
12446        self.commit_context_pack_for_session_record(
12447            &record,
12448            identity.as_ref(),
12449            session,
12450            &candidate_id,
12451            &loaded,
12452            pack,
12453            allow_clean_detachment,
12454        )
12455    }
12456
12457    async fn export_context_pack(
12458        &self,
12459        address: &SessionAddress,
12460    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12461        let pack = self.materialize_context_pack(address).await?;
12462        self.commit_context_pack(pack)
12463    }
12464
12465    async fn materialize_context_pack(
12466        &self,
12467        address: &SessionAddress,
12468    ) -> Result<NodeContextPack, NodeFailure> {
12469        let snapshot = self.internal_session_snapshot(address)?;
12470        if !context_pack_source_status_is_usable(&snapshot.status) {
12471            return Err(failure(
12472                NodeFailureCode::InvalidRequest,
12473                "context export requires a running or successfully exited source session",
12474            ));
12475        }
12476        if snapshot.history.pending.is_some()
12477            || snapshot.history.loaded_candidate_id.is_none()
12478        {
12479            return Err(failure(
12480                NodeFailureCode::InvalidRequest,
12481                "context export requires settled loaded history",
12482            ));
12483        }
12484        let loaded = snapshot.history.loaded.clone().ok_or_else(|| {
12485            failure(
12486                NodeFailureCode::InvalidRequest,
12487                "context export requires settled loaded history",
12488            )
12489        })?;
12490        let source_provider = snapshot.agent_id.clone();
12491        let loaded_candidate_id = snapshot.history.loaded_candidate_id.clone();
12492        let repository = self
12493            .context_pack_repository(&address.workspace_id)
12494            .await?;
12495        let current = self.internal_session_snapshot(address)?;
12496        if !context_pack_source_status_is_usable(&current.status)
12497            || current.agent_id != source_provider
12498            || current.history.pending.is_some()
12499            || current.history.loaded_candidate_id != loaded_candidate_id
12500            || current.history.loaded.as_ref() != Some(&loaded)
12501        {
12502            return Err(failure(
12503                NodeFailureCode::ContextPackMaterializationFailed,
12504                "context source changed during bounded repository capture",
12505            ));
12506        }
12507        NodeContextPack::export_with_repository(
12508            ContextPackLineageReceipt {
12509                source_node_id: self.node_id.clone(),
12510                source_session: address.clone(),
12511                source_provider,
12512            },
12513            &loaded,
12514            Some(repository),
12515        )
12516        .map_err(|_| {
12517            failure(
12518                NodeFailureCode::ContextPackMaterializationFailed,
12519                "bounded context pack export failed",
12520            )
12521        })
12522    }
12523
12524    /// Write-through to the durable [`ContextPackStore`], if one is
12525    /// configured (`state_path` is `Some`). A node with no durable state
12526    /// path — fixtures, tests, ephemeral runs — has no `ContextPackStore`
12527    /// open at all and this is a no-op, matching `persist_state`'s own
12528    /// "durability optional, degrade gracefully when unconfigured" idiom.
12529    fn commit_context_pack_durably(&self, pack: &NodeContextPack) -> Result<(), NodeFailure> {
12530        let mut store = self
12531            .context_pack_store
12532            .lock()
12533            .unwrap_or_else(|poisoned| poisoned.into_inner());
12534        let Some(store) = store.as_mut() else {
12535            return Ok(());
12536        };
12537        store.commit(pack).map_err(|_| {
12538            failure(
12539                NodeFailureCode::ContextPackMaterializationFailed,
12540                "durable context pack commit failed",
12541            )
12542        })
12543    }
12544
12545    fn commit_context_pack(
12546        &self,
12547        pack: NodeContextPack,
12548    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12549        self.commit_context_pack_durably(&pack)?;
12550        self.context_catalog
12551            .write()
12552            .unwrap_or_else(|poisoned| poisoned.into_inner())
12553            .insert(pack)
12554            .map_err(|_| {
12555                failure(
12556                    NodeFailureCode::ContextPackMaterializationFailed,
12557                    "context pack catalog is full",
12558                )
12559            })
12560    }
12561
12562    fn commit_context_pack_for_session_record(
12563        &self,
12564        expected: &ManagedSessionRecord,
12565        identity: Option<&ProviderSessionIdentity>,
12566        session: &SessionAddress,
12567        candidate_id: &str,
12568        loaded: &HistorySessionRecord,
12569        pack: NodeContextPack,
12570        allow_clean_detachment: bool,
12571    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12572        let current_root = self.workspace_root(&expected.workspace_id)?;
12573        let _transaction = self
12574            .state_transaction
12575            .lock()
12576            .unwrap_or_else(|poisoned| poisoned.into_inner());
12577        let records = self
12578            .session_records
12579            .lock()
12580            .unwrap_or_else(|poisoned| poisoned.into_inner());
12581        let current = records.records.get(&expected.record_id).ok_or_else(|| {
12582            failure(
12583                NodeFailureCode::SessionRecordConflict,
12584                "managed session record disappeared during context export",
12585            )
12586        })?;
12587        if !session_record_export_target_matches(expected, current, session, allow_clean_detachment) {
12588            return Err(failure(
12589                NodeFailureCode::SessionRecordConflict,
12590                "managed session record changed during context export",
12591            ));
12592        }
12593        let bindings = self
12594            .session_bindings
12595            .lock()
12596            .unwrap_or_else(|poisoned| poisoned.into_inner());
12597        let binding = bindings.get(&session.session.instance_id).ok_or_else(|| {
12598            failure(
12599                NodeFailureCode::SessionRecordConflict,
12600                "managed session runtime binding disappeared during context export",
12601            )
12602        })?;
12603        let runtime = self
12604            .handle
12605            .snapshot()
12606            .sessions
12607            .iter()
12608            .find(|snapshot| {
12609                snapshot.instance_id == session.session.instance_id
12610                    && snapshot.generation == session.session.generation
12611            })
12612            .cloned()
12613            .ok_or_else(|| {
12614                failure(
12615                    NodeFailureCode::SessionRecordConflict,
12616                    "managed session runtime disappeared during context export",
12617                )
12618            })?;
12619        let receipt = pack.receipt();
12620        if !session_record_context_export_binding_is_exact(
12621            expected,
12622            current,
12623            identity,
12624            session,
12625            binding,
12626            &runtime.agent_id,
12627            &current_root,
12628            allow_clean_detachment,
12629        ) || !session_record_context_export_source_is_usable(
12630            current,
12631            &runtime.status,
12632            allow_clean_detachment,
12633        ) || runtime.history.pending.is_some()
12634            || runtime.history.loaded_candidate_id.as_deref() != Some(candidate_id)
12635            || runtime.history.loaded.as_ref() != Some(loaded)
12636            || receipt.lineage.source_node_id != self.node_id
12637            || receipt.lineage.source_session != *session
12638            || receipt.lineage.source_provider != current.provider
12639            || receipt.source_message_count != loaded.message_count
12640        {
12641            return Err(failure(
12642                NodeFailureCode::SessionRecordConflict,
12643                "managed session source changed before context catalog commit",
12644            ));
12645        }
12646        self.commit_context_pack_durably(&pack)?;
12647        self.context_catalog
12648            .write()
12649            .unwrap_or_else(|poisoned| poisoned.into_inner())
12650            .insert(pack)
12651            .map_err(|_| {
12652                failure(
12653                    NodeFailureCode::ContextPackMaterializationFailed,
12654                    "context pack catalog is full",
12655                )
12656            })
12657    }
12658
12659    async fn context_pack_repository(
12660        &self,
12661        workspace_id: &WorkspaceId,
12662    ) -> Result<ContextPackRepository, NodeFailure> {
12663        let first = self
12664            .context_pack_repository_observation(workspace_id)
12665            .await?;
12666        let second = self
12667            .context_pack_repository_observation(workspace_id)
12668            .await?;
12669        stable_context_pack_repository(first, second)
12670    }
12671
12672    async fn context_pack_repository_observation(
12673        &self,
12674        workspace_id: &WorkspaceId,
12675    ) -> Result<ContextPackRepositoryObservation, NodeFailure> {
12676        let git = self.inspect_workspace(workspace_id.clone()).await?.git;
12677        let head = if git.is_repository {
12678            let root = self.workspace_root(workspace_id)?;
12679            match resolve_base_commit_with_timeout(
12680                &root,
12681                "HEAD",
12682                GIT_COMMAND_TIMEOUT_MS,
12683            )
12684            .await
12685            {
12686                Ok(commit) => ContextPackRepositoryHead::Commit(commit),
12687                Err(error) if error.kind == GitWorktreeErrorKind::Conflict => {
12688                    ContextPackRepositoryHead::Unborn
12689                }
12690                Err(_) => {
12691                    return Err(failure(
12692                        NodeFailureCode::ContextPackMaterializationFailed,
12693                        "context pack HEAD observation failed",
12694                    ));
12695                }
12696            }
12697        } else {
12698            ContextPackRepositoryHead::NotRepository
12699        };
12700        let mut files = Vec::with_capacity(CONTEXT_PACK_SELECTED_FILES.len());
12701        for selected_path in CONTEXT_PACK_SELECTED_FILES {
12702            let path = RepositoryPath::utf8(selected_path.to_owned()).map_err(|_| {
12703                failure(
12704                    NodeFailureCode::InvalidRepositoryPath,
12705                    "context pack selected file path is invalid",
12706                )
12707            })?;
12708            match self.read_workspace_file(workspace_id.clone(), path).await {
12709                Ok(WorkspaceFileRead {
12710                    content: WorkspaceFileContent::Utf8 { text, byte_len },
12711                    ..
12712                }) => files.push(ContextPackRepositoryFileSource::utf8(
12713                    selected_path,
12714                    text,
12715                    byte_len,
12716                )),
12717                Ok(WorkspaceFileRead {
12718                    content: WorkspaceFileContent::NonUtf8 { byte_len },
12719                    ..
12720                }) => files.push(ContextPackRepositoryFileSource::skipped(
12721                    selected_path,
12722                    ContextPackSelectedFileSkipReason::NonUtf8,
12723                    Some(byte_len),
12724                )),
12725                Ok(WorkspaceFileRead {
12726                    content: WorkspaceFileContent::TooLarge { .. },
12727                    ..
12728                }) => files.push(ContextPackRepositoryFileSource::skipped(
12729                    selected_path,
12730                    ContextPackSelectedFileSkipReason::TooLarge,
12731                    None,
12732                )),
12733                Err(error) => {
12734                    let reason = if matches!(
12735                        error.code,
12736                        NodeFailureCode::InvalidRepositoryPath
12737                            | NodeFailureCode::RepositoryFileNotRegular
12738                            | NodeFailureCode::RepositoryPathUnsafe
12739                    ) {
12740                        ContextPackSelectedFileSkipReason::Unsafe
12741                    } else {
12742                        ContextPackSelectedFileSkipReason::Unavailable
12743                    };
12744                    files.push(ContextPackRepositoryFileSource::skipped(
12745                        selected_path,
12746                        reason,
12747                        None,
12748                    ));
12749                }
12750            }
12751        }
12752        Ok(ContextPackRepositoryObservation { head, git, files })
12753    }
12754
12755    fn forget_context_pack(&self, context_id: &SpawnContextId) -> Result<(), NodeFailure> {
12756        let referenced_by_binding = self
12757            .session_bindings
12758            .lock()
12759            .unwrap_or_else(|poisoned| poisoned.into_inner())
12760            .values()
12761            .any(|binding| {
12762                binding.context.as_ref().map(|receipt| &receipt.id) == Some(context_id)
12763            });
12764        let referenced_by_record = self
12765            .session_records
12766            .lock()
12767            .unwrap_or_else(|poisoned| poisoned.into_inner())
12768            .records
12769            .values()
12770            .any(|record| record.context_id.as_ref() == Some(context_id));
12771        let referenced_by_materialization = self
12772            .materializations
12773            .lock()
12774            .unwrap_or_else(|poisoned| poisoned.into_inner())
12775            .values()
12776            .any(|ownership| {
12777                ownership.context().map(|receipt| &receipt.id) == Some(context_id)
12778            });
12779        if referenced_by_binding
12780            || referenced_by_record
12781            || referenced_by_materialization
12782        {
12783            return Err(failure(
12784                NodeFailureCode::ContextPackBusy,
12785                "context pack is still referenced",
12786            ));
12787        }
12788        let mut catalog = self.context_catalog
12789            .write()
12790            .unwrap_or_else(|poisoned| poisoned.into_inner());
12791        if catalog.get(context_id).is_none() {
12792            return Err(failure(
12793                NodeFailureCode::UnknownContextPack,
12794                "context pack is unavailable",
12795            ));
12796        }
12797        self.spawn_idempotency
12798            .lock()
12799            .unwrap_or_else(|poisoned| poisoned.into_inner())
12800            .entries
12801            .retain(|_, entry| !entry.value.references_context(context_id));
12802        let removed = catalog.remove(context_id);
12803        debug_assert!(removed.is_some(), "validated context pack remains present");
12804        Ok(())
12805    }
12806
12807    async fn dispatch_input_bounded(
12808        &self,
12809        address: &SessionAddress,
12810        action: InputAction,
12811    ) -> Result<(), NodeFailure> {
12812        let started_after = self.current_sequence();
12813        self.pty_input_marks
12814            .lock()
12815            .unwrap_or_else(|poisoned| poisoned.into_inner())
12816            .insert(address.session.instance_id, (Instant::now(), false));
12817        self.pty_input_wake.notify_one();
12818        let command_id = self
12819            .dispatch_bounded(
12820                ControlCommand::SendInput {
12821                    instance_id: address.session.instance_id,
12822                    action,
12823                },
12824                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12825            )
12826            .await?;
12827        // A semantic prompt may spend the provider's full readiness budget before
12828        // the runtime emits InputCompleted. Keep the node request deadline bounded,
12829        // but strictly beyond every readiness policy in this node's catalog.
12830        let deadline = Instant::now() + Duration::from_millis(self.input_settle_timeout_ms);
12831        let mut scan_after = started_after;
12832        let mut accepted_after = None;
12833        loop {
12834            let events = self
12835                .history
12836                .lock()
12837                .unwrap_or_else(|poisoned| poisoned.into_inner())
12838                .events
12839                .iter()
12840                .filter(|envelope| envelope.sequence > scan_after)
12841                .cloned()
12842                .collect::<Vec<_>>();
12843            for envelope in events {
12844                scan_after = scan_after.max(envelope.sequence);
12845                let NodeEvent::Control {
12846                    address: event_address,
12847                    event,
12848                } = envelope.event
12849                else {
12850                    continue;
12851                };
12852                if event_address != *address {
12853                    continue;
12854                }
12855                if event.command_id == Some(command_id) {
12856                    match &event.event {
12857                        ControlEventKind::CommandRejected { message } => {
12858                            return Err(failure(NodeFailureCode::InvalidRequest, message));
12859                        }
12860                        ControlEventKind::InputRequested { .. } => {
12861                            accepted_after = Some(envelope.sequence);
12862                            continue;
12863                        }
12864                        _ => {}
12865                    }
12866                }
12867                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12868                    match &event.event {
12869                        ControlEventKind::InputCompleted { .. } => return Ok(()),
12870                        ControlEventKind::InputFailed { message, .. } => {
12871                            return Err(failure(
12872                                NodeFailureCode::BackendOperationFailed,
12873                                message,
12874                            ));
12875                        }
12876                        _ => {}
12877                    }
12878                }
12879            }
12880            if Instant::now() >= deadline {
12881                return Err(failure(
12882                    NodeFailureCode::BackendBusy,
12883                    if accepted_after.is_some() {
12884                        "input was accepted but did not settle before the bounded deadline"
12885                    } else {
12886                        "input was not accepted before the bounded deadline"
12887                    },
12888                ));
12889            }
12890            sleep(Duration::from_millis(2)).await;
12891        }
12892    }
12893
12894    async fn dispatch_resize_bounded(
12895        &self,
12896        address: &SessionAddress,
12897        size: gate4agent_types::TerminalSize,
12898    ) -> Result<(), NodeFailure> {
12899        let started_after = self.current_sequence();
12900        let command_id = self
12901            .dispatch_bounded(
12902                ControlCommand::Resize {
12903                    instance_id: address.session.instance_id,
12904                    size,
12905                },
12906                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12907            )
12908            .await?;
12909        let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
12910        let mut scan_after = started_after;
12911        let mut accepted_after = None;
12912        loop {
12913            let events = self
12914                .history
12915                .lock()
12916                .unwrap_or_else(|poisoned| poisoned.into_inner())
12917                .events
12918                .iter()
12919                .filter(|envelope| envelope.sequence > scan_after)
12920                .cloned()
12921                .collect::<Vec<_>>();
12922            for envelope in events {
12923                scan_after = scan_after.max(envelope.sequence);
12924                let NodeEvent::Control {
12925                    address: event_address,
12926                    event,
12927                } = envelope.event
12928                else {
12929                    continue;
12930                };
12931                if event_address != *address {
12932                    continue;
12933                }
12934                if event.command_id == Some(command_id) {
12935                    match &event.event {
12936                        ControlEventKind::CommandRejected { message } => {
12937                            return Err(failure(NodeFailureCode::InvalidRequest, message));
12938                        }
12939                        ControlEventKind::ResizeRequested {
12940                            size: requested_size,
12941                            ..
12942                        } if *requested_size == size => {
12943                            accepted_after = Some(envelope.sequence);
12944                            continue;
12945                        }
12946                        _ => {}
12947                    }
12948                }
12949                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12950                    match &event.event {
12951                        ControlEventKind::Resized { size: settled_size }
12952                            if *settled_size == size =>
12953                        {
12954                            return Ok(());
12955                        }
12956                        ControlEventKind::ResizeFailed { message } => {
12957                            return Err(failure(
12958                                NodeFailureCode::BackendOperationFailed,
12959                                message,
12960                            ));
12961                        }
12962                        _ => {}
12963                    }
12964                }
12965            }
12966            if Instant::now() >= deadline {
12967                return Err(failure(
12968                    NodeFailureCode::BackendBusy,
12969                    if accepted_after.is_some() {
12970                        "resize was accepted but did not settle before the bounded deadline"
12971                    } else {
12972                        "resize was not accepted before the bounded deadline"
12973                    },
12974                ));
12975            }
12976            sleep(Duration::from_millis(2)).await;
12977        }
12978    }
12979
12980    async fn spawn_session(
12981        &self,
12982        workspace_id: WorkspaceId,
12983        provider: AgentId,
12984        mode: SessionMode,
12985        terminal_size: gate4agent_types::TerminalSize,
12986        initial_prompt: Option<String>,
12987    ) -> Result<SessionAddress, NodeFailure> {
12988        if self.managed_worktrees.lock()
12989            .unwrap_or_else(|poisoned| poisoned.into_inner())
12990            .lease_for_workspace(&workspace_id)
12991            .is_some()
12992        {
12993            return Err(failure(
12994                NodeFailureCode::ManagedWorktreeOwnershipConflict,
12995                "managed worktree targets may only be spawned through their lease request",
12996            ));
12997        }
12998        self
12999            .spawn_session_with_deadline(
13000                workspace_id,
13001                provider.clone(),
13002                mode,
13003                terminal_size,
13004                initial_prompt,
13005                None,
13006                None,
13007                None,
13008                None,
13009                None,
13010                SpawnRecordPolicy::ProviderIdentityOnly,
13011                None,
13012                &[],
13013                None,
13014                // `NodeRequest::Spawn` (this method's only caller) carries no
13015                // approval-level selection of its own -- see
13016                // `HarnessOperatorRequestV1::SpawnSession`'s own field for
13017                // the one operator-wire path that can choose something other
13018                // than the axis default. Auto-permit may lower it where the agent
13019                // cannot offer its full-auto mode (claude ACP under root).
13020                crate::auto_permit::spawn_approval_level(provider.as_str(), mode).unwrap_or_default(),
13021                None,
13022                None,
13023            )
13024            .await
13025            .map(|(session, _runtime_policy)| session)
13026    }
13027
13028    async fn spawn_session_with_deadline(
13029        &self,
13030        workspace_id: WorkspaceId,
13031        provider: AgentId,
13032        mode: SessionMode,
13033        terminal_size: gate4agent_types::TerminalSize,
13034        initial_prompt: Option<String>,
13035        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
13036        bundle: Option<ResolvedBundleReceipt>,
13037        context: Option<ResolvedContextPackReceipt>,
13038        managed_authority: Option<ManagedWorktreeLeaseId>,
13039        admitted_runtime_policy: Option<ProviderRuntimePolicy>,
13040        record_policy: SpawnRecordPolicy,
13041        deadline: Option<Instant>,
13042        required_capabilities: &[ProviderRuntimeCapability],
13043        harness_mcp: Option<&PreparedHarnessMcpSpawn>,
13044        approval_level: ApprovalLevel,
13045        network_allowlist: Option<crate::protocol::SpawnNetworkAllowlistId>,
13046        // Opaque dig2browser station profile id (ids only). Feature
13047        // `dig2-station-probe`: after probe at resolve, spawn takes an
13048        // exclusive node-local lease; cleared on session end. Never
13049        // ImportSession / cookies on C2. Feature off: ignored (stub echo
13050        // already happened on the receipt path).
13051        browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
13052    ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
13053        let reserved_lease = self.managed_worktrees.lock()
13054            .unwrap_or_else(|poisoned| poisoned.into_inner())
13055            .lease_for_workspace(&workspace_id);
13056        match (reserved_lease.as_ref(), managed_authority.as_ref()) {
13057            (Some(expected), Some(provided)) if expected == provided => {}
13058            (None, None) => {}
13059            _ => {
13060                return Err(failure(
13061                    NodeFailureCode::ManagedWorktreeOwnershipConflict,
13062                    "spawn lacks exact managed worktree lease authority",
13063                ));
13064            }
13065        }
13066        // Refuse concurrent distinct environment profiles (incl. ProviderHome
13067        // bindings) on the same workspace cwd. Parallel path: managed worktree
13068        // spawn so each profile gets its own workspace_id.
13069        self.ensure_workspace_environment_profile_compatible(
13070            &workspace_id,
13071            environment_profile.as_ref(),
13072        )?;
13073        let runtime_requirement = match (mode, initial_prompt.is_some()) {
13074            (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
13075            (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
13076            (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
13077            (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
13078        };
13079        // This session's own transport -- computed once, here, from `mode`,
13080        // and reused both to admit spawn (below) and, further down, to
13081        // compute the final `runtime_policy` this session actually operates
13082        // under (`provider_runtime::policy_for_transport`). Registration
13083        // reuses this exact value rather than recomputing it a second time.
13084        let transport = match mode {
13085            SessionMode::Pty => TransportKind::Pty,
13086            SessionMode::Inline => TransportKind::Pipe,
13087            SessionMode::Acp => TransportKind::Acp,
13088        };
13089        let pipe_transport = self.provider_supports_pipe_transport(&provider);
13090        let acp_transport = self.provider_supports_acp_transport(&provider);
13091        let runtime_policy = if let Some(runtime_policy) = admitted_runtime_policy {
13092            require_policy(runtime_policy, pipe_transport, acp_transport, runtime_requirement).map_err(|_| failure(
13093                NodeFailureCode::UnsupportedSpawnCapability,
13094                "pre-admitted provider runtime no longer satisfies spawn requirements",
13095            ))?;
13096            runtime_policy
13097        } else if let Some(deadline) = deadline {
13098            let remaining = spawn_deadline_remaining(deadline)?;
13099            timeout(
13100                remaining,
13101                self.admit_provider_runtime(&provider, runtime_requirement),
13102            )
13103            .await
13104            .map_err(|_| {
13105                failure(
13106                    NodeFailureCode::SpawnDeadlineExceeded,
13107                    "provider admission exceeded the spawn deadline",
13108                )
13109            })??
13110        } else {
13111            self.admit_provider_runtime(&provider, runtime_requirement).await?
13112        };
13113        let runtime_policy = self.effective_spawn_runtime_policy(runtime_policy);
13114        // The single point where this session's `runtime_policy` becomes
13115        // transport-correct: `admit_provider_runtime` (and the pre-admitted
13116        // branch above) only ever compute a PTY-terminal-verification-shaped
13117        // value, sound outright only for `TransportKind::Pty` (Pipe still
13118        // relies on that same value downstream -- see `policy_for_transport`'s
13119        // own doc for why). `policy_for_transport` is the explicit,
13120        // exhaustive match Item 1 asks for -- this is the ONE place its
13121        // output becomes the session's `runtime_policy`, so every later
13122        // reader (`ControlCommand::Start`, `session_bindings`, and every
13123        // engine gate that reads `state.runtime_policy`) sees the same,
13124        // correctly-computed value.
13125        let runtime_policy =
13126            crate::provider_runtime::policy_for_transport(transport, runtime_policy);
13127        if required_capabilities
13128            .iter()
13129            .any(|capability| !runtime_policy.admits(*capability))
13130        {
13131            return Err(failure(
13132                NodeFailureCode::UnsupportedSpawnCapability,
13133                "provider runtime does not admit a required spawn capability",
13134            ));
13135        }
13136        if let Some(deadline) = deadline {
13137            spawn_deadline_remaining(deadline)?;
13138        }
13139        self.ensure_binding_capacity()?;
13140        let mut working_directory = self.workspace_root(&workspace_id)?;
13141        let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
13142        let session = SessionKey {
13143            instance_id,
13144            generation: SessionGeneration(1),
13145        };
13146        let address = SessionAddress {
13147            workspace_id: workspace_id.clone(),
13148            session,
13149        };
13150        // This is the request-accepted checkpoint: admission (managed
13151        // worktree authority, runtime capability, dispatch deadline) already
13152        // passed and a concrete session identity is minted. It is a distinct
13153        // state from the process actually running (`ControlEventKind::Running`,
13154        // logged in `publish_control`) or the terminal outcome (`Exited`/
13155        // `Failed`, logged there too) -- this line exists so that gap is no
13156        // longer silent.
13157        tracing::info!(
13158            node_id = %self.node_id,
13159            workspace_id = %workspace_id,
13160            provider = %provider,
13161            environment_profile = ?environment_profile.as_ref().map(|receipt| &receipt.profile_id),
13162            session = ?address.session,
13163            "session spawn request accepted",
13164        );
13165        let mut prepared_spawn = SpawnPreparer::prepare(
13166            self,
13167            SpawnPrepareRequest {
13168                instance_id,
13169                address: &address,
13170                provider: &provider,
13171                mode,
13172                environment_profile: environment_profile.as_ref(),
13173                bundle: bundle.as_ref(),
13174                context: context.as_ref(),
13175                managed_authority: managed_authority.as_ref(),
13176                harness_mcp,
13177                approval_level,
13178                network_allowlist: network_allowlist.as_ref(),
13179                browser_profile_id,
13180            },
13181        )?;
13182        let materialization_id = prepared_spawn.materialization_id.take();
13183        let dispatch_timeout = spawn_dispatch_timeout(deadline)?;
13184        let record_id = self.bind_spawn_session_with_materialization(
13185            &address,
13186            provider.clone(),
13187            mode,
13188            runtime_policy,
13189            record_policy,
13190            environment_profile,
13191            bundle,
13192            context,
13193            materialization_id,
13194        )?;
13195        prepared_spawn.retain_session_guards();
13196        // `transport` was already computed above, alongside this session's
13197        // `runtime_policy`; reused here for `Register` rather than derived a
13198        // second time.
13199        let session_provider = provider.clone();
13200        let agent_id = provider;
13201        let register_command_id = match self.dispatch_bounded(
13202            ControlCommand::Register {
13203                instance_id,
13204                agent_id,
13205                transport,
13206            },
13207            dispatch_timeout,
13208        )
13209        .await {
13210            Ok(command_id) => command_id,
13211            Err(error) => {
13212                if let Some(binding) = self.remove_binding(&address) {
13213                    SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
13214                }
13215                if let Some(record_id) = record_id.as_ref() {
13216                    self.discard_record(record_id)?;
13217                }
13218                return Err(spawn_dispatch_error(error, deadline));
13219            }
13220        };
13221        let start_timeout = match spawn_dispatch_timeout(deadline) {
13222            Ok(timeout) => timeout,
13223            Err(error) => {
13224                let recovery = self
13225                    .rollback_spawn(
13226                        &address,
13227                        Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13228                    )
13229                    .await;
13230                return match recovery {
13231                    Ok(()) => {
13232                        if let Some(record_id) = record_id.as_ref() {
13233                            self.discard_record(record_id)?;
13234                        }
13235                        Err(error)
13236                    }
13237                    Err(recovery_error) => {
13238                        if let Some(record_id) = record_id.as_ref() {
13239                            self.mark_record_error(
13240                                record_id,
13241                                "spawn deadline elapsed and registration recovery failed",
13242                            )?;
13243                        }
13244                        Err(recovery_error)
13245                    }
13246                };
13247            }
13248        };
13249        if let Err(start_error) = self.verify_harness_mcp_before_start(harness_mcp) {
13250            if let Err(recovery_error) = self
13251                .rollback_spawn(&address, Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS))
13252                .await
13253            {
13254                return Err(recovery_error);
13255            }
13256            if let Some(record_id) = record_id.as_ref() {
13257                self.discard_record(record_id)?;
13258            }
13259            return Err(start_error);
13260        }
13261        // A session with its own m4a account runs in its own folder inside the workspace.
13262        if let Some(dir) = self.session_workdir(std::path::Path::new(&working_directory), instance_id, &session_provider) {
13263            working_directory = dir.to_string_lossy().into_owned();
13264        }
13265        let start_result = self
13266            .dispatch_bounded(
13267                ControlCommand::Start {
13268                    instance_id,
13269                    runtime_policy,
13270                    request: StartRequest {
13271                        working_directory,
13272                        terminal_size,
13273                        initial_prompt,
13274                        session_options: SPAWN_SESSION_OPTIONS.try_with(|o| o.clone()).ok().flatten(),
13275                        approval_level,
13276                    },
13277                },
13278                start_timeout,
13279            )
13280            .await;
13281        let start_command_id = match start_result {
13282            Ok(command_id) => command_id,
13283            Err(start_error) => {
13284                let start_error = spawn_dispatch_error(start_error, deadline);
13285                let recovery = self
13286                    .rollback_spawn(
13287                        &address,
13288                        Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13289                    )
13290                    .await;
13291                return match recovery {
13292                    Ok(()) => {
13293                        if let Some(record_id) = record_id.as_ref() {
13294                            self.discard_record(record_id)?;
13295                        }
13296                        Err(start_error)
13297                    }
13298                    Err(recovery_error) => {
13299                        let diagnostic = format!(
13300                            "start failed and registration recovery failed: {}",
13301                            recovery_error.message,
13302                        );
13303                        if let Some(record_id) = record_id.as_ref() {
13304                            self.mark_record_error(record_id, &diagnostic)?;
13305                        }
13306                        Err(failure(recovery_error.code, &diagnostic))
13307                    }
13308                };
13309            }
13310        };
13311
13312        let commit_deadline = deadline.unwrap_or_else(|| Instant::now() + dispatch_timeout);
13313        loop {
13314            // `Register`/`Start` only fail synchronously for a port-level
13315            // ingress problem (see `dispatch_bounded`); the kernel refusing
13316            // either command's content -- an unsupported transport, an
13317            // unknown agent -- lands one drive-loop tick later, and no
13318            // session ever appears in `self.handle.snapshot()` for it to
13319            // notice below. Check for that named reason before falling
13320            // through to the blind presence poll, so a rejected spawn
13321            // reports why instead of just timing out.
13322            if let Some(reason) = self.take_rejected_command_reason(register_command_id)
13323                .or_else(|| self.take_rejected_command_reason(start_command_id))
13324            {
13325                self.rollback_spawn(
13326                    &address,
13327                    Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13328                ).await.map_err(|error| {
13329                        failure(
13330                            error.code,
13331                            &format!(
13332                                "spawn was rejected ({}) and registration recovery failed: {}",
13333                                reason.message, error.message
13334                            ),
13335                        )
13336                    })?;
13337                if let Some(record_id) = record_id.as_ref() {
13338                    self.discard_record(record_id)?;
13339                }
13340                let code = if reason.unsupported_transport {
13341                    NodeFailureCode::UnsupportedTransport
13342                } else {
13343                    NodeFailureCode::BackendOperationFailed
13344                };
13345                return Err(failure(code, &reason.message));
13346            }
13347            if let Some(current) = self
13348                .handle
13349                .snapshot()
13350                .sessions
13351                .iter()
13352                .find(|current| current.instance_id == instance_id)
13353            {
13354                if current.generation != session.generation {
13355                    let divergence = failure(
13356                        NodeFailureCode::StaleGeneration,
13357                        "spawn generation diverged",
13358                    );
13359                    return match self
13360                        .rollback_spawn(
13361                            &address,
13362                            Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13363                        )
13364                        .await
13365                    {
13366                        Ok(()) => {
13367                            if let Some(record_id) = record_id.as_ref() {
13368                                self.discard_record(record_id)?;
13369                            }
13370                            Err(divergence)
13371                        }
13372                        Err(recovery_error) => {
13373                            if let Some(record_id) = record_id.as_ref() {
13374                                self.mark_record_error(
13375                                    record_id,
13376                                    "spawn generation diverged and recovery failed",
13377                                )?;
13378                            }
13379                            Err(recovery_error)
13380                        }
13381                    };
13382                }
13383                if !matches!(current.status, gate4agent_types::SessionStatus::Registered) {
13384                    prepared_spawn.retain_launch_guards();
13385                    return Ok((address, runtime_policy));
13386                }
13387            }
13388            if Instant::now() >= commit_deadline {
13389                self.rollback_spawn(
13390                    &address,
13391                    Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13392                ).await.map_err(|error| {
13393                        failure(
13394                            error.code,
13395                            &format!(
13396                                "start did not commit and registration recovery failed: {}",
13397                                error.message
13398                            ),
13399                        )
13400                    })?;
13401                if let Some(record_id) = record_id.as_ref() {
13402                    self.discard_record(record_id)?;
13403                }
13404                return Err(failure(
13405                    if deadline.is_some() {
13406                        NodeFailureCode::SpawnDeadlineExceeded
13407                    } else {
13408                        NodeFailureCode::BackendBusy
13409                    },
13410                    "start did not commit before the bounded deadline; registration was removed",
13411                ));
13412            }
13413            sleep(Duration::from_millis(2)).await;
13414        }
13415    }
13416
13417    fn effective_spawn_runtime_policy(
13418        &self,
13419        runtime_policy: ProviderRuntimePolicy,
13420    ) -> ProviderRuntimePolicy {
13421        #[cfg(feature = "fixture")]
13422        let runtime_policy = if self.fixture_semantic_hook_policy {
13423            ProviderRuntimePolicy::new(true, true, true, true, false, true)
13424                .expect("monitoring Hook fixture policy is internally valid")
13425        } else {
13426            runtime_policy
13427        };
13428        runtime_policy
13429    }
13430
13431    fn verify_harness_mcp_before_start(
13432        &self,
13433        prepared: Option<&PreparedHarnessMcpSpawn>,
13434    ) -> Result<(), NodeFailure> {
13435        prepared
13436            .map(PreparedHarnessMcpSpawn::verify_helper_program)
13437            .transpose()
13438            .map(|_| ())
13439            .map_err(harness_mcp_failure)
13440    }
13441
13442    async fn rollback_spawn(
13443        &self,
13444        address: &SessionAddress,
13445        commit_timeout: Duration,
13446    ) -> Result<(), NodeFailure> {
13447        self.dispatch_bounded(
13448            ControlCommand::Remove {
13449                instance_id: address.session.instance_id,
13450            },
13451            commit_timeout,
13452        )
13453        .await?;
13454        self.wait_until_removed(address, commit_timeout).await?;
13455        if let Some(binding) = self.remove_binding(address) {
13456            SessionLifecycleHook::cleanup_session_materialization(self, address, &binding)?;
13457        }
13458        Ok(())
13459    }
13460
13461    async fn remove_session(&self, address: &SessionAddress) -> Result<(), NodeFailure> {
13462        let commit_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
13463        self.dispatch_bounded(
13464            ControlCommand::Remove {
13465                instance_id: address.session.instance_id,
13466            },
13467            commit_timeout,
13468        )
13469            .await?;
13470        self.wait_until_removed(address, commit_timeout).await?;
13471        let removed = self.remove_binding(address);
13472        if let Some(binding) = removed.as_ref() {
13473            SessionLifecycleHook::cleanup_session_materialization(self, address, binding)?;
13474        }
13475        if let Some(lease_id) = removed.and_then(|binding| binding.managed_worktree_lease_id) {
13476            let snapshot = self.managed_worktrees.lock()
13477                .unwrap_or_else(|poisoned| poisoned.into_inner())
13478                .release_session(
13479                    &lease_id,
13480                    self.incarnation_id,
13481                    address.session.instance_id,
13482                    address.session.generation,
13483                    unix_time_ms(),
13484                );
13485            if self.persist_state().is_err() {
13486                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
13487            }
13488            if let Some(snapshot) = snapshot {
13489                self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
13490            }
13491            let _ = self.cleanup_managed_worktree(&lease_id, false).await;
13492        }
13493        Ok(())
13494    }
13495
13496    /// Polls until `address`'s session leaves the engine's own session map,
13497    /// or the bounded deadline expires.
13498    ///
13499    /// A `Remove` sent while the session is not yet in a terminal status
13500    /// (e.g. still `Stopping`, mid-effect right after a `Stop`) is rejected
13501    /// once by the engine (`SessionStatus::allows_remove`) and
13502    /// then discarded -- nothing re-issues it on its own, so the removal
13503    /// that would satisfy this wait never arrives and the caller was
13504    /// guaranteed to time out even though the session settles microseconds
13505    /// later. So this re-dispatches on every poll tick: a session that
13506    /// settles mid-wait -- the common case right after a `Stop` -- still
13507    /// gets removed inside the same bounded deadline.
13508    ///
13509    /// It re-dispatches only once the status will ACCEPT the command. A
13510    /// rejected re-dispatch is not the harmless no-op this loop first
13511    /// assumed: every rejection publishes a `ControlEventKind::Command
13512    /// Rejected` to every subscriber and writes a WARN line. Ungated, one
13513    /// ordinary teardown produced 241 of them in 1.6 seconds -- the whole
13514    /// `Stopping` window at one known-doomed dispatch per 2ms tick, fanned
13515    /// out to the harness and every attached client. Gated, the same
13516    /// teardown sends exactly one `Remove`, at the first tick the engine
13517    /// can act on it.
13518    ///
13519    /// The status is read from the snapshot this loop already takes, so
13520    /// the gate costs nothing extra. It races -- the status can move
13521    /// between the read and the dispatch -- and that is fine: a rare lost
13522    /// race is one rejection, and the next tick retries.
13523    ///
13524    /// Re-dispatch is separately rate-limited to
13525    /// [`REMOVE_REDISPATCH_INTERVAL_MS`], because the status gate alone
13526    /// leaves a second burst behind it. Once the session settles, every
13527    /// 2ms tick sent another `Remove` while the accepted one was still in
13528    /// flight, and each of those landed on an instance the engine had
13529    /// already dropped -- `agent instance … is not registered`, observed
13530    /// live as a tail of rejections immediately after the `session
13531    /// removed` line. The presence poll stays at 2ms so the wait still
13532    /// exits promptly; only the sending slows down.
13533    ///
13534    /// Rate-limited rather than once-only on purpose: the status gate
13535    /// covers the status precondition, but `Gate4AgentEngine::remove` has
13536    /// others (a pending operation, an in-flight capability probe or
13537    /// history request) that clear on their own. A single shot would turn
13538    /// those into a guaranteed `BackendBusy` timeout; at 10Hz they still
13539    /// get their retries, just twenty of them across the deadline instead
13540    /// of a thousand.
13541    async fn wait_until_removed(
13542        &self,
13543        address: &SessionAddress,
13544        commit_timeout: Duration,
13545    ) -> Result<(), NodeFailure> {
13546        let deadline = Instant::now() + commit_timeout;
13547        let mut next_dispatch = Instant::now();
13548        loop {
13549            let snapshot = self.handle.snapshot();
13550            let status = snapshot
13551                .sessions
13552                .iter()
13553                .find(|session| session.instance_id == address.session.instance_id)
13554                .map(|session| session.status.clone());
13555            let Some(status) = status else {
13556                return Ok(());
13557            };
13558            let now = Instant::now();
13559            if now >= deadline {
13560                return Err(failure(
13561                    NodeFailureCode::BackendBusy,
13562                    "remove did not commit before the bounded deadline; session binding was retained",
13563                ));
13564            }
13565            if status.allows_remove() && now >= next_dispatch {
13566                let _ = self.dispatch(ControlCommand::Remove {
13567                    instance_id: address.session.instance_id,
13568                });
13569                next_dispatch = now + Duration::from_millis(REMOVE_REDISPATCH_INTERVAL_MS);
13570            }
13571            sleep(Duration::from_millis(2)).await;
13572        }
13573    }
13574
13575    fn resync(&self, after_sequence: u64) -> NodeResponse {
13576        let history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
13577        let event_sequence = history.last_sequence;
13578        let oldest_available_sequence = history.replay_floor_sequence;
13579        let events = history
13580            .events
13581            .iter()
13582            .filter(|event| event.sequence > after_sequence)
13583            .cloned()
13584            .collect();
13585        drop(history);
13586        NodeResponse::Resync {
13587            event_sequence,
13588            oldest_available_sequence,
13589            snapshot: self.snapshot(),
13590            events,
13591        }
13592    }
13593}
13594
13595/// Coarse label for one `AgentStreamChunkKindV1`, used only by
13596/// `NodeShared::publish_agent_stream_chunk`'s own debug log -- mirrors
13597/// `hatchery-harness-service`'s `agent_stream_event_kind_label` (same
13598/// "name what crossed this boundary" reasoning, one layer upstream of it).
13599fn agent_stream_chunk_kind_label(kind: &AgentStreamChunkKindV1) -> &'static str {
13600    match kind {
13601        AgentStreamChunkKindV1::Text { .. } => "text",
13602        AgentStreamChunkKindV1::Thinking { .. } => "thinking",
13603        AgentStreamChunkKindV1::InteractionPrompt { .. } => "interaction-prompt",
13604        AgentStreamChunkKindV1::ModeCatalog { .. } => "mode-catalog",
13605        AgentStreamChunkKindV1::ConfigOptions { .. } => "config-options",
13606        AgentStreamChunkKindV1::ModelCatalog { .. } => "model-catalog",
13607        AgentStreamChunkKindV1::Blocked { .. } => "blocked",
13608    }
13609}
13610
13611/// Serves this wire on the local endpoint, and -- when `call_home` names a
13612/// relay -- over an outbound connection to it as well.
13613///
13614/// The two live in one future rather than a fourth arm of `run`'s own
13615/// `select!` because they are one concern: this is where the node's wire
13616/// is served, and the direction a given connection was opened in does not
13617/// change what happens on it. It also means the existing graceful drain
13618/// below covers both without being duplicated.
13619///
13620/// Either side finishing ends the pair, which is correct: the listener
13621/// only returns on shutdown or a bind failure, and `call_home_loop` only
13622/// returns on shutdown -- a failed relay connection is retried inside it,
13623/// never propagated out.
13624async fn accept_connections(
13625    endpoint: &str,
13626    call_home: Option<std::net::SocketAddr>,
13627    identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13628    shared: Arc<NodeShared>,
13629) -> Result<(), NodeServerError> {
13630    let mut connections = JoinSet::new();
13631    let result = match call_home {
13632        None => accept_connections_inner(endpoint, shared, &mut connections).await,
13633        Some(relay) => {
13634            let listener =
13635                accept_connections_inner(endpoint, Arc::clone(&shared), &mut connections);
13636            let dialler = call_home_loop(relay, identity, Arc::clone(&shared));
13637            tokio::pin!(listener);
13638            tokio::pin!(dialler);
13639            tokio::select! {
13640                result = &mut listener => result,
13641                result = &mut dialler => result,
13642            }
13643        }
13644    };
13645    let graceful_deadline = Instant::now() + Duration::from_millis(CONNECTION_SHUTDOWN_GRACE_MS);
13646    while !connections.is_empty() {
13647        let remaining = graceful_deadline.saturating_duration_since(Instant::now());
13648        if remaining.is_zero()
13649            || timeout(remaining, connections.join_next()).await.is_err()
13650        {
13651            break;
13652        }
13653    }
13654    connections.abort_all();
13655    while connections.join_next().await.is_some() {}
13656    result
13657}
13658
13659/// Backoff ladder for a relay that will not answer, capped rather than
13660/// unbounded: a node whose relay is down is not broken and should keep
13661/// trying forever, but it should not spend a core doing it.
13662const CALL_HOME_BACKOFFS_MS: [u64; 5] = [500, 1_000, 2_000, 4_000, 8_000];
13663
13664/// Dials the relay, serves this wire over the connection, and does it
13665/// again when the connection ends.
13666///
13667/// The node is the SERVER on the socket it just opened. That inversion is
13668/// the whole point: `serve_connection` is the same function the local
13669/// listener feeds, and it neither knows nor cares which end placed the
13670/// call. The one thing this direction adds is the preface -- the relay
13671/// accepted a socket and cannot know whose it is, so the node says its
13672/// name before the handshake it would otherwise begin with.
13673///
13674/// Never returns an error. A relay that is down, refusing, or speaking a
13675/// different protocol is a condition to retry, not a reason to take the
13676/// node's local endpoint down with it; the only way out is shutdown.
13677async fn call_home_loop(
13678    relay: std::net::SocketAddr,
13679    identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13680    shared: Arc<NodeShared>,
13681) -> Result<(), NodeServerError> {
13682    let mut failures = 0_usize;
13683    loop {
13684        if shared.shutdown.load(Ordering::Acquire) {
13685            return Ok(());
13686        }
13687        let permit = tokio::select! {
13688            permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13689                permit.map_err(|_| NodeServerError::PreauthClosed)?
13690            }
13691            _ = shared.shutdown_notify.notified() => return Ok(()),
13692        };
13693        match dial_relay_and_serve(relay, identity.as_deref(), Arc::clone(&shared), permit).await {
13694            Ok(()) => {
13695                // A clean end is the relay hanging up, which it does on
13696                // its own restart. Reconnect immediately rather than
13697                // serving the backoff a peer that is plainly there.
13698                failures = 0;
13699                tracing::info!(node_id = %shared.node_id, %relay, "call-home connection closed; redialling");
13700            }
13701            Err(error) => {
13702                failures = failures.saturating_add(1);
13703                tracing::warn!(
13704                    node_id = %shared.node_id,
13705                    %relay,
13706                    cause = %error,
13707                    attempt = failures,
13708                    "call-home connection failed",
13709                );
13710                let backoff = call_home_backoff(failures);
13711                tokio::select! {
13712                    _ = sleep(backoff) => {}
13713                    _ = shared.shutdown_notify.notified() => return Ok(()),
13714                }
13715            }
13716        }
13717    }
13718}
13719
13720/// Idle time before the first keepalive probe on a call-home socket.
13721const CALL_HOME_KEEPALIVE_IDLE: Duration = Duration::from_secs(15);
13722/// Interval between keepalive probes once the link has gone quiet.
13723const CALL_HOME_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(5);
13724
13725fn call_home_keepalive() -> socket2::TcpKeepalive {
13726    let keepalive = socket2::TcpKeepalive::new()
13727        .with_time(CALL_HOME_KEEPALIVE_IDLE)
13728        .with_interval(CALL_HOME_KEEPALIVE_INTERVAL);
13729    #[cfg(not(windows))]
13730    let keepalive = keepalive.with_retries(3);
13731    keepalive
13732}
13733
13734fn enable_call_home_keepalive(stream: &tokio::net::TcpStream) -> std::io::Result<()> {
13735    socket2::SockRef::from(stream).set_tcp_keepalive(&call_home_keepalive())
13736}
13737
13738/// Same keepalive profile for any node->relay socket (restream uplink).
13739pub(crate) fn enable_tcp_keepalive(stream: &std::net::TcpStream) -> std::io::Result<()> {
13740    socket2::SockRef::from(stream).set_tcp_keepalive(&call_home_keepalive())
13741}
13742
13743/// Backoff for the `attempt`-th consecutive failure (1-based), capped.
13744fn call_home_backoff(attempt: usize) -> Duration {
13745    Duration::from_millis(
13746        CALL_HOME_BACKOFFS_MS[attempt.saturating_sub(1).min(CALL_HOME_BACKOFFS_MS.len() - 1)],
13747    )
13748}
13749
13750
13751/// Dial the call-home relay. Tunnel addresses go through the in-process
13752/// WireGuard dialer when the userspace session is up.
13753async fn dial_call_home(relay: std::net::SocketAddr) -> Result<tokio::net::TcpStream, NodeServerError> {
13754    #[cfg(feature = "wireguard")]
13755    {
13756        let std_stream = tokio::task::spawn_blocking(move || {
13757            gate4agent_node_core::kit_wireguard::tcp_connect(relay)
13758        })
13759        .await
13760        .map_err(|e| NodeServerError::CallHomeTransport(std::io::Error::other(e.to_string())))?
13761        .map_err(NodeServerError::CallHomeTransport)?;
13762        std_stream
13763            .set_nonblocking(true)
13764            .map_err(NodeServerError::CallHomeTransport)?;
13765        return tokio::net::TcpStream::from_std(std_stream).map_err(NodeServerError::CallHomeTransport);
13766    }
13767    #[cfg(not(feature = "wireguard"))]
13768    {
13769        tokio::net::TcpStream::connect(relay)
13770            .await
13771            .map_err(NodeServerError::CallHomeTransport)
13772    }
13773}
13774
13775/// One call-home attempt: connect, name this node, then serve.
13776async fn dial_relay_and_serve(
13777    relay: std::net::SocketAddr,
13778    identity: Option<&hatchery_enroll::NodeIdentity>,
13779    shared: Arc<NodeShared>,
13780    preauth_permit: OwnedSemaphorePermit,
13781) -> Result<(), NodeServerError> {
13782    let mut stream = dial_call_home(relay).await?;
13783    // Nagle off for the same reason the rest of this wire wants it off:
13784    // the traffic is small request/response frames, and coalescing them
13785    // adds latency to every one.
13786    let _ = stream.set_nodelay(true);
13787    // A relay that vanishes without a FIN/RST reaching us (tunnel drop,
13788    // relay-side socket kill whose RST was lost) would otherwise leave this
13789    // socket ESTABLISHED forever: the node mostly reads, so nothing ever
13790    // fails and the reconnect loop never runs. Kernel keepalive turns that
13791    // half-open link into a read error within a bounded time.
13792    if let Err(error) = enable_call_home_keepalive(&stream) {
13793        tracing::warn!(cause = %error, "could not enable TCP keepalive on the call-home socket");
13794    }
13795    gate4agent_node_wire::write_call_home_announce(&mut stream, &shared.node_id)
13796        .await
13797        .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13798    tracing::info!(node_id = %shared.node_id, %relay, "announced this node to the relay");
13799    let mut session_token = None;
13800    if let Some(identity) = identity {
13801        // An enrolled relay answers the announce with a signed-call-home challenge; a
13802        // token-mode relay sends its wire hello instead. Peek to tell them apart
13803        // without consuming either.
13804        let mut magic = [0_u8; 4];
13805        let peeked = timeout(Duration::from_secs(10), stream.peek(&mut magic))
13806            .await
13807            .map_err(|_| NodeServerError::Handshake("relay sent nothing after the announce".to_owned()))?
13808            .map_err(NodeServerError::CallHomeTransport)?;
13809        if peeked == 4 && &magic == hatchery_enroll::CHALLENGE_MAGIC {
13810            let mut frame = [0_u8; hatchery_enroll::CHALLENGE_LEN];
13811            timeout(Duration::from_secs(5), tokio::io::AsyncReadExt::read_exact(&mut stream, &mut frame))
13812                .await
13813                .map_err(|_| NodeServerError::Handshake("relay challenge timed out".to_owned()))?
13814                .map_err(NodeServerError::CallHomeTransport)?;
13815            let (answer, token) = identity
13816                .answer_challenge(shared.node_id.as_str(), &frame)
13817                .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13818            tokio::io::AsyncWriteExt::write_all(&mut stream, &answer).await.map_err(NodeServerError::CallHomeTransport)?;
13819            tracing::info!(node_id = %shared.node_id, "answered the relay's signed call-home challenge");
13820            session_token = Some(token);
13821        }
13822    }
13823    serve_connection(stream, shared, session_token, preauth_permit).await
13824}
13825
13826async fn accept_connections_inner(
13827    endpoint: &str,
13828    shared: Arc<NodeShared>,
13829    connections: &mut JoinSet<Result<(), NodeServerError>>,
13830) -> Result<(), NodeServerError> {
13831    let mut listener = OwnerOnlyLocalListener::bind(endpoint).await?;
13832    loop {
13833        while connections.try_join_next().is_some() {}
13834        if shared.shutdown.load(Ordering::Acquire) {
13835            return Ok(());
13836        }
13837        let preauth_permit = tokio::select! {
13838            permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13839                permit.map_err(|_| NodeServerError::PreauthClosed)?
13840            }
13841            _ = shared.shutdown_notify.notified() => return Ok(()),
13842        };
13843        let server = tokio::select! {
13844            result = listener.accept() => result?,
13845            _ = shared.shutdown_notify.notified() => return Ok(()),
13846        };
13847        let connection_shared = Arc::clone(&shared);
13848        connections.spawn(async move {
13849            serve_connection(server, connection_shared, None, preauth_permit).await
13850        });
13851        if shared.shutdown.load(Ordering::Acquire) {
13852            return Ok(());
13853        }
13854    }
13855}
13856
13857/// Serves one connection, whichever end opened it.
13858///
13859/// Generic over the stream rather than typed to `LocalServerStream`
13860/// because a call-home connection arrives on a `TcpStream` the node itself
13861/// dialled. Nothing in the body ever depended on the concrete type -- it
13862/// reads and writes framed JSON and then `tokio::io::split`s -- so this is
13863/// the whole of what welding "who dials" to "who serves" was costing.
13864async fn serve_connection<S>(
13865    mut pipe: S,
13866    shared: Arc<NodeShared>,
13867    session_token: Option<String>,
13868    preauth_permit: OwnedSemaphorePermit,
13869) -> Result<(), NodeServerError>
13870where
13871    S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
13872{
13873    // A signed call-home connection authenticates with its own per-connection token.
13874    let auth_token: &[u8] = session_token.as_deref().map_or(shared.access_token.as_bytes(), str::as_bytes);
13875    let hello_frame = timeout(
13876        Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13877        read_json_frame_limited_body_timeout(
13878            &mut pipe,
13879            MAX_NODE_HELLO_FRAME_BYTES,
13880            Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13881        ),
13882    )
13883    .await
13884    .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13885    let ClientFrame::Hello(hello) = hello_frame else {
13886        return Err(NodeServerError::Handshake("first frame must be hello".to_owned()));
13887    };
13888    if hello.build_stamp != BUILD_STAMP {
13889        return Err(NodeServerError::Handshake(format!(
13890            "build stamp mismatch: local={BUILD_STAMP} remote={}",
13891            hello.build_stamp,
13892        )));
13893    }
13894    let compatibility = match hello.compatibility.as_ref() {
13895        Some(offer) => {
13896            let mut selected = node_compatibility_support(&shared)?
13897                .negotiate(offer)
13898                .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13899            project_negotiated_provider_ids(&mut selected);
13900            Some(selected)
13901        }
13902        None => None,
13903    };
13904    let server_nonce = random_nonce().map_err(NodeServerError::Authentication)?;
13905    let server_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13906        (Some(offer), Some(selected)) => negotiated_auth_proof(
13907            auth_token,
13908            AuthDirection::Server,
13909            hello.role,
13910            &hello.client_nonce,
13911            &server_nonce,
13912            offer,
13913            selected,
13914        ),
13915        (None, None) => auth_proof(
13916            auth_token,
13917            AuthDirection::Server,
13918            hello.role,
13919            &hello.client_nonce,
13920            &server_nonce,
13921        ),
13922        _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13923    }
13924    .map_err(NodeServerError::Authentication)?;
13925    write_json_frame_limited(
13926        &mut pipe,
13927        &ServerFrame::Challenge(ServerChallenge {
13928            build_stamp: BUILD_STAMP.to_owned(),
13929            server_nonce,
13930            server_proof,
13931            compatibility: compatibility.clone(),
13932        }),
13933        MAX_NODE_HELLO_FRAME_BYTES,
13934    )
13935    .await?;
13936    let authentication = timeout(
13937        Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13938        read_json_frame_limited_body_timeout(
13939            &mut pipe,
13940            MAX_NODE_HELLO_FRAME_BYTES,
13941            Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13942        ),
13943    )
13944    .await
13945    .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13946    let ClientFrame::Authenticate(authentication) = authentication else {
13947        return Err(NodeServerError::Handshake("second frame must authenticate the challenge".to_owned()));
13948    };
13949    let expected_client_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13950        (Some(offer), Some(selected)) => negotiated_auth_proof(
13951            auth_token,
13952            AuthDirection::Client,
13953            hello.role,
13954            &hello.client_nonce,
13955            &server_nonce,
13956            offer,
13957            selected,
13958        ),
13959        (None, None) => auth_proof(
13960            auth_token,
13961            AuthDirection::Client,
13962            hello.role,
13963            &hello.client_nonce,
13964            &server_nonce,
13965        ),
13966        _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13967    }
13968    .map_err(NodeServerError::Authentication)?;
13969    if !proofs_match(&authentication.client_proof, &expected_client_proof) {
13970        return Err(NodeServerError::Handshake("access denied".to_owned()));
13971    }
13972    let selected_capabilities = compatibility
13973        .as_ref()
13974        .map(|selected| selected.capabilities.clone())
13975        .unwrap_or_default();
13976    let include_provider_runtime_status = selected_capabilities.iter().any(|capability| {
13977        capability.as_str() == NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY
13978    });
13979    let include_open_provider_ids = selected_capabilities.iter().any(|capability| {
13980        capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
13981    });
13982    let include_terminal_frame_events = selected_capabilities.iter().any(|capability| {
13983        capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
13984    });
13985    let include_agent_stream_events = selected_capabilities.iter().any(|capability| {
13986        capability.as_str() == NODE_AGENT_STREAM_EVENTS_CAPABILITY
13987    });
13988    let include_spawn_profiles = selected_capabilities.iter().any(|capability| {
13989        capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
13990    });
13991    let include_managed_worktrees = selected_capabilities.iter().any(|capability| {
13992        capability.as_str() == NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY
13993    }) && selected_capabilities.iter().any(|capability| {
13994        capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
13995    });
13996    let include_child_environment_profiles = selected_capabilities.iter().any(|capability| {
13997        capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
13998    });
13999    let include_session_bundles = selected_capabilities.iter().any(|capability| {
14000        capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
14001    });
14002    let include_history_context_packs = selected_capabilities.iter().any(|capability| {
14003        capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
14004    });
14005    let include_agent_progress = selected_capabilities.iter().any(|capability| {
14006        capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
14007    });
14008    let include_session_task_correlation = selected_capabilities.iter().any(|capability| {
14009        capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
14010    });
14011    let include_harness_mcp_proxy = selected_capabilities.iter().any(|capability| {
14012        capability.as_str() == NODE_HARNESS_MCP_READ_PROXY_CAPABILITY
14013    });
14014    let authenticated_permit = Arc::clone(&shared.authenticated_slots)
14015        .try_acquire_owned()
14016        .map_err(|_| NodeServerError::AuthenticatedConnectionLimit)?;
14017    let connection_id = shared.next_connection_id.fetch_add(1, Ordering::AcqRel);
14018    let _release_guard = ControllerReleaseGuard {
14019        shared: Arc::clone(&shared),
14020        connection_id,
14021    };
14022    drop(preauth_permit);
14023    let _authenticated_permit = authenticated_permit;
14024    let mut event_rx = shared.event_tx.subscribe();
14025    let mut terminal_event_rx = terminal_event_subscription(
14026        &shared,
14027        include_terminal_frame_events,
14028    );
14029    let mut agent_stream_event_rx = agent_stream_event_subscription(
14030        &shared,
14031        include_agent_stream_events,
14032    );
14033    let mut harness_mcp_proxy_event_rx = harness_mcp_proxy_event_subscription(
14034        &shared,
14035        include_harness_mcp_proxy,
14036    );
14037    write_json_frame(
14038        &mut pipe,
14039        &ServerFrame::Hello(NodeHello {
14040            build_stamp: BUILD_STAMP.to_owned(),
14041            incarnation_id: shared.incarnation_id,
14042            connection_id,
14043            role: hello.role,
14044            event_sequence: shared.current_sequence(),
14045            controller: shared.controller_state(),
14046            snapshot: snapshot_for_wire(
14047                &shared,
14048                include_provider_runtime_status,
14049                include_open_provider_ids,
14050                include_spawn_profiles,
14051                include_managed_worktrees,
14052                include_child_environment_profiles,
14053                include_session_bundles,
14054                include_history_context_packs,
14055                include_agent_progress,
14056                include_session_task_correlation,
14057            ),
14058            compatibility,
14059        }),
14060    )
14061    .await?;
14062    let (mut reader, mut writer) = tokio::io::split(pipe);
14063    let (frame_tx, mut frame_rx) = mpsc::channel(16);
14064    let reader_task = tokio::spawn(async move {
14065        loop {
14066            let frame = read_json_frame_limited_body_timeout::<_, ClientFrame>(
14067                &mut reader,
14068                MAX_NODE_CLIENT_FRAME_BYTES,
14069                Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
14070            )
14071            .await;
14072            let terminal = frame.is_err();
14073            if frame_tx.send(frame).await.is_err() || terminal {
14074                break;
14075            }
14076        }
14077    });
14078    let _reader_abort = AbortTaskOnDrop(reader_task.abort_handle());
14079    let mut pending_events = Vec::new();
14080    let mut resync_required = false;
14081    let mut discard_events_through = 0_u64;
14082    loop {
14083        // One tick of the serve loop, whether or not it had anything to do.
14084        // Against `connection_events_sent` this separates "busy" from
14085        // "spinning": a loop turning over thousands of times a second while
14086        // sending nothing is not serving, it is burning a core.
14087        shared.connection_loop_iterations.fetch_add(1, Ordering::Relaxed);
14088        let durable_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
14089            .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
14090        for _ in 0..durable_drain_budget {
14091            match event_rx.try_recv() {
14092                Ok(event) => queue_connection_event(
14093                    &mut pending_events,
14094                    event,
14095                    discard_events_through,
14096                ),
14097                Err(broadcast::error::TryRecvError::Empty) => break,
14098                Err(broadcast::error::TryRecvError::Lagged(_)) => {
14099                    resync_required = true;
14100                    break;
14101                }
14102                Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14103            }
14104        }
14105        if let Some(receiver) = terminal_event_rx.as_mut() {
14106            match receiver.try_recv() {
14107                Ok(events) => queue_connection_event_batch(
14108                    &mut pending_events,
14109                    &events,
14110                    discard_events_through,
14111                ),
14112                Err(broadcast::error::TryRecvError::Empty) => {}
14113                Err(broadcast::error::TryRecvError::Lagged(_)) => {
14114                    resync_required = true;
14115                }
14116                Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14117            }
14118        }
14119        if let Some(receiver) = agent_stream_event_rx.as_mut() {
14120            // Loops up to the same remaining-room budget the durable
14121            // `event_rx` drain above uses, rather than one `try_recv` per
14122            // tick: each successful receive here is itself only ONE
14123            // `NodeEventEnvelope` batch (`publish_agent_stream_chunk` always
14124            // sends `vec![envelope]`, never more), so a single-shot drain
14125            // caps this connection's agent-stream throughput at one chunk
14126            // per outer-loop tick regardless of how many are already queued
14127            // -- exactly the kind of self-inflicted stall this crate's own
14128            // durable-event budget exists to avoid.
14129            let agent_stream_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
14130                .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
14131            for _ in 0..agent_stream_drain_budget {
14132                match receiver.try_recv() {
14133                    Ok(events) => queue_connection_event_batch(
14134                        &mut pending_events,
14135                        &events,
14136                        discard_events_through,
14137                    ),
14138                    Err(broadcast::error::TryRecvError::Empty) => break,
14139                    Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
14140                        tracing::warn!(
14141                            connection_id,
14142                            dropped,
14143                            "agent stream event subscription lagged; forcing a resync",
14144                        );
14145                        resync_required = true;
14146                        break;
14147                    }
14148                    Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14149                }
14150            }
14151        }
14152        if let Some(receiver) = harness_mcp_proxy_event_rx.as_mut() {
14153            // Mirrors the agent-stream drain immediately above: each
14154            // successful receive is one single-envelope batch
14155            // (`publish_transient` always sends `vec![envelope]`), so this
14156            // loops up to the same remaining-room budget rather than
14157            // stopping after one `try_recv`.
14158            let harness_mcp_proxy_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
14159                .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
14160            for _ in 0..harness_mcp_proxy_drain_budget {
14161                match receiver.try_recv() {
14162                    Ok(events) => queue_connection_event_batch(
14163                        &mut pending_events,
14164                        &events,
14165                        discard_events_through,
14166                    ),
14167                    Err(broadcast::error::TryRecvError::Empty) => break,
14168                    Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
14169                        tracing::warn!(
14170                            connection_id,
14171                            dropped,
14172                            "harness MCP proxy event subscription lagged; forcing a resync",
14173                        );
14174                        resync_required = true;
14175                        break;
14176                    }
14177                    Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14178                }
14179            }
14180        }
14181        if resync_required {
14182            let event = resync_required_event(&shared);
14183            apply_connection_resync_watermark(
14184                &mut pending_events,
14185                &mut discard_events_through,
14186                event.sequence,
14187            );
14188            write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
14189            resync_required = false;
14190            continue;
14191        }
14192        if !pending_events.is_empty() {
14193            pending_events.sort_unstable_by_key(|event| event.sequence);
14194            let burst_len = connection_event_burst_len(pending_events.len());
14195            shared
14196                .connection_events_sent
14197                .fetch_add(burst_len as u64, Ordering::Relaxed);
14198            for event in pending_events.drain(..burst_len) {
14199                let event = if include_managed_worktrees {
14200                    Some(event)
14201                } else {
14202                    project_event_without_managed_worktrees(event)
14203                };
14204                let event = event.and_then(|event| if include_open_provider_ids {
14205                    Some(event)
14206                } else {
14207                    project_event_legacy_provider_ids(&shared, event)
14208                });
14209                let event = event.map(|event| if include_child_environment_profiles {
14210                    event
14211                } else {
14212                    project_event_without_child_environment_profile(event)
14213                });
14214                let event = event.map(|event| if include_session_bundles {
14215                    event
14216                } else {
14217                    project_event_without_session_bundle(event)
14218                });
14219                let event = event.map(|event| {
14220                    project_event_history_for_wire(event, include_history_context_packs)
14221                });
14222                let event = event.map(|event| if include_history_context_packs {
14223                    event
14224                } else {
14225                    project_event_without_context_pack(event)
14226                });
14227                let event = event.map(|event| if include_session_task_correlation {
14228                    event
14229                } else {
14230                    project_event_without_session_task_binding(event)
14231                });
14232                let exact_harness_controller = hello.role == ClientRole::Operator
14233                    && shared.controller_state().is_some_and(|controller| {
14234                        controller.connection_id == connection_id
14235                    });
14236                let event = event.and_then(|event| {
14237                    if include_harness_mcp_proxy && exact_harness_controller {
14238                        Some(event)
14239                    } else {
14240                        project_event_without_harness_mcp_proxy(event)
14241                    }
14242                });
14243                if let Some(event) = event {
14244                    write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
14245                }
14246            }
14247        }
14248        tokio::select! {
14249            biased;
14250            frame = frame_rx.recv() => {
14251                let frame = match frame {
14252                    Some(frame) => frame,
14253                    None => break,
14254                };
14255                let frame = match frame {
14256                    Ok(frame) => frame,
14257                    Err(FrameError::Io(error)) if matches!(error.kind(), io::ErrorKind::BrokenPipe | io::ErrorKind::UnexpectedEof) => break,
14258                    Err(error) => return Err(error.into()),
14259                };
14260                let ClientFrame::Request(request) = frame else {
14261                    return Err(NodeServerError::Handshake("hello may only be sent once".to_owned()));
14262                };
14263                #[cfg(feature = "fixture")]
14264                let probe_spawn_managed_worktree_v2 = matches!(
14265                    &request.request,
14266                    NodeRequest::SpawnManagedWorktreeV2 { .. }
14267                );
14268                let requires_open_provider_ids =
14269                    request_requires_open_provider_ids(&shared, &request.request);
14270                let requires_child_environment_profile =
14271                    request_requires_child_environment_profile(&shared, &request.request);
14272                let requires_session_bundle =
14273                    request_requires_session_bundle(&shared, &request.request);
14274                let requires_history_context_pack =
14275                    request_requires_history_context_pack(&shared, &request.request);
14276                let mut reply = if requires_open_provider_ids && !include_open_provider_ids {
14277                    ResponseEnvelope {
14278                        request_id: request.request_id,
14279                        result: Err(failure(
14280                            NodeFailureCode::UnsupportedCapability,
14281                            "open provider IDs were not negotiated",
14282                        )),
14283                    }
14284                } else if requires_child_environment_profile
14285                    && !include_child_environment_profiles
14286                {
14287                    ResponseEnvelope {
14288                        request_id: request.request_id,
14289                        result: Err(failure(
14290                            NodeFailureCode::UnsupportedCapability,
14291                            "child environment profiles were not negotiated",
14292                        )),
14293                    }
14294                } else if requires_session_bundle && !include_session_bundles {
14295                    ResponseEnvelope {
14296                        request_id: request.request_id,
14297                        result: Err(failure(
14298                            NodeFailureCode::UnsupportedCapability,
14299                            "session bundles were not negotiated",
14300                        )),
14301                    }
14302                } else if requires_history_context_pack && !include_history_context_packs {
14303                    ResponseEnvelope {
14304                        request_id: request.request_id,
14305                        result: Err(failure(
14306                            NodeFailureCode::UnsupportedCapability,
14307                            "history context packs were not negotiated",
14308                        )),
14309                    }
14310                } else if request_uses_unnegotiated_capability(
14311                    &request.request,
14312                    &selected_capabilities,
14313                )
14314                {
14315                    ResponseEnvelope {
14316                        request_id: request.request_id,
14317                        result: Err(failure(
14318                            NodeFailureCode::UnsupportedCapability,
14319                            "request capability was not negotiated",
14320                        )),
14321                    }
14322                } else {
14323                    process_request(&shared, connection_id, hello.role, request).await
14324                };
14325                #[cfg(feature = "fixture")]
14326                if probe_spawn_managed_worktree_v2 {
14327                    shared
14328                        .fixture_spawn_managed_worktree_v2_failure_probe
14329                        .record(reply.result.as_ref().err().map(|failure| failure.code));
14330                }
14331                if !include_provider_runtime_status {
14332                    clear_response_provider_runtime_status(&mut reply);
14333                }
14334                if !include_managed_worktrees {
14335                    project_response_without_managed_worktrees(&mut reply);
14336                }
14337                if !include_open_provider_ids {
14338                    project_response_legacy_provider_ids(&shared, &mut reply);
14339                }
14340                if !include_child_environment_profiles {
14341                    project_response_without_child_environment_profile(&mut reply);
14342                }
14343                if !include_session_bundles {
14344                    project_response_without_session_bundle(&mut reply);
14345                }
14346                project_response_history_for_wire(
14347                    &mut reply,
14348                    include_history_context_packs,
14349                );
14350                if !include_history_context_packs {
14351                    project_response_without_context_pack(&mut reply);
14352                }
14353                if !include_agent_progress {
14354                    project_response_without_agent_progress(&mut reply);
14355                }
14356                if !include_session_task_correlation {
14357                    project_response_without_session_task_binding(&mut reply);
14358                }
14359                write_json_frame(&mut writer, &ServerFrame::Reply(reply)).await?;
14360            }
14361            _ = tokio::task::yield_now(), if !pending_events.is_empty() => {}
14362            event = event_rx.recv() => {
14363                match event {
14364                    Ok(event) => queue_connection_event(
14365                        &mut pending_events,
14366                        event,
14367                        discard_events_through,
14368                    ),
14369                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14370                    Err(broadcast::error::RecvError::Closed) => break,
14371                }
14372            }
14373            events = receive_terminal_event_batch(&mut terminal_event_rx) => {
14374                match events {
14375                    Ok(events) => queue_connection_event_batch(
14376                        &mut pending_events,
14377                        &events,
14378                        discard_events_through,
14379                    ),
14380                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14381                    Err(broadcast::error::RecvError::Closed) => break,
14382                }
14383            }
14384            events = receive_terminal_event_batch(&mut agent_stream_event_rx) => {
14385                match events {
14386                    Ok(events) => queue_connection_event_batch(
14387                        &mut pending_events,
14388                        &events,
14389                        discard_events_through,
14390                    ),
14391                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14392                    Err(broadcast::error::RecvError::Closed) => break,
14393                }
14394            }
14395            events = receive_terminal_event_batch(&mut harness_mcp_proxy_event_rx) => {
14396                match events {
14397                    Ok(events) => queue_connection_event_batch(
14398                        &mut pending_events,
14399                        &events,
14400                        discard_events_through,
14401                    ),
14402                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14403                    Err(broadcast::error::RecvError::Closed) => break,
14404                }
14405            }
14406            _ = shared.shutdown_notify.notified() => break,
14407        }
14408    }
14409    Ok(())
14410}
14411
14412async fn receive_terminal_event_batch(
14413    receiver: &mut Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>>,
14414) -> Result<Arc<Vec<NodeEventEnvelope>>, broadcast::error::RecvError> {
14415    match receiver {
14416        Some(receiver) => receiver.recv().await,
14417        None => std::future::pending().await,
14418    }
14419}
14420
14421fn terminal_event_subscription(
14422    shared: &NodeShared,
14423    enabled: bool,
14424) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14425    enabled.then(|| shared.terminal_event_tx.subscribe())
14426}
14427
14428/// Mirrors `terminal_event_subscription` exactly, over the agent stream's
14429/// own channel -- see `NodeShared::agent_stream_event_tx`'s own doc for why
14430/// this needs a dedicated channel rather than a flag on the terminal one.
14431fn agent_stream_event_subscription(
14432    shared: &NodeShared,
14433    enabled: bool,
14434) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14435    enabled.then(|| shared.agent_stream_event_tx.subscribe())
14436}
14437
14438/// Mirrors `agent_stream_event_subscription` exactly, over the harness MCP
14439/// proxy's own channel -- see `NodeShared::harness_mcp_proxy_event_tx`'s
14440/// own doc for why this needs a dedicated channel rather than a ride on
14441/// the durable `event_tx`.
14442fn harness_mcp_proxy_event_subscription(
14443    shared: &NodeShared,
14444    enabled: bool,
14445) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14446    enabled.then(|| shared.harness_mcp_proxy_event_tx.subscribe())
14447}
14448
14449fn queue_connection_event(
14450    pending: &mut Vec<NodeEventEnvelope>,
14451    event: NodeEventEnvelope,
14452    discard_through: u64,
14453) {
14454    if event.sequence <= discard_through
14455        && !event.event.requires_harness_mcp_proxy_capability()
14456    {
14457        return;
14458    }
14459    if let NodeEvent::TerminalFrame { address, .. } = &event.event {
14460        if let Some(index) = pending.iter().position(|current| {
14461            matches!(
14462                &current.event,
14463                NodeEvent::TerminalFrame {
14464                    address: current_address,
14465                    ..
14466                } if current_address == address
14467            )
14468        }) {
14469            pending.remove(index);
14470        }
14471    }
14472    pending.push(event);
14473}
14474
14475fn project_event_without_harness_mcp_proxy(
14476    envelope: NodeEventEnvelope,
14477) -> Option<NodeEventEnvelope> {
14478    (!envelope.event.requires_harness_mcp_proxy_capability()).then_some(envelope)
14479}
14480
14481fn queue_connection_event_batch(
14482    pending: &mut Vec<NodeEventEnvelope>,
14483    events: &[NodeEventEnvelope],
14484    discard_through: u64,
14485) {
14486    for event in events {
14487        queue_connection_event(pending, event.clone(), discard_through);
14488    }
14489}
14490
14491fn apply_connection_resync_watermark(
14492    pending: &mut Vec<NodeEventEnvelope>,
14493    discard_through: &mut u64,
14494    marker_sequence: u64,
14495) {
14496    *discard_through = (*discard_through).max(marker_sequence);
14497    pending.retain(|event| event.sequence > *discard_through);
14498}
14499
14500fn connection_event_burst_len(pending_len: usize) -> usize {
14501    pending_len.min(NODE_CONNECTION_EVENT_BURST_MAX)
14502}
14503
14504fn resync_required_event(shared: &NodeShared) -> NodeEventEnvelope {
14505    let history = shared
14506        .history
14507        .lock()
14508        .unwrap_or_else(|poisoned| poisoned.into_inner());
14509    let sequence = history.last_sequence;
14510    let oldest_available_sequence = history.replay_floor_sequence;
14511    NodeEventEnvelope {
14512        sequence,
14513        event: NodeEvent::ResyncRequired {
14514            oldest_available_sequence,
14515        },
14516    }
14517}
14518
14519fn node_compatibility_support(
14520    shared: &NodeShared,
14521) -> Result<NodeCompatibilitySupport, NodeServerError> {
14522    let mut support = node_compatibility_support_for_manifest(
14523        &shared.provider_contracts,
14524        &shared.provider_adapter_contracts,
14525    )?;
14526    if shared
14527        .delivery_store
14528        .lock()
14529        .unwrap_or_else(|poisoned| poisoned.into_inner())
14530        .is_some()
14531    {
14532        support.capabilities.push(
14533            CapabilityId::new(NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY)
14534                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14535        );
14536    }
14537    if shared.harness_mcp_registry.is_some() {
14538        support.capabilities.push(
14539            CapabilityId::new(NODE_HARNESS_MCP_READ_PROXY_CAPABILITY)
14540                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14541        );
14542    }
14543    Ok(support)
14544}
14545
14546fn node_compatibility_support_for_manifest(
14547    provider_contracts: &[ProviderContractSupport],
14548    provider_adapter_contracts: &[ProviderAdapterContractSupport],
14549) -> Result<NodeCompatibilitySupport, NodeServerError> {
14550    Ok(NodeCompatibilitySupport {
14551        build_stamp: BUILD_STAMP.to_owned(),
14552        capabilities: baseline_capabilities()?,
14553        host: platform::host_descriptor().map_err(NodeServerError::Handshake)?,
14554        path_semantics: platform::path_semantics(),
14555        local_transport: platform::local_transport(),
14556        state_schema: StateSchemaSupport {
14557            versions: ProtocolRange::new(NODE_STATE_SCHEMA_V1, NODE_STATE_SCHEMA_V10)
14558                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14559        },
14560        provider_contracts: provider_contracts.to_vec(),
14561        provider_adapter_contracts: provider_adapter_contracts.to_vec(),
14562    })
14563}
14564
14565fn baseline_capabilities() -> Result<Vec<CapabilityId>, NodeServerError> {
14566    let capabilities = [
14567        NODE_COMPATIBILITY_METADATA_CAPABILITY,
14568        CAPABILITY_HOST_DIRECTORY_BROWSE_V1,
14569        NODE_REPOSITORY_PATH_CAPABILITY,
14570        NODE_WORKSPACE_FILE_READ_CAPABILITY,
14571        NODE_WORKSPACE_FILE_WRITE_CAPABILITY,
14572        NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY,
14573        NODE_GIT_READ_CAPABILITY,
14574        NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY,
14575        NODE_PROVIDER_ID_OPEN_CAPABILITY,
14576        NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
14577        NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY,
14578        NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
14579        NODE_AGENT_STREAM_EVENTS_CAPABILITY,
14580        NODE_ACP_CONTROL_CAPABILITY,
14581        NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
14582        NODE_SPAWN_PROFILE_REVISION_CAPABILITY,
14583        NODE_WORKTREE_SELECTION_CAPABILITY,
14584        NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
14585        NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
14586        NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
14587        NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
14588        NODE_HISTORY_CONTEXT_PACK_CAPABILITY,
14589        NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
14590        NODE_NATIVE_SESSION_CATALOG_CAPABILITY,
14591        NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
14592        NODE_NATIVE_SESSION_INDEX_CAPABILITY,
14593        NODE_NATIVE_SESSION_PREVIEW_CAPABILITY,
14594        NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
14595        NODE_SESSION_TASK_CORRELATION_CAPABILITY,
14596    ]
14597        .into_iter()
14598        .map(|capability| {
14599            CapabilityId::new(capability)
14600                .map_err(|error| NodeServerError::Handshake(error.to_string()))
14601        })
14602        .collect::<Result<Vec<_>, _>>()?;
14603    #[cfg(windows)]
14604    let capabilities = {
14605        let mut capabilities = capabilities;
14606        capabilities.push(
14607            CapabilityId::new(NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY)
14608                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14609        );
14610        capabilities
14611    };
14612    Ok(capabilities)
14613}
14614
14615fn request_uses_unnegotiated_capability(
14616    request: &NodeRequest,
14617    selected_capabilities: &[CapabilityId],
14618) -> bool {
14619    let selected = |required: &str| {
14620        selected_capabilities
14621            .iter()
14622            .any(|capability| capability.as_str() == required)
14623    };
14624    request
14625        .required_capability()
14626        .is_some_and(|required| !selected(required))
14627        || (request.requires_worktree_selection_capability()
14628            && !selected(NODE_WORKTREE_SELECTION_CAPABILITY))
14629        || (request.requires_spawn_spec_defaults_overrides_capability()
14630            && !selected(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY))
14631        || (request.requires_spawn_profile_revision_capability()
14632            && !selected(NODE_SPAWN_PROFILE_REVISION_CAPABILITY))
14633        || (request.requires_child_environment_profile_capability()
14634            && !selected(NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY))
14635        || (request.requires_session_bundle_materialization_capability()
14636            && !selected(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY))
14637        || (request.requires_history_context_pack_capability()
14638            && !selected(NODE_HISTORY_CONTEXT_PACK_CAPABILITY))
14639}
14640
14641fn snapshot_for_wire(
14642    shared: &NodeShared,
14643    include_provider_runtime_status: bool,
14644    include_open_provider_ids: bool,
14645    include_spawn_profiles: bool,
14646    include_managed_worktrees: bool,
14647    include_child_environment_profiles: bool,
14648    include_session_bundles: bool,
14649    include_history_context_packs: bool,
14650    include_agent_progress: bool,
14651    include_session_task_correlation: bool,
14652) -> NodeSnapshot {
14653    let mut snapshot = shared.snapshot();
14654    if !include_provider_runtime_status {
14655        snapshot.provider_runtime_statuses.clear();
14656    }
14657    if !include_open_provider_ids {
14658        project_snapshot_legacy_provider_ids(&mut snapshot);
14659    }
14660    if !include_managed_worktrees {
14661        snapshot.managed_worktrees.clear();
14662        for workspace in &mut snapshot.workspaces {
14663            workspace.managed_worktree_profiles = None;
14664        }
14665    }
14666    if let Some(inventory) = snapshot.launch_inventory.as_mut() {
14667        if !include_spawn_profiles {
14668            inventory.spawn_profiles = None;
14669        }
14670        if !include_session_bundles {
14671            inventory.bundles = None;
14672        }
14673        if inventory.spawn_profiles.is_none()
14674            && inventory.bundles.is_none()
14675            && inventory.network_allowlists.is_none()
14676        {
14677            snapshot.launch_inventory = None;
14678        }
14679    }
14680    if !include_child_environment_profiles {
14681        clear_snapshot_child_environment_profiles(&mut snapshot);
14682    }
14683    if !include_session_bundles {
14684        clear_snapshot_session_bundles(&mut snapshot);
14685    }
14686    project_snapshot_history_for_wire(&mut snapshot, include_history_context_packs);
14687    if !include_history_context_packs {
14688        clear_snapshot_context_packs(&mut snapshot);
14689    }
14690    if !include_agent_progress {
14691        snapshot.agent_progress.clear();
14692    }
14693    if !include_session_task_correlation {
14694        clear_snapshot_session_task_bindings(&mut snapshot);
14695    }
14696    snapshot
14697}
14698
14699fn clear_snapshot_session_task_bindings(snapshot: &mut NodeSnapshot) {
14700    for record in &mut snapshot.session_records {
14701        record.task_binding = None;
14702    }
14703}
14704
14705fn project_event_without_session_task_binding(
14706    mut envelope: NodeEventEnvelope,
14707) -> NodeEventEnvelope {
14708    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14709        record.task_binding = None;
14710    }
14711    envelope
14712}
14713
14714fn project_response_without_session_task_binding(reply: &mut ResponseEnvelope) {
14715    let Ok(response) = reply.result.as_mut() else { return };
14716    match response {
14717        NodeResponse::Snapshot { snapshot, .. } => {
14718            clear_snapshot_session_task_bindings(snapshot);
14719        }
14720        NodeResponse::Resync { snapshot, events, .. } => {
14721            clear_snapshot_session_task_bindings(snapshot);
14722            for event in events {
14723                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14724                    record.task_binding = None;
14725                }
14726            }
14727        }
14728        NodeResponse::SessionRecordUpdated { record }
14729        | NodeResponse::ProviderSessionIndexed { record }
14730        | NodeResponse::NativeSessionIndexed { record, .. }
14731        | NodeResponse::SessionRecordResumed { record, .. } => {
14732            record.task_binding = None;
14733        }
14734        _ => {}
14735    }
14736}
14737
14738fn project_response_without_agent_progress(reply: &mut ResponseEnvelope) {
14739    let Ok(response) = reply.result.as_mut() else { return };
14740    match response {
14741        NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
14742            snapshot.agent_progress.clear();
14743        }
14744        _ => {}
14745    }
14746}
14747
14748fn project_event_without_managed_worktrees(
14749    mut envelope: NodeEventEnvelope,
14750) -> Option<NodeEventEnvelope> {
14751    if matches!(
14752        envelope.event,
14753        NodeEvent::ManagedWorktreeUpserted { .. }
14754            | NodeEvent::ManagedWorktreeRemoved { .. }
14755    ) {
14756        return None;
14757    }
14758    if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
14759        workspace.managed_worktree_profiles = None;
14760    }
14761    Some(envelope)
14762}
14763
14764fn project_response_without_managed_worktrees(reply: &mut ResponseEnvelope) {
14765    if matches!(
14766        reply.result,
14767        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { .. })
14768            | Ok(NodeResponse::ManagedWorktreeCleanup { .. })
14769    ) {
14770        reply.result = Err(failure(
14771            NodeFailureCode::UnsupportedCapability,
14772            "managed worktree capability was not negotiated",
14773        ));
14774        return;
14775    }
14776    let Ok(response) = reply.result.as_mut() else { return };
14777    match response {
14778        NodeResponse::Snapshot { snapshot, .. } => {
14779            snapshot.managed_worktrees.clear();
14780            for workspace in &mut snapshot.workspaces {
14781                workspace.managed_worktree_profiles = None;
14782            }
14783        }
14784        NodeResponse::Resync { snapshot, events, .. } => {
14785            snapshot.managed_worktrees.clear();
14786            for workspace in &mut snapshot.workspaces {
14787                workspace.managed_worktree_profiles = None;
14788            }
14789            *events = std::mem::take(events)
14790                .into_iter()
14791                .filter_map(project_event_without_managed_worktrees)
14792                .collect();
14793        }
14794        NodeResponse::WorkspaceRegistered { workspace }
14795        | NodeResponse::StandaloneWorkspaceCreated { workspace }
14796        | NodeResponse::WorktreeCreated { workspace, .. } => {
14797            workspace.managed_worktree_profiles = None;
14798        }
14799        NodeResponse::WorkspaceInspected { inspection } => {
14800            inspection.git.managed_worktree = None;
14801        }
14802        _ => {}
14803    }
14804}
14805
14806fn project_negotiated_provider_ids(compatibility: &mut NegotiatedNodeCompatibility) {
14807    let includes_open_provider_ids = compatibility.capabilities.iter().any(|capability| {
14808        capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
14809    });
14810    if includes_open_provider_ids {
14811        return;
14812    }
14813    compatibility
14814        .provider_contracts
14815        .retain(|contract| provider_id_is_legacy(&contract.provider));
14816    compatibility
14817        .provider_adapter_contracts
14818        .retain(|contract| provider_id_is_legacy(&contract.provider));
14819}
14820
14821fn request_requires_open_provider_ids(shared: &NodeShared, request: &NodeRequest) -> bool {
14822    if let Some(spec) = match request {
14823        NodeRequest::SpawnSpec { spec }
14824        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14825        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14826        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14827        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14828        _ => None,
14829    } {
14830        return shared
14831            .resolve_spawn_spec(spec)
14832            .is_ok_and(|resolved| !provider_id_is_legacy(&resolved.provider));
14833    }
14834    request_requires_open_provider_ids_with(
14835        request,
14836        |session| shared.validate_address(session).ok(),
14837        |record_id| shared.record(record_id).ok().map(|record| record.provider),
14838    )
14839}
14840
14841fn request_requires_child_environment_profile(
14842    shared: &NodeShared,
14843    request: &NodeRequest,
14844) -> bool {
14845    if let Some(spec) = match request {
14846        NodeRequest::SpawnSpec { spec }
14847        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14848        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14849        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14850        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14851        _ => None,
14852    } {
14853        return shared
14854            .resolve_spawn_spec(spec)
14855            .is_ok_and(|resolved| resolved.environment_profile_id.is_some());
14856    }
14857    match request {
14858        NodeRequest::Resume { session, .. }
14859        | NodeRequest::Prompt { session, .. }
14860        | NodeRequest::Paste { session, .. }
14861        | NodeRequest::Input { session, .. }
14862        | NodeRequest::TerminalBytes { session, .. }
14863        | NodeRequest::TerminalControl { session, .. }
14864        | NodeRequest::Resize { session, .. }
14865        | NodeRequest::Interrupt { session }
14866        | NodeRequest::Stop { session, .. }
14867        | NodeRequest::Remove { session }
14868        | NodeRequest::ResolveInteraction { session, .. }
14869        | NodeRequest::SetSessionMode { session, .. }
14870        | NodeRequest::SetSessionConfigOption { session, .. }
14871        | NodeRequest::SetSessionModel { session, .. }
14872        | NodeRequest::DiscoverHistory { session, .. }
14873        | NodeRequest::LoadHistory { session, .. }
14874        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14875        | NodeRequest::ExportContextPack { session } => shared
14876            .session_bindings
14877            .lock()
14878            .unwrap_or_else(|poisoned| poisoned.into_inner())
14879            .get(&session.session.instance_id)
14880            .filter(|binding| {
14881                binding.workspace_id == session.workspace_id
14882                    && binding.generation == session.session.generation
14883            })
14884            .map_or(true, |binding| binding.environment_profile.is_some()),
14885        NodeRequest::RenameSessionRecord { record_id, .. }
14886        | NodeRequest::SetSessionTask { record_id, .. }
14887        | NodeRequest::ResumeSessionRecord { record_id, .. }
14888        | NodeRequest::ForgetSessionRecord { record_id } => shared
14889            .record(record_id)
14890            .map_or(true, |record| record.environment_profile.is_some()),
14891        NodeRequest::ArmHarnessMcpReservation { .. }
14892        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14893        | NodeRequest::ActivateHarnessMcpReservation { .. }
14894        | NodeRequest::AbortHarnessMcpReservation { .. }
14895        | NodeRequest::PutHarnessMcpReplyChunk { .. }
14896        | NodeRequest::RejectHarnessMcpCall { .. }
14897        | NodeRequest::Snapshot
14898        | NodeRequest::Resync { .. }
14899        | NodeRequest::BeginDeliveryStage { .. }
14900        | NodeRequest::PutDeliveryBlobChunk { .. }
14901        | NodeRequest::CommitDeliveryStage { .. }
14902        | NodeRequest::AbortDeliveryStage { .. }
14903        | NodeRequest::BrowseHostDirectories { .. }
14904        | NodeRequest::InspectWorkspace { .. }
14905        | NodeRequest::ReadWorkspaceFile { .. }
14906        | NodeRequest::WriteWorkspaceFile { .. }
14907        | NodeRequest::CreateWorkspaceFile { .. }
14908        | NodeRequest::CreateWorkspaceDirectory { .. }
14909        | NodeRequest::ReadGitHistory { .. }
14910        | NodeRequest::ReadGitDiff { .. }
14911        | NodeRequest::AcquireController { .. }
14912        | NodeRequest::ReleaseController
14913        | NodeRequest::Spawn { .. }
14914        | NodeRequest::SpawnWithOptions { .. }
14915        | NodeRequest::SpawnSpec { .. }
14916        | NodeRequest::RegisterWorkspace { .. }
14917        | NodeRequest::CreateStandaloneWorkspace { .. }
14918        | NodeRequest::UnregisterWorkspace { .. }
14919        | NodeRequest::CreateWorktree { .. }
14920        | NodeRequest::RemoveWorktree { .. }
14921        | NodeRequest::SpawnManagedWorktree { .. }
14922        | NodeRequest::SpawnManagedWorktreeV2 { .. }
14923        | NodeRequest::CleanupManagedWorktree { .. }
14924        | NodeRequest::IndexProviderSession { .. }
14925        | NodeRequest::IndexNativeSession { .. }
14926        | NodeRequest::CatalogNativeSessions { .. }
14927        | NodeRequest::PageNativeSessions { .. }
14928        | NodeRequest::PreviewNativeSession { .. }
14929        | NodeRequest::PreviewSessionRecord { .. }
14930        | NodeRequest::ForgetContextPack { .. }
14931        | NodeRequest::ResolveDurableContextPack { .. }
14932        | NodeRequest::ReadContextPack { .. }
14933        | NodeRequest::Shutdown => false,
14934    }
14935}
14936
14937fn request_requires_session_bundle(shared: &NodeShared, request: &NodeRequest) -> bool {
14938    if let Some(spec) = match request {
14939        NodeRequest::SpawnSpec { spec }
14940        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14941        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14942        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14943        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14944        _ => None,
14945    } {
14946        return shared
14947            .resolve_spawn_spec(spec)
14948            .is_ok_and(|resolved| resolved.bundle_id.is_some());
14949    }
14950    match request {
14951        NodeRequest::Resume { session, .. }
14952        | NodeRequest::Prompt { session, .. }
14953        | NodeRequest::Paste { session, .. }
14954        | NodeRequest::Input { session, .. }
14955        | NodeRequest::TerminalBytes { session, .. }
14956        | NodeRequest::TerminalControl { session, .. }
14957        | NodeRequest::Resize { session, .. }
14958        | NodeRequest::Interrupt { session }
14959        | NodeRequest::Stop { session, .. }
14960        | NodeRequest::Remove { session }
14961        | NodeRequest::ResolveInteraction { session, .. }
14962        | NodeRequest::SetSessionMode { session, .. }
14963        | NodeRequest::SetSessionConfigOption { session, .. }
14964        | NodeRequest::SetSessionModel { session, .. }
14965        | NodeRequest::DiscoverHistory { session, .. }
14966        | NodeRequest::LoadHistory { session, .. }
14967        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14968        | NodeRequest::ExportContextPack { session } => shared
14969            .session_bindings
14970            .lock()
14971            .unwrap_or_else(|poisoned| poisoned.into_inner())
14972            .get(&session.session.instance_id)
14973            .filter(|binding| {
14974                binding.workspace_id == session.workspace_id
14975                    && binding.generation == session.session.generation
14976            })
14977            .map_or(true, |binding| binding.bundle.is_some()),
14978        NodeRequest::RenameSessionRecord { record_id, .. }
14979        | NodeRequest::SetSessionTask { record_id, .. }
14980        | NodeRequest::ResumeSessionRecord { record_id, .. }
14981        | NodeRequest::ForgetSessionRecord { record_id } => shared
14982            .record(record_id)
14983            .map_or(true, |record| record.bundle.is_some()),
14984        NodeRequest::ArmHarnessMcpReservation { .. }
14985        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14986        | NodeRequest::ActivateHarnessMcpReservation { .. }
14987        | NodeRequest::AbortHarnessMcpReservation { .. }
14988        | NodeRequest::PutHarnessMcpReplyChunk { .. }
14989        | NodeRequest::RejectHarnessMcpCall { .. }
14990        | NodeRequest::Snapshot
14991        | NodeRequest::Resync { .. }
14992        | NodeRequest::BeginDeliveryStage { .. }
14993        | NodeRequest::PutDeliveryBlobChunk { .. }
14994        | NodeRequest::CommitDeliveryStage { .. }
14995        | NodeRequest::AbortDeliveryStage { .. }
14996        | NodeRequest::BrowseHostDirectories { .. }
14997        | NodeRequest::InspectWorkspace { .. }
14998        | NodeRequest::ReadWorkspaceFile { .. }
14999        | NodeRequest::WriteWorkspaceFile { .. }
15000        | NodeRequest::CreateWorkspaceFile { .. }
15001        | NodeRequest::CreateWorkspaceDirectory { .. }
15002        | NodeRequest::ReadGitHistory { .. }
15003        | NodeRequest::ReadGitDiff { .. }
15004        | NodeRequest::AcquireController { .. }
15005        | NodeRequest::ReleaseController
15006        | NodeRequest::Spawn { .. }
15007        | NodeRequest::SpawnWithOptions { .. }
15008        | NodeRequest::SpawnSpec { .. }
15009        | NodeRequest::RegisterWorkspace { .. }
15010        | NodeRequest::CreateStandaloneWorkspace { .. }
15011        | NodeRequest::UnregisterWorkspace { .. }
15012        | NodeRequest::CreateWorktree { .. }
15013        | NodeRequest::RemoveWorktree { .. }
15014        | NodeRequest::SpawnManagedWorktree { .. }
15015        | NodeRequest::SpawnManagedWorktreeV2 { .. }
15016        | NodeRequest::CleanupManagedWorktree { .. }
15017        | NodeRequest::IndexProviderSession { .. }
15018        | NodeRequest::IndexNativeSession { .. }
15019        | NodeRequest::CatalogNativeSessions { .. }
15020        | NodeRequest::PageNativeSessions { .. }
15021        | NodeRequest::PreviewNativeSession { .. }
15022        | NodeRequest::PreviewSessionRecord { .. }
15023        | NodeRequest::ForgetContextPack { .. }
15024        | NodeRequest::ResolveDurableContextPack { .. }
15025        | NodeRequest::ReadContextPack { .. }
15026        | NodeRequest::Shutdown => false,
15027    }
15028}
15029
15030fn request_requires_history_context_pack(shared: &NodeShared, request: &NodeRequest) -> bool {
15031    if request.requires_history_context_pack_capability() {
15032        return true;
15033    }
15034    if let Some(spec) = match request {
15035        NodeRequest::SpawnSpec { spec }
15036        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
15037        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
15038        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
15039        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
15040        _ => None,
15041    } {
15042        return shared
15043            .resolve_spawn_spec(spec)
15044            .map_or(true, |resolved| resolved.context_id.is_some());
15045    }
15046    match request {
15047        NodeRequest::Resume { session, .. }
15048        | NodeRequest::Prompt { session, .. }
15049        | NodeRequest::Paste { session, .. }
15050        | NodeRequest::Input { session, .. }
15051        | NodeRequest::TerminalBytes { session, .. }
15052        | NodeRequest::TerminalControl { session, .. }
15053        | NodeRequest::Resize { session, .. }
15054        | NodeRequest::Interrupt { session }
15055        | NodeRequest::Stop { session, .. }
15056        | NodeRequest::Remove { session }
15057        | NodeRequest::ResolveInteraction { session, .. }
15058        | NodeRequest::SetSessionMode { session, .. }
15059        | NodeRequest::SetSessionConfigOption { session, .. }
15060        | NodeRequest::SetSessionModel { session, .. } => shared
15061            .session_bindings
15062            .lock()
15063            .unwrap_or_else(|poisoned| poisoned.into_inner())
15064            .get(&session.session.instance_id)
15065            .filter(|binding| {
15066                binding.workspace_id == session.workspace_id
15067                    && binding.generation == session.session.generation
15068            })
15069            .map_or(true, |binding| binding.context.is_some()),
15070        NodeRequest::RenameSessionRecord { record_id, .. }
15071        | NodeRequest::SetSessionTask { record_id, .. }
15072        | NodeRequest::ResumeSessionRecord { record_id, .. }
15073        | NodeRequest::ForgetSessionRecord { record_id } => shared
15074            .record(record_id)
15075            .map_or(true, |record| record.context.is_some()),
15076        NodeRequest::ArmHarnessMcpReservation { .. }
15077        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
15078        | NodeRequest::ActivateHarnessMcpReservation { .. }
15079        | NodeRequest::AbortHarnessMcpReservation { .. }
15080        | NodeRequest::PutHarnessMcpReplyChunk { .. }
15081        | NodeRequest::RejectHarnessMcpCall { .. }
15082        | NodeRequest::Snapshot
15083        | NodeRequest::Resync { .. }
15084        | NodeRequest::BeginDeliveryStage { .. }
15085        | NodeRequest::PutDeliveryBlobChunk { .. }
15086        | NodeRequest::CommitDeliveryStage { .. }
15087        | NodeRequest::AbortDeliveryStage { .. }
15088        | NodeRequest::BrowseHostDirectories { .. }
15089        | NodeRequest::InspectWorkspace { .. }
15090        | NodeRequest::ReadWorkspaceFile { .. }
15091        | NodeRequest::WriteWorkspaceFile { .. }
15092        | NodeRequest::CreateWorkspaceFile { .. }
15093        | NodeRequest::CreateWorkspaceDirectory { .. }
15094        | NodeRequest::ReadGitHistory { .. }
15095        | NodeRequest::ReadGitDiff { .. }
15096        | NodeRequest::AcquireController { .. }
15097        | NodeRequest::ReleaseController
15098        | NodeRequest::Spawn { .. }
15099        | NodeRequest::SpawnWithOptions { .. }
15100        | NodeRequest::SpawnSpec { .. }
15101        | NodeRequest::RegisterWorkspace { .. }
15102        | NodeRequest::CreateStandaloneWorkspace { .. }
15103        | NodeRequest::UnregisterWorkspace { .. }
15104        | NodeRequest::CreateWorktree { .. }
15105        | NodeRequest::RemoveWorktree { .. }
15106        | NodeRequest::SpawnManagedWorktree { .. }
15107        | NodeRequest::SpawnManagedWorktreeV2 { .. }
15108        | NodeRequest::CleanupManagedWorktree { .. }
15109        | NodeRequest::IndexProviderSession { .. }
15110        | NodeRequest::IndexNativeSession { .. }
15111        | NodeRequest::CatalogNativeSessions { .. }
15112        | NodeRequest::PageNativeSessions { .. }
15113        | NodeRequest::PreviewNativeSession { .. }
15114        | NodeRequest::PreviewSessionRecord { .. }
15115        | NodeRequest::DiscoverHistory { .. }
15116        | NodeRequest::LoadHistory { .. }
15117        | NodeRequest::ExportContextPackForSessionRecord { .. }
15118        | NodeRequest::ExportContextPack { .. }
15119        | NodeRequest::ForgetContextPack { .. }
15120        | NodeRequest::ResolveDurableContextPack { .. }
15121        | NodeRequest::ReadContextPack { .. }
15122        | NodeRequest::Shutdown => false,
15123    }
15124}
15125
15126fn request_requires_open_provider_ids_with(
15127    request: &NodeRequest,
15128    provider_for_session: impl Fn(&SessionAddress) -> Option<AgentId>,
15129    provider_for_record: impl Fn(&SessionRecordId) -> Option<AgentId>,
15130) -> bool {
15131    match request {
15132        NodeRequest::Spawn { provider, .. } | NodeRequest::SpawnWithOptions { provider, .. } => !provider_id_is_legacy(provider),
15133        NodeRequest::IndexProviderSession { provider, .. } => !provider_id_is_legacy(provider),
15134        NodeRequest::CatalogNativeSessions { route, .. }
15135        | NodeRequest::PageNativeSessions { route, .. } => {
15136            !provider_id_is_legacy(&route.provider)
15137        }
15138        NodeRequest::PreviewNativeSession { selection, .. }
15139        | NodeRequest::IndexNativeSession { selection, .. } => {
15140            !provider_id_is_legacy(&selection.route.provider)
15141        }
15142        NodeRequest::SpawnSpec { .. }
15143        | NodeRequest::SpawnSpecWithHarnessMcp { .. } => false,
15144        NodeRequest::SpawnManagedWorktree { .. }
15145        | NodeRequest::SpawnManagedWorktreeV2 { .. } => false,
15146        NodeRequest::Resume { session, .. }
15147        | NodeRequest::Prompt { session, .. }
15148        | NodeRequest::Paste { session, .. }
15149        | NodeRequest::Input { session, .. }
15150        | NodeRequest::TerminalBytes { session, .. }
15151        | NodeRequest::TerminalControl { session, .. }
15152        | NodeRequest::Resize { session, .. }
15153        | NodeRequest::Interrupt { session }
15154        | NodeRequest::Stop { session, .. }
15155        | NodeRequest::Remove { session }
15156        | NodeRequest::ResolveInteraction { session, .. }
15157        | NodeRequest::SetSessionMode { session, .. }
15158        | NodeRequest::SetSessionConfigOption { session, .. }
15159        | NodeRequest::SetSessionModel { session, .. }
15160        | NodeRequest::DiscoverHistory { session, .. }
15161        | NodeRequest::LoadHistory { session, .. }
15162        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
15163        | NodeRequest::ExportContextPack { session } => provider_for_session(session)
15164            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
15165        NodeRequest::RenameSessionRecord { record_id, .. }
15166        | NodeRequest::SetSessionTask { record_id, .. }
15167        | NodeRequest::ResumeSessionRecord { record_id, .. }
15168        | NodeRequest::ForgetSessionRecord { record_id } => provider_for_record(record_id)
15169            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
15170        NodeRequest::PreviewSessionRecord { record_id, .. } => provider_for_record(record_id)
15171            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
15172        NodeRequest::ArmHarnessMcpReservation { .. }
15173        | NodeRequest::ActivateHarnessMcpReservation { .. }
15174        | NodeRequest::AbortHarnessMcpReservation { .. }
15175        | NodeRequest::PutHarnessMcpReplyChunk { .. }
15176        | NodeRequest::RejectHarnessMcpCall { .. }
15177        | NodeRequest::Snapshot
15178        | NodeRequest::Resync { .. }
15179        | NodeRequest::BeginDeliveryStage { .. }
15180        | NodeRequest::PutDeliveryBlobChunk { .. }
15181        | NodeRequest::CommitDeliveryStage { .. }
15182        | NodeRequest::AbortDeliveryStage { .. }
15183        | NodeRequest::BrowseHostDirectories { .. }
15184        | NodeRequest::InspectWorkspace { .. }
15185        | NodeRequest::ReadWorkspaceFile { .. }
15186        | NodeRequest::WriteWorkspaceFile { .. }
15187        | NodeRequest::CreateWorkspaceFile { .. }
15188        | NodeRequest::CreateWorkspaceDirectory { .. }
15189        | NodeRequest::ReadGitHistory { .. }
15190        | NodeRequest::ReadGitDiff { .. }
15191        | NodeRequest::AcquireController { .. }
15192        | NodeRequest::ReleaseController
15193        | NodeRequest::RegisterWorkspace { .. }
15194        | NodeRequest::CreateStandaloneWorkspace { .. }
15195        | NodeRequest::UnregisterWorkspace { .. }
15196        | NodeRequest::CreateWorktree { .. }
15197        | NodeRequest::RemoveWorktree { .. }
15198        | NodeRequest::CleanupManagedWorktree { .. }
15199        | NodeRequest::Shutdown => false,
15200        NodeRequest::ForgetContextPack { .. } => true,
15201        // No session/record to cheaply resolve a provider from here either;
15202        // the resolved receipt can carry an open-id provider, so require the
15203        // same capability as ForgetContextPack unconditionally. `ReadContextPack`
15204        // is the same digest-keyed lookup one step further into the same
15205        // catalog, so it inherits the same conservative answer.
15206        NodeRequest::ResolveDurableContextPack { .. }
15207        | NodeRequest::ReadContextPack { .. } => true,
15208    }
15209}
15210
15211fn project_snapshot_legacy_provider_ids(snapshot: &mut NodeSnapshot) {
15212    snapshot.enabled_providers.retain(provider_id_is_legacy);
15213    snapshot.provider_runtime_statuses = ProviderRuntimeStatuses::new(
15214        snapshot
15215            .provider_runtime_statuses
15216            .as_slice()
15217            .iter()
15218            .filter(|status| provider_id_is_legacy(status.provider()))
15219            .cloned(),
15220    )
15221    .expect("a filtered provider runtime projection remains valid");
15222    snapshot
15223        .session_records
15224        .retain(|record| provider_id_is_legacy(&record.provider));
15225    for workspace in &mut snapshot.workspaces {
15226        workspace
15227            .sessions
15228            .retain(|session| provider_id_is_legacy(&session.agent_id));
15229    }
15230}
15231
15232fn clear_snapshot_child_environment_profiles(snapshot: &mut NodeSnapshot) {
15233    for record in &mut snapshot.session_records {
15234        record.environment_profile = None;
15235    }
15236    if let Some(profiles) = snapshot
15237        .launch_inventory
15238        .as_mut()
15239        .and_then(|inventory| inventory.spawn_profiles.as_mut())
15240    {
15241        for profile in profiles {
15242            profile.environment_profile = None;
15243        }
15244    }
15245}
15246
15247fn project_event_without_child_environment_profile(
15248    mut envelope: NodeEventEnvelope,
15249) -> NodeEventEnvelope {
15250    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15251        record.environment_profile = None;
15252    }
15253    envelope
15254}
15255
15256fn project_response_without_child_environment_profile(reply: &mut ResponseEnvelope) {
15257    let contains_environment_profile = match reply.result.as_ref() {
15258        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
15259            receipt.environment_profile.is_some()
15260        }
15261        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15262            receipt.spawn.environment_profile.is_some()
15263        }
15264        _ => false,
15265    };
15266    if contains_environment_profile {
15267        reply.result = Err(failure(
15268            NodeFailureCode::UnsupportedCapability,
15269            "child environment profiles were not negotiated",
15270        ));
15271        return;
15272    }
15273    let Ok(response) = reply.result.as_mut() else {
15274        return;
15275    };
15276    match response {
15277        NodeResponse::Snapshot { snapshot, .. } => {
15278            clear_snapshot_child_environment_profiles(snapshot);
15279        }
15280        NodeResponse::Resync { snapshot, events, .. } => {
15281            clear_snapshot_child_environment_profiles(snapshot);
15282            for event in events {
15283                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15284                    record.environment_profile = None;
15285                }
15286            }
15287        }
15288        NodeResponse::SessionRecordUpdated { record }
15289        | NodeResponse::ProviderSessionIndexed { record }
15290        | NodeResponse::NativeSessionIndexed { record, .. }
15291        | NodeResponse::SessionRecordResumed { record, .. } => {
15292            record.environment_profile = None;
15293        }
15294        NodeResponse::Armed { .. }
15295        | NodeResponse::Spawned { .. }
15296        | NodeResponse::Activated { .. }
15297        | NodeResponse::Aborted { .. }
15298        | NodeResponse::ReplyChunkAccepted { .. }
15299        | NodeResponse::CallRejected { .. }
15300        | NodeResponse::DeliveryStageBegun { .. }
15301        | NodeResponse::DeliveryBlobChunkAccepted { .. }
15302        | NodeResponse::DeliveryCommitted { .. }
15303        | NodeResponse::DeliveryStageAborted { .. }
15304        | NodeResponse::WorkspaceInspected { .. }
15305        | NodeResponse::HostDirectoriesBrowsed { .. }
15306        | NodeResponse::WorkspaceFileRead { .. }
15307        | NodeResponse::WorkspaceFileWritten { .. }
15308        | NodeResponse::WorkspaceFileCreated { .. }
15309        | NodeResponse::WorkspaceDirectoryCreated { .. }
15310        | NodeResponse::GitHistoryRead { .. }
15311        | NodeResponse::GitDiffRead { .. }
15312        | NodeResponse::Controller { .. }
15313        | NodeResponse::SpawnAccepted { .. }
15314        | NodeResponse::SpawnSpecAccepted { .. }
15315        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15316        | NodeResponse::ManagedWorktreeCleanup { .. }
15317        | NodeResponse::NativeSessionsCataloged { .. }
15318        | NodeResponse::NativeSessionsPaged { .. }
15319        | NodeResponse::NativeSessionPreviewed { .. }
15320        | NodeResponse::SessionRecordPreviewed { .. }
15321        | NodeResponse::HistoryDiscovered { .. }
15322        | NodeResponse::HistoryLoaded { .. }
15323        | NodeResponse::ContextPackForSessionRecordExported { .. }
15324        | NodeResponse::ContextPackExported { .. }
15325        | NodeResponse::ContextPackForgotten { .. }
15326        | NodeResponse::DurableContextPackResolved { .. }
15327        | NodeResponse::ContextPackBytesRead { .. }
15328        | NodeResponse::SessionRecordForgotten { .. }
15329        | NodeResponse::WorkspaceRegistered { .. }
15330        | NodeResponse::StandaloneWorkspaceCreated { .. }
15331        | NodeResponse::WorkspaceUnregistered { .. }
15332        | NodeResponse::WorktreeCreated { .. }
15333        | NodeResponse::WorktreeRemoved { .. }
15334        | NodeResponse::Accepted
15335        | NodeResponse::ShuttingDown => {}
15336    }
15337}
15338
15339fn clear_snapshot_session_bundles(snapshot: &mut NodeSnapshot) {
15340    for record in &mut snapshot.session_records {
15341        record.bundle = None;
15342    }
15343}
15344
15345fn project_event_without_session_bundle(
15346    mut envelope: NodeEventEnvelope,
15347) -> NodeEventEnvelope {
15348    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15349        record.bundle = None;
15350    }
15351    envelope
15352}
15353
15354fn project_response_without_session_bundle(reply: &mut ResponseEnvelope) {
15355    let contains_bundle = match reply.result.as_ref() {
15356        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.bundle.is_some(),
15357        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15358            receipt.spawn.bundle.is_some()
15359        }
15360        _ => false,
15361    };
15362    if contains_bundle {
15363        reply.result = Err(failure(
15364            NodeFailureCode::UnsupportedCapability,
15365            "session bundles were not negotiated",
15366        ));
15367        return;
15368    }
15369    let Ok(response) = reply.result.as_mut() else {
15370        return;
15371    };
15372    match response {
15373        NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_session_bundles(snapshot),
15374        NodeResponse::Resync { snapshot, events, .. } => {
15375            clear_snapshot_session_bundles(snapshot);
15376            for event in events {
15377                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15378                    record.bundle = None;
15379                }
15380            }
15381        }
15382        NodeResponse::SessionRecordUpdated { record }
15383        | NodeResponse::ProviderSessionIndexed { record }
15384        | NodeResponse::NativeSessionIndexed { record, .. }
15385        | NodeResponse::SessionRecordResumed { record, .. } => record.bundle = None,
15386        NodeResponse::Armed { .. }
15387        | NodeResponse::Spawned { .. }
15388        | NodeResponse::Activated { .. }
15389        | NodeResponse::Aborted { .. }
15390        | NodeResponse::ReplyChunkAccepted { .. }
15391        | NodeResponse::CallRejected { .. }
15392        | NodeResponse::DeliveryStageBegun { .. }
15393        | NodeResponse::DeliveryBlobChunkAccepted { .. }
15394        | NodeResponse::DeliveryCommitted { .. }
15395        | NodeResponse::DeliveryStageAborted { .. }
15396        | NodeResponse::WorkspaceInspected { .. }
15397        | NodeResponse::HostDirectoriesBrowsed { .. }
15398        | NodeResponse::WorkspaceFileRead { .. }
15399        | NodeResponse::WorkspaceFileWritten { .. }
15400        | NodeResponse::WorkspaceFileCreated { .. }
15401        | NodeResponse::WorkspaceDirectoryCreated { .. }
15402        | NodeResponse::GitHistoryRead { .. }
15403        | NodeResponse::GitDiffRead { .. }
15404        | NodeResponse::Controller { .. }
15405        | NodeResponse::SpawnAccepted { .. }
15406        | NodeResponse::SpawnSpecAccepted { .. }
15407        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15408        | NodeResponse::ManagedWorktreeCleanup { .. }
15409        | NodeResponse::NativeSessionsCataloged { .. }
15410        | NodeResponse::NativeSessionsPaged { .. }
15411        | NodeResponse::NativeSessionPreviewed { .. }
15412        | NodeResponse::SessionRecordPreviewed { .. }
15413        | NodeResponse::HistoryDiscovered { .. }
15414        | NodeResponse::HistoryLoaded { .. }
15415        | NodeResponse::ContextPackForSessionRecordExported { .. }
15416        | NodeResponse::ContextPackExported { .. }
15417        | NodeResponse::ContextPackForgotten { .. }
15418        | NodeResponse::DurableContextPackResolved { .. }
15419        | NodeResponse::ContextPackBytesRead { .. }
15420        | NodeResponse::SessionRecordForgotten { .. }
15421        | NodeResponse::WorkspaceRegistered { .. }
15422        | NodeResponse::StandaloneWorkspaceCreated { .. }
15423        | NodeResponse::WorkspaceUnregistered { .. }
15424        | NodeResponse::WorktreeCreated { .. }
15425        | NodeResponse::WorktreeRemoved { .. }
15426        | NodeResponse::Accepted
15427        | NodeResponse::ShuttingDown => {}
15428    }
15429}
15430
15431fn project_snapshot_history_for_wire(
15432    snapshot: &mut NodeSnapshot,
15433    include_history_metadata: bool,
15434) {
15435    for workspace in &mut snapshot.workspaces {
15436        for session in &mut workspace.sessions {
15437            if include_history_metadata {
15438                session.history.loaded = None;
15439            } else {
15440                session.history = Default::default();
15441            }
15442        }
15443    }
15444}
15445
15446fn project_event_history_for_wire(
15447    mut envelope: NodeEventEnvelope,
15448    include_history_metadata: bool,
15449) -> NodeEventEnvelope {
15450    if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
15451        for session in &mut workspace.sessions {
15452            if include_history_metadata {
15453                session.history.loaded = None;
15454            } else {
15455                session.history = Default::default();
15456            }
15457        }
15458    }
15459    envelope
15460}
15461
15462fn project_response_history_for_wire(
15463    reply: &mut ResponseEnvelope,
15464    include_history_metadata: bool,
15465) {
15466    let Ok(response) = reply.result.as_mut() else {
15467        return;
15468    };
15469    match response {
15470        NodeResponse::Snapshot { snapshot, .. } => {
15471            project_snapshot_history_for_wire(snapshot, include_history_metadata);
15472        }
15473        NodeResponse::Resync {
15474            snapshot, events, ..
15475        } => {
15476            project_snapshot_history_for_wire(snapshot, include_history_metadata);
15477            for event in events {
15478                *event = project_event_history_for_wire(
15479                    event.clone(),
15480                    include_history_metadata,
15481                );
15482            }
15483        }
15484        NodeResponse::WorkspaceRegistered { workspace }
15485        | NodeResponse::StandaloneWorkspaceCreated { workspace }
15486        | NodeResponse::WorktreeCreated { workspace, .. } => {
15487            for session in &mut workspace.sessions {
15488                if include_history_metadata {
15489                    session.history.loaded = None;
15490                } else {
15491                    session.history = Default::default();
15492                }
15493            }
15494        }
15495        _ => {}
15496    }
15497}
15498
15499fn clear_snapshot_context_packs(snapshot: &mut NodeSnapshot) {
15500    for record in &mut snapshot.session_records {
15501        record.context_id = None;
15502        record.context = None;
15503        record.exported_context = None;
15504    }
15505}
15506
15507fn project_event_without_context_pack(
15508    mut envelope: NodeEventEnvelope,
15509) -> NodeEventEnvelope {
15510    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15511        record.context_id = None;
15512        record.context = None;
15513        record.exported_context = None;
15514    }
15515    envelope
15516}
15517
15518fn project_response_without_context_pack(reply: &mut ResponseEnvelope) {
15519    let contains_context = match reply.result.as_ref() {
15520        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.context.is_some(),
15521        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15522            receipt.spawn.context.is_some()
15523        }
15524        Ok(NodeResponse::HistoryDiscovered { .. })
15525        | Ok(NodeResponse::HistoryLoaded { .. })
15526        | Ok(NodeResponse::ContextPackForSessionRecordExported { .. })
15527        | Ok(NodeResponse::ContextPackExported { .. })
15528        | Ok(NodeResponse::ContextPackForgotten { .. })
15529        | Ok(NodeResponse::DurableContextPackResolved { .. }) => true,
15530        _ => false,
15531    };
15532    if contains_context {
15533        reply.result = Err(failure(
15534            NodeFailureCode::UnsupportedCapability,
15535            "history context packs were not negotiated",
15536        ));
15537        return;
15538    }
15539    let Ok(response) = reply.result.as_mut() else {
15540        return;
15541    };
15542    match response {
15543        NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_context_packs(snapshot),
15544        NodeResponse::Resync {
15545            snapshot, events, ..
15546        } => {
15547            clear_snapshot_context_packs(snapshot);
15548            for event in events {
15549                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15550                    record.context_id = None;
15551                    record.context = None;
15552                    record.exported_context = None;
15553                }
15554            }
15555        }
15556        NodeResponse::SessionRecordUpdated { record }
15557        | NodeResponse::ProviderSessionIndexed { record }
15558        | NodeResponse::NativeSessionIndexed { record, .. }
15559        | NodeResponse::SessionRecordResumed { record, .. } => {
15560            record.context_id = None;
15561            record.context = None;
15562            record.exported_context = None;
15563        }
15564        _ => {}
15565    }
15566}
15567
15568fn project_response_legacy_provider_ids(shared: &NodeShared, reply: &mut ResponseEnvelope) {
15569    let contains_open_record = match reply.result.as_ref() {
15570        Ok(NodeResponse::SessionRecordUpdated { record })
15571        | Ok(NodeResponse::ProviderSessionIndexed { record })
15572        | Ok(NodeResponse::SessionRecordResumed { record, .. }) => {
15573            !provider_id_is_legacy(&record.provider)
15574        }
15575        Ok(NodeResponse::NativeSessionIndexed { selection, record }) => {
15576            !provider_id_is_legacy(&selection.route.provider)
15577                || !provider_id_is_legacy(&record.provider)
15578        }
15579        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
15580            !provider_id_is_legacy(&receipt.provider)
15581        }
15582        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15583            !provider_id_is_legacy(&receipt.spawn.provider)
15584        }
15585        Ok(NodeResponse::NativeSessionsCataloged { route, .. })
15586        | Ok(NodeResponse::NativeSessionsPaged { route, .. }) => {
15587            !provider_id_is_legacy(&route.provider)
15588        }
15589        Ok(NodeResponse::NativeSessionPreviewed { selection, .. }) => {
15590            !provider_id_is_legacy(&selection.route.provider)
15591        }
15592        _ => false,
15593    };
15594    if contains_open_record {
15595        reply.result = Err(failure(
15596            NodeFailureCode::UnsupportedCapability,
15597            "open provider IDs were not negotiated",
15598        ));
15599        return;
15600    }
15601    let Ok(response) = reply.result.as_mut() else {
15602        return;
15603    };
15604    match response {
15605        NodeResponse::Snapshot { snapshot, .. } => {
15606            project_snapshot_legacy_provider_ids(snapshot);
15607        }
15608        NodeResponse::Resync { snapshot, events, .. } => {
15609            project_snapshot_legacy_provider_ids(snapshot);
15610            *events = std::mem::take(events)
15611                .into_iter()
15612                .filter_map(|event| project_event_legacy_provider_ids(shared, event))
15613                .collect();
15614        }
15615        NodeResponse::WorkspaceRegistered { workspace }
15616        | NodeResponse::StandaloneWorkspaceCreated { workspace }
15617        | NodeResponse::WorktreeCreated { workspace, .. } => {
15618            workspace
15619                .sessions
15620                .retain(|session| provider_id_is_legacy(&session.agent_id));
15621        }
15622        NodeResponse::Armed { .. }
15623        | NodeResponse::Spawned { .. }
15624        | NodeResponse::Activated { .. }
15625        | NodeResponse::Aborted { .. }
15626        | NodeResponse::ReplyChunkAccepted { .. }
15627        | NodeResponse::CallRejected { .. }
15628        | NodeResponse::DeliveryStageBegun { .. }
15629        | NodeResponse::DeliveryBlobChunkAccepted { .. }
15630        | NodeResponse::DeliveryCommitted { .. }
15631        | NodeResponse::DeliveryStageAborted { .. }
15632        | NodeResponse::WorkspaceInspected { .. }
15633        | NodeResponse::HostDirectoriesBrowsed { .. }
15634        | NodeResponse::WorkspaceFileRead { .. }
15635        | NodeResponse::WorkspaceFileWritten { .. }
15636        | NodeResponse::WorkspaceFileCreated { .. }
15637        | NodeResponse::WorkspaceDirectoryCreated { .. }
15638        | NodeResponse::GitHistoryRead { .. }
15639        | NodeResponse::GitDiffRead { .. }
15640        | NodeResponse::Controller { .. }
15641        | NodeResponse::SpawnAccepted { .. }
15642        | NodeResponse::SpawnSpecAccepted { .. }
15643        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15644        | NodeResponse::ManagedWorktreeCleanup { .. }
15645        | NodeResponse::NativeSessionsCataloged { .. }
15646        | NodeResponse::NativeSessionsPaged { .. }
15647        | NodeResponse::NativeSessionPreviewed { .. }
15648        | NodeResponse::SessionRecordPreviewed { .. }
15649        | NodeResponse::HistoryDiscovered { .. }
15650        | NodeResponse::HistoryLoaded { .. }
15651        | NodeResponse::ContextPackForSessionRecordExported { .. }
15652        | NodeResponse::ContextPackExported { .. }
15653        | NodeResponse::ContextPackForgotten { .. }
15654        | NodeResponse::DurableContextPackResolved { .. }
15655        | NodeResponse::ContextPackBytesRead { .. }
15656        | NodeResponse::SessionRecordUpdated { .. }
15657        | NodeResponse::ProviderSessionIndexed { .. }
15658        | NodeResponse::NativeSessionIndexed { .. }
15659        | NodeResponse::SessionRecordResumed { .. }
15660        | NodeResponse::SessionRecordForgotten { .. }
15661        | NodeResponse::WorkspaceUnregistered { .. }
15662        | NodeResponse::WorktreeRemoved { .. }
15663        | NodeResponse::Accepted
15664        | NodeResponse::ShuttingDown => {}
15665    }
15666}
15667
15668fn project_event_legacy_provider_ids(
15669    shared: &NodeShared,
15670    mut envelope: NodeEventEnvelope,
15671) -> Option<NodeEventEnvelope> {
15672    let include = match &mut envelope.event {
15673        NodeEvent::HarnessMcpReadCall { .. } => true,
15674        NodeEvent::Control { address, .. }
15675        | NodeEvent::AgentStream { address, .. }
15676        | NodeEvent::TerminalFrame { address, .. } => shared
15677            .validate_address(address)
15678            .is_ok_and(|provider| provider_id_is_legacy(&provider)),
15679        NodeEvent::WorkspaceAdded { workspace } => {
15680            workspace
15681                .sessions
15682                .retain(|session| provider_id_is_legacy(&session.agent_id));
15683            true
15684        }
15685        NodeEvent::SessionRecordUpserted { record } => {
15686            provider_id_is_legacy(&record.provider)
15687        }
15688        NodeEvent::SessionRecordHistorySummarized { .. } => false,
15689        NodeEvent::SessionRecordRemoved { .. } => shared
15690            .history
15691            .lock()
15692            .unwrap_or_else(|poisoned| poisoned.into_inner())
15693            .removed_record_providers
15694            .get(&envelope.sequence)
15695            .is_some_and(provider_id_is_legacy),
15696        NodeEvent::ControllerChanged { .. }
15697        | NodeEvent::ManagedWorktreeUpserted { .. }
15698        | NodeEvent::ManagedWorktreeRemoved { .. }
15699        | NodeEvent::WorkspaceRemoved { .. }
15700        | NodeEvent::ResyncRequired { .. } => true,
15701    };
15702    include.then_some(envelope)
15703}
15704
15705fn clear_response_provider_runtime_status(reply: &mut ResponseEnvelope) {
15706    let Ok(response) = reply.result.as_mut() else {
15707        return;
15708    };
15709    match response {
15710        NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
15711            snapshot.provider_runtime_statuses.clear();
15712        }
15713        NodeResponse::Armed { .. }
15714        | NodeResponse::Spawned { .. }
15715        | NodeResponse::Activated { .. }
15716        | NodeResponse::Aborted { .. }
15717        | NodeResponse::ReplyChunkAccepted { .. }
15718        | NodeResponse::CallRejected { .. }
15719        | NodeResponse::DeliveryStageBegun { .. }
15720        | NodeResponse::DeliveryBlobChunkAccepted { .. }
15721        | NodeResponse::DeliveryCommitted { .. }
15722        | NodeResponse::DeliveryStageAborted { .. }
15723        | NodeResponse::WorkspaceInspected { .. }
15724        | NodeResponse::HostDirectoriesBrowsed { .. }
15725        | NodeResponse::WorkspaceFileRead { .. }
15726        | NodeResponse::WorkspaceFileWritten { .. }
15727        | NodeResponse::WorkspaceFileCreated { .. }
15728        | NodeResponse::WorkspaceDirectoryCreated { .. }
15729        | NodeResponse::GitHistoryRead { .. }
15730        | NodeResponse::GitDiffRead { .. }
15731        | NodeResponse::Controller { .. }
15732        | NodeResponse::SpawnAccepted { .. }
15733        | NodeResponse::SpawnSpecAccepted { .. }
15734        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15735        | NodeResponse::ManagedWorktreeCleanup { .. }
15736        | NodeResponse::NativeSessionsCataloged { .. }
15737        | NodeResponse::NativeSessionsPaged { .. }
15738        | NodeResponse::NativeSessionPreviewed { .. }
15739        | NodeResponse::SessionRecordPreviewed { .. }
15740        | NodeResponse::HistoryDiscovered { .. }
15741        | NodeResponse::HistoryLoaded { .. }
15742        | NodeResponse::ContextPackForSessionRecordExported { .. }
15743        | NodeResponse::ContextPackExported { .. }
15744        | NodeResponse::ContextPackForgotten { .. }
15745        | NodeResponse::DurableContextPackResolved { .. }
15746        | NodeResponse::ContextPackBytesRead { .. }
15747        | NodeResponse::SessionRecordUpdated { .. }
15748        | NodeResponse::ProviderSessionIndexed { .. }
15749        | NodeResponse::NativeSessionIndexed { .. }
15750        | NodeResponse::SessionRecordResumed { .. }
15751        | NodeResponse::SessionRecordForgotten { .. }
15752        | NodeResponse::WorkspaceRegistered { .. }
15753        | NodeResponse::StandaloneWorkspaceCreated { .. }
15754        | NodeResponse::WorkspaceUnregistered { .. }
15755        | NodeResponse::WorktreeCreated { .. }
15756        | NodeResponse::WorktreeRemoved { .. }
15757        | NodeResponse::Accepted
15758        | NodeResponse::ShuttingDown => {}
15759    }
15760}
15761
15762struct ControllerReleaseGuard {
15763    shared: Arc<NodeShared>,
15764    connection_id: u64,
15765}
15766
15767impl Drop for ControllerReleaseGuard {
15768    fn drop(&mut self) {
15769        self.shared.release_controller(self.connection_id);
15770    }
15771}
15772
15773struct AbortTaskOnDrop(AbortHandle);
15774
15775impl Drop for AbortTaskOnDrop {
15776    fn drop(&mut self) {
15777        self.0.abort();
15778    }
15779}
15780
15781/// Reads an unsigned env-config override, following the pattern of every
15782/// other bounded knob in this module: missing/unparsable/zero falls back to
15783/// `default`, and the resolved value is always clamped to `max` so a bad
15784/// override cannot reopen a bound this budget exists to enforce.
15785fn env_config_bounded(name: &str, default: u64, max: u64) -> u64 {
15786    std::env::var(name)
15787        .ok()
15788        .and_then(|value| value.parse::<u64>().ok())
15789        .filter(|value| *value > 0)
15790        .unwrap_or(default)
15791        .min(max)
15792}
15793
15794fn workspace_inspection_time_budget_ms() -> u64 {
15795    env_config_bounded(
15796        "GATE4AGENT_NODE_WORKSPACE_INSPECTION_BUDGET_MS",
15797        WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT,
15798        WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX,
15799    )
15800}
15801
15802fn workspace_inspection_entry_cap() -> usize {
15803    env_config_bounded(
15804        "GATE4AGENT_NODE_WORKSPACE_INSPECTION_ENTRY_CAP",
15805        WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT as u64,
15806        WORKSPACE_INSPECTION_ENTRY_CAP_MAX as u64,
15807    ) as usize
15808}
15809
15810/// Tracks the shared walk-side budget across the whole recursive walk: a
15811/// wall-clock deadline (shared with the git phase that runs after the walk)
15812/// and a cap on directory entries visited, whether or not they were pushed
15813/// to the response. Cheap to check per entry — one `Instant::now()` and one
15814/// integer compare.
15815struct WorkspaceWalkBudget {
15816    deadline: Instant,
15817    entry_cap: usize,
15818    entries_visited: u64,
15819    time_budget_exceeded: bool,
15820    entry_cap_exceeded: bool,
15821}
15822
15823impl WorkspaceWalkBudget {
15824    fn new(deadline: Instant, entry_cap: usize) -> Self {
15825        Self {
15826            deadline,
15827            entry_cap,
15828            entries_visited: 0,
15829            time_budget_exceeded: false,
15830            entry_cap_exceeded: false,
15831        }
15832    }
15833
15834    fn is_exhausted(&self) -> bool {
15835        self.time_budget_exceeded || self.entry_cap_exceeded
15836    }
15837
15838    /// Records one visited directory entry (pushed to the response or not)
15839    /// and reports whether the walk must stop now.
15840    fn record_visit(&mut self) -> bool {
15841        self.entries_visited += 1;
15842        if self.entries_visited as usize >= self.entry_cap {
15843            self.entry_cap_exceeded = true;
15844        }
15845        if Instant::now() >= self.deadline {
15846            self.time_budget_exceeded = true;
15847        }
15848        self.is_exhausted()
15849    }
15850}
15851
15852fn collect_workspace_entries(
15853    root: &Path,
15854    deadline: Instant,
15855    entry_cap: usize,
15856) -> (Vec<WorkspaceEntry>, bool, WorkspaceWalkBudget) {
15857    let mut entries = Vec::new();
15858    let mut truncated = false;
15859    let mut budget = WorkspaceWalkBudget::new(deadline, entry_cap);
15860    walk_workspace_directory(root, None, 0, &mut entries, &mut truncated, &mut budget);
15861    // DFS pre-order emits `x/child` between prefix-named siblings `x` and
15862    // `x-y` ('-' sorts below '/'), while the bounded operator projections
15863    // require strict global byte order over relative paths.
15864    entries.sort_by(|left, right| left.relative_path.as_utf8().cmp(&right.relative_path.as_utf8()));
15865    (entries, truncated, budget)
15866}
15867
15868/// Builds the additive `WorkspaceInspectionTruncationV1` for an
15869/// `inspect_workspace` response — `None` once the walk and git phase both
15870/// completed inside their shared budget.
15871fn workspace_inspection_truncation(
15872    walk_budget: &WorkspaceWalkBudget,
15873    git_time_budget_exceeded: bool,
15874    elapsed: Duration,
15875) -> Option<WorkspaceInspectionTruncationV1> {
15876    if !walk_budget.time_budget_exceeded
15877        && !walk_budget.entry_cap_exceeded
15878        && !git_time_budget_exceeded
15879    {
15880        return None;
15881    }
15882    Some(WorkspaceInspectionTruncationV1 {
15883        walk_time_budget_exceeded: walk_budget.time_budget_exceeded,
15884        walk_entry_cap_exceeded: walk_budget.entry_cap_exceeded,
15885        git_time_budget_exceeded,
15886        entries_visited: walk_budget.entries_visited,
15887        elapsed_ms: u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX),
15888    })
15889}
15890
15891fn walk_workspace_directory(
15892    directory: &Path,
15893    relative_directory: Option<&RepositoryPath>,
15894    depth: usize,
15895    entries: &mut Vec<WorkspaceEntry>,
15896    truncated: &mut bool,
15897    budget: &mut WorkspaceWalkBudget,
15898) {
15899    if budget.is_exhausted() {
15900        *truncated = true;
15901        return;
15902    }
15903    let read_dir = match std::fs::read_dir(directory) {
15904        Ok(read_dir) => read_dir,
15905        Err(_) => {
15906            *truncated = true;
15907            return;
15908        }
15909    };
15910    let mut children = Vec::new();
15911    for child in read_dir {
15912        match child {
15913            Ok(child) => children.push(child),
15914            Err(_) => *truncated = true,
15915        }
15916    }
15917    children.sort_by_key(|child| child.file_name());
15918    for child in children {
15919        if budget.record_visit() {
15920            *truncated = true;
15921            return;
15922        }
15923        if entries.len() >= WORKSPACE_TREE_MAX_ENTRIES {
15924            *truncated = true;
15925            return;
15926        }
15927        let file_type = match child.file_type() {
15928            Ok(file_type) => file_type,
15929            Err(_) => {
15930                *truncated = true;
15931                continue;
15932            }
15933        };
15934        if file_type.is_symlink() {
15935            continue;
15936        }
15937        let name = match child.file_name().into_string() {
15938            Ok(name) => name,
15939            Err(_) => {
15940                *truncated = true;
15941                continue;
15942            }
15943        };
15944        if file_type.is_dir() && is_skipped_workspace_directory(&name, &child.path()) {
15945            continue;
15946        }
15947        let relative_path = match relative_directory {
15948            None => windows_repository_path(name),
15949            Some(parent) => parent
15950                .as_utf8()
15951                .and_then(|parent| windows_repository_path(format!("{parent}/{name}"))),
15952        };
15953        let Some(relative_path) = relative_path else {
15954            *truncated = true;
15955            continue;
15956        };
15957        let kind = if file_type.is_dir() {
15958            WorkspaceEntryKind::Directory
15959        } else if file_type.is_file() {
15960            WorkspaceEntryKind::File
15961        } else {
15962            continue;
15963        };
15964        entries.push(WorkspaceEntry {
15965            relative_path: relative_path.clone(),
15966            kind,
15967        });
15968        if file_type.is_dir() {
15969            if depth + 1 < WORKSPACE_TREE_MAX_DEPTH {
15970                walk_workspace_directory(
15971                    &child.path(),
15972                    Some(&relative_path),
15973                    depth + 1,
15974                    entries,
15975                    truncated,
15976                    budget,
15977                );
15978            } else {
15979                *truncated = true;
15980            }
15981        }
15982    }
15983}
15984
15985fn windows_repository_path(value: String) -> Option<RepositoryPath> {
15986    if value.contains('\\') {
15987        return None;
15988    }
15989    RepositoryPath::utf8(value).ok()
15990}
15991
15992fn windows_repository_path_from_bytes(value: &[u8]) -> Option<RepositoryPath> {
15993    windows_repository_path(std::str::from_utf8(value).ok()?.to_owned())
15994}
15995
15996/// Excludes a directory from the workspace tree walk. The three literal
15997/// names are the common convention-based cases; the `CACHEDIR.TAG` check
15998/// beneath them catches every build-output directory regardless of name
15999/// (cargo writes `CACHEDIR.TAG` into every `--target-dir` it creates, so
16000/// this covers `target-a2`, `target-a2-tui`, and any future renamed build
16001/// directory without maintaining a name heuristic list). The name check
16002/// runs first and returns early so a directory rejected by name never pays
16003/// for the extra `CACHEDIR.TAG` stat.
16004fn is_skipped_workspace_directory(name: &str, path: &Path) -> bool {
16005    if name.eq_ignore_ascii_case(".git")
16006        || name.eq_ignore_ascii_case("target")
16007        || name.eq_ignore_ascii_case("node_modules")
16008    {
16009        return true;
16010    }
16011    path.join("CACHEDIR.TAG").is_file()
16012}
16013
16014fn context_pack_source_status_is_usable(status: &SessionStatus) -> bool {
16015    matches!(
16016        status,
16017        SessionStatus::Running | SessionStatus::Exited { exit_code: Some(0) }
16018    )
16019}
16020
16021#[derive(Clone, Debug, Eq, PartialEq)]
16022enum ContextPackRepositoryHead {
16023    NotRepository,
16024    Unborn,
16025    Commit(String),
16026}
16027
16028#[derive(Clone, Debug, Eq, PartialEq)]
16029struct ContextPackRepositoryObservation {
16030    head: ContextPackRepositoryHead,
16031    git: GitSnapshot,
16032    files: Vec<ContextPackRepositoryFileSource>,
16033}
16034
16035fn stable_context_pack_repository(
16036    first: ContextPackRepositoryObservation,
16037    second: ContextPackRepositoryObservation,
16038) -> Result<ContextPackRepository, NodeFailure> {
16039    if first != second {
16040        return Err(failure(
16041            NodeFailureCode::ContextPackMaterializationFailed,
16042            "context repository changed during bounded capture",
16043        ));
16044    }
16045    Ok(ContextPackRepository::from_sources(
16046        &second.git,
16047        second.files,
16048    ))
16049}
16050
16051/// Appends a "skipped: budget exhausted" diagnostic and returns `true` once
16052/// (and only once) the shared walk+git `deadline` has already passed. Every
16053/// remaining phase in `inspect_git_workspace` checks this immediately
16054/// before issuing its own git call, so a budget spent by the walk (or by an
16055/// earlier git call) skips the rest of the chain instead of paying for it
16056/// anyway.
16057fn git_budget_exhausted(snapshot: &mut GitSnapshot, deadline: Instant, stage: &str) -> bool {
16058    if Instant::now() < deadline {
16059        return false;
16060    }
16061    append_git_diagnostic(
16062        snapshot,
16063        &format!("{stage} skipped: workspace inspection time budget exhausted"),
16064    );
16065    true
16066}
16067
16068/// Deadline-aware sibling of `run_git_bounded`, used only by
16069/// `inspect_git_workspace`'s six-call chain: caps the per-call timeout to
16070/// whatever remains of the shared `deadline` (never more than
16071/// `GIT_COMMAND_TIMEOUT_MS`), so a slow walk phase that already spent most
16072/// of the budget cannot still hand a full per-call timeout to every git
16073/// probe behind it. `inspect_git_workspace` already guards each call site
16074/// with `git_budget_exhausted`, so `deadline` is guaranteed not to have
16075/// passed yet here.
16076async fn run_git_bounded_with_deadline(
16077    root: &str,
16078    arguments: &[&str],
16079    output_limit: usize,
16080    deadline: Instant,
16081) -> io::Result<GitCommandOutput> {
16082    let remaining = deadline.checked_duration_since(Instant::now());
16083    let timeout_ms = remaining
16084        .and_then(|remaining| u64::try_from(remaining.as_millis()).ok())
16085        .unwrap_or(0)
16086        .clamp(1, GIT_COMMAND_TIMEOUT_MS);
16087    run_git_read_bounded(root, arguments, output_limit, timeout_ms).await
16088}
16089
16090/// Runs the bounded six-call git probe chain used by `inspect_workspace`.
16091/// `deadline` is the same absolute instant the preceding directory walk was
16092/// bounded by, so time already spent walking counts against what remains
16093/// for git enrichment. Returns the resulting `GitSnapshot` plus whether the
16094/// shared budget (not an individual per-call timeout) is what cut the git
16095/// phase short.
16096async fn inspect_git_workspace(root: &str, deadline: Instant) -> (GitSnapshot, bool) {
16097    let mut snapshot = GitSnapshot {
16098        is_repository: false,
16099        branch: None,
16100        status: Vec::new(),
16101        recent_commits: Vec::new(),
16102        worktrees: Vec::new(),
16103        managed_worktree: None,
16104        truncated: false,
16105        diagnostic: None,
16106    };
16107    if git_budget_exhausted(&mut snapshot, deadline, "git repository probe") {
16108        return (snapshot, true);
16109    }
16110    let repository = match run_git_bounded_with_deadline(
16111        root,
16112        &["rev-parse", "--is-inside-work-tree"],
16113        4 * 1_024,
16114        deadline,
16115    )
16116    .await
16117    {
16118        Ok(output) => output,
16119        Err(error) => {
16120            append_git_diagnostic(
16121                &mut snapshot,
16122                &format!("git inspection unavailable: {error}"),
16123            );
16124            return (snapshot, false);
16125        }
16126    };
16127    snapshot.truncated |= repository.truncated;
16128    if repository.timed_out {
16129        append_git_diagnostic(&mut snapshot, "git repository probe timed out");
16130        return (snapshot, false);
16131    }
16132    if !repository.success
16133        || String::from_utf8_lossy(&repository.stdout).trim() != "true"
16134    {
16135        let stderr = String::from_utf8_lossy(&repository.stderr);
16136        if !stderr.to_ascii_lowercase().contains("not a git repository") {
16137            append_git_diagnostic(&mut snapshot, stderr.trim());
16138        }
16139        return (snapshot, false);
16140    }
16141    snapshot.is_repository = true;
16142
16143    if git_budget_exhausted(&mut snapshot, deadline, "git branch query") {
16144        return (snapshot, true);
16145    }
16146    match run_git_bounded_with_deadline(root, &["branch", "--show-current"], 4 * 1_024, deadline).await {
16147        Ok(output) => {
16148            snapshot.truncated |= output.truncated;
16149            if output.timed_out {
16150                append_git_diagnostic(&mut snapshot, "git branch query timed out");
16151            } else if output.success {
16152                let branch = String::from_utf8_lossy(&output.stdout).trim().to_owned();
16153                if !branch.is_empty() {
16154                    snapshot.branch = Some(truncate_git_field(
16155                        &branch,
16156                        MAX_REPOSITORY_PATH_BYTES,
16157                        &mut snapshot.truncated,
16158                    ));
16159                }
16160            }
16161        }
16162        Err(error) => append_git_diagnostic(
16163            &mut snapshot,
16164            &format!("git branch query failed: {error}"),
16165        ),
16166    }
16167    if snapshot.branch.is_none() && !git_budget_exhausted(&mut snapshot, deadline, "git detached-HEAD probe") {
16168        if let Ok(output) = run_git_bounded_with_deadline(root, &["rev-parse", "--short", "HEAD"], 4 * 1_024, deadline).await {
16169            snapshot.truncated |= output.truncated;
16170            if output.success && !output.timed_out {
16171                let head = String::from_utf8_lossy(&output.stdout).trim().to_owned();
16172                if !head.is_empty() {
16173                    snapshot.branch = Some(format!("detached:{head}"));
16174                }
16175            }
16176        }
16177    }
16178
16179    if git_budget_exhausted(&mut snapshot, deadline, "git status") {
16180        return (snapshot, true);
16181    }
16182    match run_git_bounded_with_deadline(
16183        root,
16184        &[
16185            "status",
16186            "--porcelain=v1",
16187            "-z",
16188            "--renames",
16189            "--untracked-files=normal",
16190            "--",
16191            ".",
16192        ],
16193        GIT_OUTPUT_MAX_BYTES,
16194        deadline,
16195    )
16196    .await
16197    {
16198        Ok(output) => {
16199            snapshot.truncated |= output.truncated;
16200            if output.timed_out {
16201                append_git_diagnostic(&mut snapshot, "git status timed out");
16202            } else if output.success {
16203                parse_git_status(&output.stdout, &mut snapshot);
16204            } else {
16205                append_git_diagnostic(
16206                    &mut snapshot,
16207                    String::from_utf8_lossy(&output.stderr).trim(),
16208                );
16209            }
16210        }
16211        Err(error) => append_git_diagnostic(
16212            &mut snapshot,
16213            &format!("git status failed: {error}"),
16214        ),
16215    }
16216
16217    if git_budget_exhausted(&mut snapshot, deadline, "git log") {
16218        return (snapshot, true);
16219    }
16220    let log_limit = (GIT_COMMIT_MAX_ENTRIES + 1).to_string();
16221    match run_git_bounded_with_deadline(
16222        root,
16223        &["log", "-n", &log_limit, "--pretty=format:%H%x1f%s", "--", "."],
16224        16 * 1_024,
16225        deadline,
16226    )
16227    .await
16228    {
16229        Ok(output) => {
16230            snapshot.truncated |= output.truncated;
16231            if output.timed_out {
16232                append_git_diagnostic(&mut snapshot, "git log timed out");
16233            } else if output.success {
16234                parse_git_commits(&output.stdout, &mut snapshot);
16235            } else {
16236                let stderr = String::from_utf8_lossy(&output.stderr);
16237                if !stderr.to_ascii_lowercase().contains("does not have any commits") {
16238                    append_git_diagnostic(&mut snapshot, stderr.trim());
16239                }
16240            }
16241        }
16242        Err(error) => append_git_diagnostic(
16243            &mut snapshot,
16244            &format!("git log failed: {error}"),
16245        ),
16246    }
16247
16248    if git_budget_exhausted(&mut snapshot, deadline, "git worktree list") {
16249        return (snapshot, true);
16250    }
16251    match list_git_worktrees_with_deadline(root, deadline).await {
16252        Ok(worktrees) => {
16253            snapshot.worktrees = worktrees.into_iter().map(protocol_worktree).collect()
16254        }
16255        Err(error) => append_git_diagnostic(
16256            &mut snapshot,
16257            &format!("git worktree list failed: {}", error.message),
16258        ),
16259    }
16260    (snapshot, false)
16261}
16262
16263fn parse_git_status(output: &[u8], snapshot: &mut GitSnapshot) {
16264    let mut remaining = output;
16265    while !remaining.is_empty() {
16266        if snapshot.status.len() >= GIT_STATUS_MAX_ENTRIES {
16267            snapshot.truncated = true;
16268            break;
16269        }
16270        let Some(record_end) = remaining.iter().position(|byte| *byte == 0) else {
16271            snapshot.truncated = true;
16272            break;
16273        };
16274        let record = &remaining[..record_end];
16275        remaining = &remaining[record_end + 1..];
16276        let has_previous_path = record
16277            .get(..2)
16278            .is_some_and(|status| status.iter().any(|byte| matches!(*byte, b'R' | b'C')));
16279        let previous_path_bytes = if has_previous_path {
16280            let Some(previous_end) = remaining.iter().position(|byte| *byte == 0) else {
16281                snapshot.truncated = true;
16282                break;
16283            };
16284            let previous = &remaining[..previous_end];
16285            remaining = &remaining[previous_end + 1..];
16286            Some(previous)
16287        } else {
16288            None
16289        };
16290        if record.len() < 4
16291            || record[2] != b' '
16292            || !is_git_status_code(record[0])
16293            || !is_git_status_code(record[1])
16294        {
16295            snapshot.truncated = true;
16296            continue;
16297        }
16298        let Some(path) = windows_repository_path_from_bytes(&record[3..]) else {
16299            snapshot.truncated = true;
16300            continue;
16301        };
16302        let previous_path = match previous_path_bytes {
16303            Some(previous) => {
16304                let Some(previous) = windows_repository_path_from_bytes(previous) else {
16305                    snapshot.truncated = true;
16306                    continue;
16307                };
16308                Some(previous)
16309            }
16310            None => None,
16311        };
16312        snapshot.status.push(GitStatusEntry {
16313            index_status: (record[0] as char).to_string(),
16314            worktree_status: (record[1] as char).to_string(),
16315            path,
16316            previous_path,
16317        });
16318    }
16319}
16320
16321fn is_git_status_code(byte: u8) -> bool {
16322    matches!(byte, b' ' | b'M' | b'T' | b'A' | b'D' | b'R' | b'C' | b'U' | b'?' | b'!')
16323}
16324
16325fn parse_git_commits(output: &[u8], snapshot: &mut GitSnapshot) {
16326    for line in String::from_utf8_lossy(output).lines() {
16327        let Some((id, summary)) = line.split_once('\u{1f}') else {
16328            continue;
16329        };
16330        if snapshot.recent_commits.len() >= GIT_COMMIT_MAX_ENTRIES {
16331            snapshot.truncated = true;
16332            break;
16333        }
16334        let id = truncate_git_field(id, 64, &mut snapshot.truncated);
16335        let summary = truncate_git_field(summary, 512, &mut snapshot.truncated);
16336        snapshot.recent_commits.push(GitCommitSummary { id, summary });
16337    }
16338}
16339
16340fn truncate_git_field(value: &str, max_bytes: usize, truncated: &mut bool) -> String {
16341    if value.len() <= max_bytes {
16342        return value.to_owned();
16343    }
16344    *truncated = true;
16345    let mut boundary = max_bytes;
16346    while boundary > 0 && !value.is_char_boundary(boundary) {
16347        boundary -= 1;
16348    }
16349    value[..boundary].to_owned()
16350}
16351
16352fn append_git_diagnostic(snapshot: &mut GitSnapshot, message: &str) {
16353    if message.is_empty() {
16354        return;
16355    }
16356    let normalized = message
16357        .chars()
16358        .map(|character| if character.is_control() { ' ' } else { character })
16359        .collect::<String>();
16360    let mut diagnostic = snapshot.diagnostic.take().unwrap_or_default();
16361    if !diagnostic.is_empty() {
16362        diagnostic.push_str("; ");
16363    }
16364    diagnostic.push_str(normalized.trim());
16365    snapshot.diagnostic = Some(truncate_git_field(
16366        &diagnostic,
16367        GIT_DIAGNOSTIC_MAX_BYTES,
16368        &mut snapshot.truncated,
16369    ));
16370}
16371
16372async fn run_git_bounded(
16373    root: &str,
16374    arguments: &[&str],
16375    output_limit: usize,
16376) -> io::Result<GitCommandOutput> {
16377    run_git_read_bounded(root, arguments, output_limit, GIT_COMMAND_TIMEOUT_MS).await
16378}
16379
16380async fn read_git_history_bounded(
16381    root: &str,
16382    path: Option<&RepositoryPath>,
16383    before: Option<&GitObjectId>,
16384    limit: u16,
16385) -> Result<GitHistoryPage, NodeFailure> {
16386    let path = path
16387        .map(|path| {
16388            path.as_utf8().ok_or_else(|| {
16389                failure(
16390                    NodeFailureCode::InvalidRepositoryPath,
16391                    "git history path must use UTF-8",
16392                )
16393            })
16394        })
16395        .transpose()?
16396        .unwrap_or(".");
16397    let count = (usize::from(limit) + 1).to_string();
16398    let start = before
16399        .map(|revision| format!("{}^", revision.as_str()))
16400        .unwrap_or_else(|| "HEAD".to_owned());
16401    let arguments = [
16402        "--no-pager",
16403        "log",
16404        "--no-decorate",
16405        "--date=iso-strict",
16406        "--pretty=format:%H%x1f%P%x1f%s%x1f%an%x1f%ae%x1f%aI%x1f%cn%x1f%ce%x1f%cI%x1e",
16407        "-n",
16408        count.as_str(),
16409        start.as_str(),
16410        "--",
16411        path,
16412    ];
16413    let output = run_git_bounded(root, &arguments, 256 * 1_024)
16414        .await
16415        .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git history command failed"))?;
16416    if output.timed_out {
16417        return Err(failure(NodeFailureCode::GitReadTimedOut, "git history exceeded its bounded deadline"));
16418    }
16419    if !output.success {
16420        return Err(failure(NodeFailureCode::GitReadFailed, "git history could not be read"));
16421    }
16422    let mut commits = parse_git_history(&output.stdout);
16423    let has_more = commits.len() > usize::from(limit) || output.truncated;
16424    commits.truncate(usize::from(limit));
16425    let next_before = has_more.then(|| commits.last().map(|commit| commit.id.clone())).flatten();
16426    Ok(GitHistoryPage { commits, next_before, truncated: output.truncated })
16427}
16428
16429fn parse_git_history(output: &[u8]) -> Vec<GitCommitDetails> {
16430    String::from_utf8_lossy(output)
16431        .split('\u{1e}')
16432        .filter_map(|record| {
16433            let fields = record.trim_matches(['\r', '\n']).split('\u{1f}').collect::<Vec<_>>();
16434            if fields.len() != 9 {
16435                return None;
16436            }
16437            let id = GitObjectId::new(fields[0].to_owned()).ok()?;
16438            let parents = if fields[1].is_empty() {
16439                Vec::new()
16440            } else {
16441                fields[1]
16442                    .split(' ')
16443                    .map(|value| GitObjectId::new(value.to_owned()))
16444                    .collect::<Result<Vec<_>, _>>()
16445                    .ok()?
16446            };
16447            Some(GitCommitDetails {
16448                id,
16449                parents,
16450                subject: bounded_git_text(fields[2], 512),
16451                author_name: bounded_git_text(fields[3], 256),
16452                author_email: bounded_git_text(fields[4], 320),
16453                authored_at: bounded_git_text(fields[5], 64),
16454                committer_name: bounded_git_text(fields[6], 256),
16455                committer_email: bounded_git_text(fields[7], 320),
16456                committed_at: bounded_git_text(fields[8], 64),
16457                signature_status: GitSignatureStatus::NoSignature,
16458                signer: None,
16459            })
16460        })
16461        .take(usize::from(MAX_GIT_HISTORY_COMMITS) + 1)
16462        .collect()
16463}
16464
16465fn bounded_git_text(value: &str, max_bytes: usize) -> String {
16466    let normalized = value
16467        .chars()
16468        .map(|character| if character.is_control() { ' ' } else { character })
16469        .collect::<String>();
16470    if normalized.len() <= max_bytes {
16471        return normalized;
16472    }
16473    let mut boundary = max_bytes;
16474    while boundary > 0 && !normalized.is_char_boundary(boundary) {
16475        boundary -= 1;
16476    }
16477    normalized[..boundary].to_owned()
16478}
16479
16480async fn read_git_diff_bounded(root: &str, request: GitDiffRequest) -> Result<GitDiff, NodeFailure> {
16481    let path = request.path.as_ref().map(|path| {
16482        path.as_utf8()
16483            .ok_or_else(|| failure(NodeFailureCode::InvalidRepositoryPath, "git diff path must use UTF-8"))
16484    }).transpose()?;
16485    let mut arguments = match &request.mode {
16486        GitDiffMode::Working => vec!["--no-pager", "-c", "diff.external=", "diff", "--no-ext-diff", "--no-textconv"],
16487        GitDiffMode::Staged => vec!["--no-pager", "-c", "diff.external=", "diff", "--cached", "--no-ext-diff", "--no-textconv"],
16488        GitDiffMode::Commit { .. } => vec!["--no-pager", "-c", "diff.external=", "show", "--format=fuller", "--no-ext-diff", "--no-textconv"],
16489    };
16490    if let GitDiffMode::Commit { revision } = &request.mode {
16491        arguments.push(revision.as_str());
16492    }
16493    arguments.push("--");
16494    if let Some(path) = path {
16495        arguments.push(path);
16496    }
16497    let output = run_git_bounded(root, &arguments, MAX_GIT_DIFF_BYTES)
16498        .await
16499        .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git diff command failed"))?;
16500    if output.timed_out {
16501        return Err(failure(NodeFailureCode::GitReadTimedOut, "git diff exceeded its bounded deadline"));
16502    }
16503    if !output.success {
16504        return Err(failure(NodeFailureCode::GitReadFailed, "git diff could not be read"));
16505    }
16506    Ok(GitDiff {
16507        mode: request.mode,
16508        path: request.path,
16509        text: String::from_utf8_lossy(&output.stdout).into_owned(),
16510        truncated: output.truncated,
16511    })
16512}
16513
16514async fn process_request(shared: &NodeShared, connection_id: u64, role: ClientRole, envelope: RequestEnvelope) -> ResponseEnvelope {
16515    let result = process_request_inner(shared, connection_id, role, envelope.request).await;
16516    ResponseEnvelope { request_id: envelope.request_id, result }
16517}
16518
16519async fn process_request_inner(shared: &NodeShared, connection_id: u64, role: ClientRole, request: NodeRequest) -> Result<NodeResponse, NodeFailure> {
16520    if !request.harness_mcp_contract_is_valid_at(unix_time_ms()) {
16521        return Err(failure(NodeFailureCode::InvalidRequest, "harness MCP request is invalid or stale"));
16522    }
16523    let read_only = matches!(
16524        &request,
16525        NodeRequest::Snapshot
16526            | NodeRequest::Resync { .. }
16527            | NodeRequest::BrowseHostDirectories { .. }
16528            | NodeRequest::InspectWorkspace { .. }
16529            | NodeRequest::ReadWorkspaceFile { .. }
16530            | NodeRequest::ReadGitHistory { .. }
16531            | NodeRequest::ReadGitDiff { .. }
16532            | NodeRequest::CatalogNativeSessions { .. }
16533            | NodeRequest::PageNativeSessions { .. }
16534            | NodeRequest::PreviewNativeSession { .. }
16535            | NodeRequest::PreviewSessionRecord { .. }
16536    );
16537    let _mutation_guard = if read_only {
16538        None
16539    } else {
16540        let guard = shared.mutation_gate.lock().await;
16541        if shared.shutdown.load(Ordering::Acquire) {
16542            return Err(failure(NodeFailureCode::ShuttingDown, "node shutdown has begun"));
16543        }
16544        Some(guard)
16545    };
16546    match request {
16547        NodeRequest::Snapshot => Ok(NodeResponse::Snapshot {
16548            event_sequence: shared.current_sequence(),
16549            controller: shared.controller_state(),
16550            snapshot: shared.snapshot(),
16551        }),
16552        NodeRequest::Resync { after_sequence } => Ok(shared.resync(after_sequence)),
16553        NodeRequest::ArmHarnessMcpReservation {
16554            reservation_id,
16555            activation_digest,
16556            spawn_spec,
16557            launch,
16558            expires_at_unix_ms,
16559        } => {
16560            shared.require_controller(connection_id, role)?;
16561            let registry = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16562                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16563            })?;
16564            let expires_at_unix_ms = registry
16565                .arm(reservation_id.clone(), activation_digest.clone(), spawn_spec, launch, expires_at_unix_ms)
16566                .await
16567                .map_err(harness_mcp_failure)?;
16568            Ok(NodeResponse::Armed { reservation_id, activation_digest, expires_at_unix_ms })
16569        }
16570        NodeRequest::SpawnSpecWithHarnessMcp {
16571            reservation_id,
16572            activation_digest,
16573            spec,
16574            deadline_unix_ms,
16575        } => {
16576            shared.require_controller(connection_id, role)?;
16577            let receipt = shared
16578                .spawn_from_spec_with_harness_mcp(
16579                    reservation_id.clone(), activation_digest.clone(), spec, deadline_unix_ms,
16580                )
16581                .await?;
16582            Ok(NodeResponse::Spawned { reservation_id, activation_digest, receipt })
16583        }
16584        NodeRequest::ActivateHarnessMcpReservation {
16585            reservation_id,
16586            activation_digest,
16587            record_id,
16588            session,
16589        } => {
16590            shared.require_controller(connection_id, role)?;
16591            let provider_root_pid = shared.handle.snapshot().sessions.iter().find(|current| {
16592                current.instance_id == session.session.instance_id
16593                    && current.generation == session.session.generation
16594            }).and_then(|current| current.process_id).ok_or_else(|| {
16595                failure(NodeFailureCode::BindingMismatch, "provider root process is unavailable")
16596            })?;
16597            shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16598                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16599            })?.activate(
16600                &reservation_id, &activation_digest, &record_id, &session, provider_root_pid,
16601            ).map_err(harness_mcp_failure)?;
16602            Ok(NodeResponse::Activated { reservation_id, activation_digest, record_id, session })
16603        }
16604        NodeRequest::AbortHarnessMcpReservation { reservation_id, activation_digest } => {
16605            shared.require_controller(connection_id, role)?;
16606            let instance_id = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16607                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16608            })?.abort(&reservation_id, &activation_digest).map_err(harness_mcp_failure)?;
16609            if let Some(instance_id) = instance_id {
16610                SessionLifecycleHook::on_harness_reservation_aborted(&*shared, instance_id);
16611            }
16612            Ok(NodeResponse::Aborted { reservation_id, activation_digest })
16613        }
16614        NodeRequest::PutHarnessMcpReplyChunk {
16615            reservation_id, activation_digest, record_id, session, call_id,
16616            offset, final_chunk, chunk_hex,
16617        } => {
16618            shared.require_controller(connection_id, role)?;
16619            let (next_offset, completed) = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16620                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16621            })?.put_reply_chunk(
16622                &reservation_id, &activation_digest, &record_id, &session, &call_id,
16623                offset, final_chunk, &chunk_hex,
16624            ).map_err(harness_mcp_failure)?;
16625            Ok(NodeResponse::ReplyChunkAccepted {
16626                reservation_id, activation_digest, record_id, session, call_id,
16627                next_offset, completed,
16628            })
16629        }
16630        NodeRequest::RejectHarnessMcpCall {
16631            reservation_id, activation_digest, record_id, session, call_id, reason,
16632        } => {
16633            shared.require_controller(connection_id, role)?;
16634            shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16635                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16636            })?.reject_call(
16637                &reservation_id, &activation_digest, &record_id, &session, &call_id, reason,
16638            ).map_err(harness_mcp_failure)?;
16639            Ok(NodeResponse::CallRejected {
16640                reservation_id, activation_digest, record_id, session, call_id,
16641            })
16642        }
16643        NodeRequest::BeginDeliveryStage { manifest } => {
16644            shared.require_controller(connection_id, role)?;
16645            shared.begin_delivery_stage(manifest)
16646        }
16647        NodeRequest::PutDeliveryBlobChunk {
16648            stage_id,
16649            blob_digest,
16650            offset,
16651            chunk_hex,
16652        } => {
16653            shared.require_controller(connection_id, role)?;
16654            shared.put_delivery_blob_chunk(stage_id, blob_digest, offset, chunk_hex)
16655        }
16656        NodeRequest::CommitDeliveryStage { stage_id } => {
16657            shared.require_controller(connection_id, role)?;
16658            let receipt = shared.commit_delivery_stage(stage_id)?;
16659            Ok(NodeResponse::DeliveryCommitted { receipt })
16660        }
16661        NodeRequest::AbortDeliveryStage { stage_id } => {
16662            shared.require_controller(connection_id, role)?;
16663            shared.abort_delivery_stage(&stage_id)?;
16664            Ok(NodeResponse::DeliveryStageAborted { stage_id })
16665        }
16666        NodeRequest::BrowseHostDirectories { directory, after } => {
16667            let listing = shared.browse_host_directories(directory, after).await?;
16668            Ok(NodeResponse::HostDirectoriesBrowsed { listing })
16669        }
16670        NodeRequest::InspectWorkspace { workspace_id } => {
16671            let inspection = shared.inspect_workspace(workspace_id).await?;
16672            Ok(NodeResponse::WorkspaceInspected { inspection })
16673        }
16674        NodeRequest::ReadWorkspaceFile { workspace_id, path } => {
16675            let file = shared.read_workspace_file(workspace_id, path).await?;
16676            Ok(NodeResponse::WorkspaceFileRead { file })
16677        }
16678        NodeRequest::WriteWorkspaceFile { workspace_id, path, expected_revision, text } => {
16679            shared.require_controller(connection_id, role)?;
16680            let file = shared
16681                .write_workspace_file(workspace_id, path, expected_revision, text)
16682                .await?;
16683            Ok(NodeResponse::WorkspaceFileWritten { file })
16684        }
16685        NodeRequest::CreateWorkspaceFile { workspace_id, path } => {
16686            shared.require_controller(connection_id, role)?;
16687            let file = shared.create_workspace_file(workspace_id, path).await?;
16688            Ok(NodeResponse::WorkspaceFileCreated { file })
16689        }
16690        NodeRequest::CreateWorkspaceDirectory { workspace_id, path } => {
16691            shared.require_controller(connection_id, role)?;
16692            let entry = shared
16693                .create_workspace_directory(workspace_id.clone(), path)
16694                .await?;
16695            Ok(NodeResponse::WorkspaceDirectoryCreated { workspace_id, entry })
16696        }
16697        NodeRequest::ReadGitHistory { workspace_id, path, before, limit } => {
16698            if role != ClientRole::Operator {
16699                return Err(failure(NodeFailureCode::ObserverReadOnly, "git history requires an operator connection"));
16700            }
16701            if !(1..=MAX_GIT_HISTORY_COMMITS).contains(&limit) {
16702                return Err(failure(NodeFailureCode::InvalidRequest, "git history limit is invalid"));
16703            }
16704            let page = shared
16705                .read_git_history(workspace_id.clone(), path, before, limit)
16706                .await?;
16707            Ok(NodeResponse::GitHistoryRead { workspace_id, page })
16708        }
16709        NodeRequest::ReadGitDiff { workspace_id, request } => {
16710            if role != ClientRole::Operator {
16711                return Err(failure(NodeFailureCode::ObserverReadOnly, "git diff requires an operator connection"));
16712            }
16713            let diff = shared.read_git_diff(workspace_id.clone(), request).await?;
16714            Ok(NodeResponse::GitDiffRead { workspace_id, diff })
16715        }
16716        NodeRequest::CatalogNativeSessions { route, limit } => {
16717            if role != ClientRole::Operator {
16718                return Err(failure(
16719                    NodeFailureCode::ObserverReadOnly,
16720                    "native session catalog requires an operator connection",
16721                ));
16722            }
16723            if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit) {
16724                return Err(failure(
16725                    NodeFailureCode::InvalidRequest,
16726                    "native session catalog limit is invalid",
16727                ));
16728            }
16729            let (entries, summary) = shared
16730                .catalog_native_sessions(route.clone(), limit)
16731                .await?;
16732            Ok(NodeResponse::NativeSessionsCataloged {
16733                route,
16734                entries,
16735                summary: Some(summary),
16736            })
16737        }
16738        NodeRequest::PageNativeSessions {
16739            route,
16740            window,
16741            catalog_revision,
16742            recent_cutoff_unix_ms,
16743            after_selection_id,
16744            limit,
16745        } => {
16746            if role != ClientRole::Operator {
16747                return Err(failure(
16748                    NodeFailureCode::ObserverReadOnly,
16749                    "native session catalog paging requires an operator connection",
16750                ));
16751            }
16752            if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit)
16753                || after_selection_id
16754                    .as_deref()
16755                    .is_some_and(|cursor| validate_candidate_id(cursor).is_err())
16756            {
16757                return Err(failure(
16758                    NodeFailureCode::InvalidRequest,
16759                    "native session catalog page request is invalid",
16760                ));
16761            }
16762            let page = shared
16763                .page_native_sessions(
16764                    route.clone(),
16765                    window,
16766                    catalog_revision,
16767                    recent_cutoff_unix_ms,
16768                    after_selection_id,
16769                    limit,
16770                )
16771                .await?;
16772            Ok(NodeResponse::NativeSessionsPaged {
16773                route,
16774                page,
16775            })
16776        }
16777        NodeRequest::PreviewNativeSession {
16778            selection,
16779            message_limit,
16780        } => {
16781            if role != ClientRole::Operator {
16782                return Err(failure(
16783                    NodeFailureCode::ObserverReadOnly,
16784                    "native session preview requires an operator connection",
16785                ));
16786            }
16787            if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16788                .contains(&message_limit)
16789                || selection.validate().is_err()
16790            {
16791                return Err(failure(
16792                    NodeFailureCode::InvalidRequest,
16793                    "native session preview request is invalid",
16794                ));
16795            }
16796            let preview = shared
16797                .preview_native_session(selection.clone(), message_limit)
16798                .await?;
16799            Ok(NodeResponse::NativeSessionPreviewed {
16800                selection,
16801                preview,
16802            })
16803        }
16804        NodeRequest::IndexNativeSession {
16805            selection,
16806            display_name,
16807        } => {
16808            shared.require_controller(connection_id, role)?;
16809            let record = shared
16810                .index_native_session(selection.clone(), display_name)
16811                .await?;
16812            Ok(NodeResponse::NativeSessionIndexed { selection, record })
16813        }
16814        NodeRequest::PreviewSessionRecord {
16815            record_id,
16816            message_limit,
16817        } => {
16818            if role != ClientRole::Operator {
16819                return Err(failure(
16820                    NodeFailureCode::ObserverReadOnly,
16821                    "native session preview requires an operator connection",
16822                ));
16823            }
16824            if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16825                .contains(&message_limit)
16826            {
16827                return Err(failure(
16828                    NodeFailureCode::InvalidRequest,
16829                    "native session preview request is invalid",
16830                ));
16831            }
16832            let record = shared.record(&record_id)?;
16833            let working_directory = shared.workspace_root(&record.workspace_id)?;
16834            if !platform::roots_equal(
16835                &working_directory,
16836                windows_path_text(&record.canonical_root),
16837            ) {
16838                return Err(failure(
16839                    NodeFailureCode::SessionRecordConflict,
16840                    "managed session workspace root changed; refusing to preview another directory",
16841                ));
16842            }
16843            let identity = record.provider_session.clone().ok_or_else(|| {
16844                failure(
16845                    NodeFailureCode::BackendOperationFailed,
16846                    "native session preview is unavailable for this session record",
16847                )
16848            })?;
16849            if identity.key != ProviderSessionKey::SessionId {
16850                return Err(failure(
16851                    NodeFailureCode::BackendOperationFailed,
16852                    "native session preview is unavailable for this session identity",
16853                ));
16854            }
16855            let preview = shared
16856                .preview_native_session_by_session_id(
16857                    record.workspace_id.clone(), record.provider.clone(), identity.id.clone(), message_limit,
16858                )
16859                .await?;
16860            if preview.session_id != identity.id {
16861                return Err(failure(
16862                    NodeFailureCode::SessionRecordConflict,
16863                    "native session preview identity changed while loading",
16864                ));
16865            }
16866            shared.revalidate_session_record_preview(&record, &identity)?;
16867            let preview: gate4agent_types::SessionRecordPreview = preview.into();
16868            shared.publish(NodeEvent::SessionRecordHistorySummarized {
16869                record_id: record_id.clone(),
16870                summary: SessionHistorySummaryV1::from(&preview),
16871            });
16872            Ok(NodeResponse::SessionRecordPreviewed {
16873                record_id,
16874                preview,
16875            })
16876        }
16877        NodeRequest::AcquireController { lease_ms } => {
16878            let controller = shared.acquire_controller(connection_id, role, lease_ms)?;
16879            Ok(NodeResponse::Controller { controller: Some(controller) })
16880        }
16881        NodeRequest::ReleaseController => {
16882            shared.release_controller(connection_id);
16883            Ok(NodeResponse::Controller { controller: shared.controller_state() })
16884        }
16885        NodeRequest::RegisterWorkspace { workspace_id, root } => {
16886            shared.require_controller(connection_id, role)?;
16887            let root = require_windows_path(root)?;
16888            shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16889            let workspace = shared.register_workspace(workspace_id, root).await?;
16890            Ok(NodeResponse::WorkspaceRegistered { workspace })
16891        }
16892        NodeRequest::CreateStandaloneWorkspace {
16893            workspace_id,
16894            root,
16895            initial_branch,
16896        } => {
16897            shared.require_controller(connection_id, role)?;
16898            let root = require_windows_path(root)?;
16899            shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16900            let workspace = shared
16901                .create_standalone_workspace(workspace_id, root, initial_branch)
16902                .await?;
16903            Ok(NodeResponse::StandaloneWorkspaceCreated { workspace })
16904        }
16905        NodeRequest::UnregisterWorkspace { workspace_id } => {
16906            shared.require_controller(connection_id, role)?;
16907            shared.reject_managed_reservation(Some(&workspace_id), None)?;
16908            shared.unregister_workspace(&workspace_id)?;
16909            Ok(NodeResponse::WorkspaceUnregistered { workspace_id })
16910        }
16911        NodeRequest::CreateWorktree {
16912            source_workspace_id,
16913            workspace_id,
16914            target_root,
16915            branch,
16916            base,
16917        } => {
16918            shared.require_controller(connection_id, role)?;
16919            let target_root = require_windows_path(target_root)?;
16920            shared.reject_managed_reservation(Some(&workspace_id), Some(&target_root))?;
16921            let (worktree, workspace) = shared
16922                .create_worktree(
16923                    source_workspace_id,
16924                    workspace_id,
16925                    target_root,
16926                    branch,
16927                    base,
16928                )
16929                .await?;
16930            Ok(NodeResponse::WorktreeCreated { worktree, workspace })
16931        }
16932        NodeRequest::RemoveWorktree {
16933            source_workspace_id,
16934            target_root,
16935        } => {
16936            shared.require_controller(connection_id, role)?;
16937            let native_target_root = require_windows_path(target_root.clone())?;
16938            shared.reject_managed_reservation(None, Some(&native_target_root))?;
16939            let workspace_id = shared
16940                .remove_worktree(source_workspace_id, native_target_root)
16941                .await?;
16942            Ok(NodeResponse::WorktreeRemoved {
16943                target_root,
16944                workspace_id,
16945            })
16946        }
16947        NodeRequest::Spawn { workspace_id, provider, mode, terminal_size, initial_prompt } => {
16948            shared.require_controller(connection_id, role)?;
16949            if !terminal_size.is_valid() {
16950                return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
16951            }
16952            if let Some(prompt) = initial_prompt.as_deref() {
16953                validate_node_text("spawn initial prompt", prompt)?;
16954            }
16955            match shared
16956                .spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt)
16957                .await
16958            {
16959                Ok(session) => {
16960                    shared.mark_spawn_provisional(session.clone());
16961                    Ok(NodeResponse::SpawnAccepted { session })
16962                }
16963                Err(err) => {
16964                    tracing::warn!(
16965                        %provider,
16966                        %workspace_id,
16967                        ?mode,
16968                        code = ?err.code,
16969                        message = %err.message,
16970                        "session spawn refused"
16971                    );
16972                    Err(err)
16973                }
16974            }
16975        }
16976        NodeRequest::SpawnWithOptions { workspace_id, provider, mode, terminal_size, initial_prompt, options } => {
16977            shared.require_controller(connection_id, role)?;
16978            if !terminal_size.is_valid() {
16979                return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
16980            }
16981            if let Some(prompt) = initial_prompt.as_deref() {
16982                validate_node_text("spawn initial prompt", prompt)?;
16983            }
16984            let plan = backend_paths::plan_launch_options(
16985                provider.as_str(),
16986                mode,
16987                options.model.is_some(),
16988                options.effort.is_some(),
16989            )
16990            .map_err(|r| {
16991                tracing::warn!(%provider, reason = %r.message, "launch option path refused");
16992                failure(NodeFailureCode::InvalidRequest, "launch option path refused")
16993            })?;
16994            let selection = spawn_selection(&options)?;
16995            let nick_model = selection.as_ref().map(|s| s.model.clone());
16996            let pty_selection = match plan {
16997                backend_paths::LaunchOptionsPlan::PtyFlags => selection,
16998                _ => None,
16999            };
17000            let spawned = SPAWN_ORIGIN.scope(options.origin.clone(), async {
17001                SPAWN_PARENT.scope(options.parent_session_id.clone(), async {
17002                    SPAWN_NICK_MODEL.scope(nick_model, SPAWN_SESSION_OPTIONS
17003                        .scope(pty_selection, shared.spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt)))
17004                        .await
17005                }).await
17006            }).await;
17007            match spawned {
17008                Ok(session) => {
17009                    if plan == backend_paths::LaunchOptionsPlan::AcpAfterSpawn {
17010                        shared
17011                            .pending_acp_options
17012                            .lock()
17013                            .unwrap_or_else(|p| p.into_inner())
17014                            .insert(session.session.instance_id, (options.model.clone(), options.effort.clone()));
17015                    }
17016                    shared.mark_spawn_provisional(session.clone());
17017                    tracing::info!(%provider, model = ?options.model, effort = ?options.effort, "session spawned with launch options");
17018                    Ok(NodeResponse::SpawnAccepted { session })
17019                }
17020                Err(err) => {
17021                    tracing::warn!(%provider, %workspace_id, code = ?err.code, message = %err.message, "session spawn with options refused");
17022                    Err(err)
17023                }
17024            }
17025        }
17026        NodeRequest::SpawnSpec { spec } => {
17027            shared.require_controller(connection_id, role)?;
17028            let receipt = shared.spawn_from_spec(spec).await?;
17029            Ok(NodeResponse::SpawnSpecAccepted { receipt })
17030        }
17031        NodeRequest::SpawnManagedWorktree { request } => {
17032            shared.require_controller(connection_id, role)?;
17033            let receipt = shared.spawn_managed_worktree(request).await?;
17034            Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
17035        }
17036        NodeRequest::SpawnManagedWorktreeV2 { request } => {
17037            shared.require_controller(connection_id, role)?;
17038            let receipt = shared.spawn_managed_worktree_v2(request).await?;
17039            Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
17040        }
17041        NodeRequest::CleanupManagedWorktree { lease_id } => {
17042            shared.require_controller(connection_id, role)?;
17043            let lease = shared.cleanup_managed_worktree(&lease_id, true).await?;
17044            Ok(NodeResponse::ManagedWorktreeCleanup { lease })
17045        }
17046        NodeRequest::DiscoverHistory { session, limit } => {
17047            controlled_session(shared, connection_id, role, &session)?;
17048            let candidates = shared.discover_history(&session, limit).await?;
17049            Ok(NodeResponse::HistoryDiscovered {
17050                session,
17051                candidates,
17052            })
17053        }
17054        NodeRequest::LoadHistory {
17055            session,
17056            candidate_id,
17057        } => {
17058            controlled_session(shared, connection_id, role, &session)?;
17059            let loaded = shared
17060                .load_history(&session, candidate_id)
17061                .await?;
17062            Ok(NodeResponse::HistoryLoaded {
17063                session,
17064                session_id: loaded.session_id,
17065                message_count: loaded.message_count,
17066                completed_turn_count: loaded.completed_turn_count,
17067            })
17068        }
17069        NodeRequest::ExportContextPackForSessionRecord { record_id, session } => {
17070            shared.require_controller(connection_id, role)?;
17071            let context = shared
17072                .export_context_pack_for_session_record(&record_id, &session, false)
17073                .await?;
17074            Ok(NodeResponse::ContextPackForSessionRecordExported {
17075                record_id,
17076                session,
17077                context,
17078            })
17079        }
17080        NodeRequest::ExportContextPack { session } => {
17081            controlled_session(shared, connection_id, role, &session)?;
17082            let context = shared.export_context_pack(&session).await?;
17083            Ok(NodeResponse::ContextPackExported { context })
17084        }
17085        NodeRequest::ForgetContextPack { context_id } => {
17086            shared.require_controller(connection_id, role)?;
17087            shared.forget_context_pack(&context_id)?;
17088            Ok(NodeResponse::ContextPackForgotten { context_id })
17089        }
17090        NodeRequest::ResolveDurableContextPack { context_id } => {
17091            // Read-only, idempotent, no controller lease required: this lets
17092            // a caller preflight "is this durably exported pack still here"
17093            // without holding the Node's mutation controller.
17094            let context = shared
17095                .context_catalog
17096                .read()
17097                .unwrap_or_else(|poisoned| poisoned.into_inner())
17098                .get(&context_id)
17099                .map(|pack| pack.receipt().clone())
17100                .ok_or_else(|| {
17101                    failure(
17102                        NodeFailureCode::UnknownContextPack,
17103                        "context pack is not present",
17104                    )
17105                })?;
17106            Ok(NodeResponse::DurableContextPackResolved { context })
17107        }
17108        NodeRequest::ReadContextPack { digest } => {
17109            // Read-only fetch of the pack's own bytes, keyed by content digest
17110            // rather than the caller's `SpawnContextId`: same no-controller-lease
17111            // rationale as `ResolveDurableContextPack` above, one step further
17112            // down the same catalog.
17113            let (id, byte_len, bytes) = shared
17114                .context_catalog
17115                .read()
17116                .unwrap_or_else(|poisoned| poisoned.into_inner())
17117                .get_by_digest(&digest)
17118                .map(|pack| {
17119                    (pack.receipt().id.clone(), pack.receipt().byte_len, pack.bytes().to_vec())
17120                })
17121                .ok_or_else(|| {
17122                    failure(
17123                        NodeFailureCode::UnknownContextPack,
17124                        &format!("context pack {digest} not on this node"),
17125                    )
17126                })?;
17127            tracing::debug!(
17128                digest = digest.as_str(),
17129                byte_len,
17130                "read context pack bytes",
17131            );
17132            Ok(NodeResponse::ContextPackBytesRead {
17133                pack: ContextPackBytesRead {
17134                    digest,
17135                    id,
17136                    byte_len,
17137                    bytes,
17138                },
17139            })
17140        }
17141        NodeRequest::Resume { session, terminal_size, initial_prompt } => {
17142            let provider = controlled_session(shared, connection_id, role, &session)?;
17143            if let Some(prompt) = initial_prompt.as_deref() {
17144                validate_node_text("resume initial prompt", prompt)?;
17145            }
17146            let runtime_requirement = if initial_prompt.is_some() {
17147                ProviderRuntimeRequirement::ResumeWithPrompt
17148            } else {
17149                ProviderRuntimeRequirement::Resume
17150            };
17151            shared.require_session_runtime_policy(&session, runtime_requirement)?;
17152            let runtime_policy = shared
17153                .admit_provider_runtime(&provider, runtime_requirement)
17154                .await?;
17155            let working_directory = shared.bound_workspace_root(&session)?;
17156            let request = ResumeLaunchRequest {
17157                working_directory,
17158                terminal_size,
17159                initial_prompt,
17160            };
17161            request
17162                .validate()
17163                .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
17164            let command = shared.prepare_command(ControlCommand::Resume {
17165                instance_id: session.session.instance_id,
17166                target: ResumeTarget::CurrentProvider,
17167                runtime_policy,
17168                request,
17169            });
17170            shared.arm_resume(&session, command.id, runtime_policy)?;
17171            let dispatch = shared.dispatch_envelope(command);
17172            if let Err(error) = dispatch {
17173                shared.clear_armed_resume(&session);
17174                return Err(error);
17175            }
17176            Ok(NodeResponse::Accepted)
17177        }
17178        NodeRequest::RenameSessionRecord {
17179            record_id,
17180            display_name,
17181        } => {
17182            shared.require_controller(connection_id, role)?;
17183            let record = shared.rename_session_record(&record_id, display_name)?;
17184            Ok(NodeResponse::SessionRecordUpdated { record })
17185        }
17186        NodeRequest::SetSessionTask {
17187            record_id,
17188            expected_revision,
17189            target,
17190        } => {
17191            shared.require_controller(connection_id, role)?;
17192            let record = shared.set_session_task(&record_id, expected_revision, target)?;
17193            Ok(NodeResponse::SessionRecordUpdated { record })
17194        }
17195        NodeRequest::IndexProviderSession {
17196            workspace_id,
17197            provider,
17198            identity,
17199            display_name,
17200        } => {
17201            shared.require_controller(connection_id, role)?;
17202            let record = shared.index_provider_session(
17203                workspace_id,
17204                provider,
17205                identity,
17206                display_name,
17207            )?;
17208            Ok(NodeResponse::ProviderSessionIndexed { record })
17209        }
17210        NodeRequest::ResumeSessionRecord {
17211            record_id,
17212            terminal_size,
17213            initial_prompt,
17214        } => {
17215            shared.require_controller(connection_id, role)?;
17216            if let Some(prompt) = initial_prompt.as_deref() {
17217                validate_node_text("managed resume initial prompt", prompt)?;
17218            }
17219            let (record, session) = shared
17220                .resume_session_record(
17221                    &record_id,
17222                    terminal_size,
17223                    initial_prompt,
17224                )
17225                .await?;
17226            Ok(NodeResponse::SessionRecordResumed { record, session })
17227        }
17228        NodeRequest::ForgetSessionRecord { record_id } => {
17229            shared.require_controller(connection_id, role)?;
17230            shared.forget_session_record(&record_id).await?;
17231            Ok(NodeResponse::SessionRecordForgotten { record_id })
17232        }
17233        NodeRequest::Prompt { session, text } => {
17234            let agent_id = controlled_session(shared, connection_id, role, &session)?;
17235            validate_node_text("prompt", &text)?;
17236            shared.require_session_runtime_policy(
17237                &session,
17238                ProviderRuntimeRequirement::SemanticPrompt,
17239            )?;
17240            let framing = prompt_framing(&agent_id);
17241            shared
17242                .dispatch_input_bounded(
17243                    &session,
17244                    InputAction::SubmitPrompt(PromptPayload { text, framing }),
17245                )
17246                .await?;
17247            Ok(NodeResponse::Accepted)
17248        }
17249        NodeRequest::Paste { session, text } => {
17250            controlled_session(shared, connection_id, role, &session)?;
17251            validate_node_text("paste", &text)?;
17252            shared.require_session_runtime_policy(
17253                &session,
17254                ProviderRuntimeRequirement::SemanticPrompt,
17255            )?;
17256            shared
17257                .dispatch_input_bounded(
17258                    &session,
17259                    InputAction::InsertDraft(PromptPayload {
17260                        text,
17261                        framing: PromptFraming::BracketedPaste,
17262                    }),
17263                )
17264                .await?;
17265            Ok(NodeResponse::Accepted)
17266        }
17267        NodeRequest::Input { session, text } => {
17268            controlled_session(shared, connection_id, role, &session)?;
17269            validate_node_text("terminal input", &text)?;
17270            shared
17271                .dispatch_input_bounded(
17272                    &session,
17273                    InputAction::TerminalText(TerminalText { text }),
17274                )
17275                .await?;
17276            Ok(NodeResponse::Accepted)
17277        }
17278        NodeRequest::TerminalBytes { session, bytes } => {
17279            controlled_session(shared, connection_id, role, &session)?;
17280            shared.sessionize_spawn(session.session.instance_id);
17281            let action = terminal_bytes_action(bytes)?;
17282            shared
17283                .dispatch_input_bounded(&session, action)
17284                .await?;
17285            Ok(NodeResponse::Accepted)
17286        }
17287        NodeRequest::TerminalControl { session, control } => {
17288            controlled_session(shared, connection_id, role, &session)?;
17289            shared
17290                .dispatch_input_bounded(&session, InputAction::TerminalControl(control))
17291                .await?;
17292            Ok(NodeResponse::Accepted)
17293        }
17294        NodeRequest::Resize { session, size } => {
17295            controlled_session(shared, connection_id, role, &session)?;
17296            shared.sessionize_spawn(session.session.instance_id);
17297            if !size.is_valid() {
17298                return Err(failure(NodeFailureCode::InvalidRequest, "terminal size is invalid"));
17299            }
17300            shared.dispatch_resize_bounded(&session, size).await?;
17301            Ok(NodeResponse::Accepted)
17302        }
17303        NodeRequest::Interrupt { session } => {
17304            controlled_session(shared, connection_id, role, &session)?;
17305            shared
17306                .dispatch_input_bounded(
17307                    &session,
17308                    InputAction::TerminalControl(TerminalControl::Interrupt),
17309                )
17310                .await?;
17311            Ok(NodeResponse::Accepted)
17312        }
17313        NodeRequest::Stop { session, force } => {
17314            controlled_session(shared, connection_id, role, &session)?;
17315            shared.sessionize_spawn(session.session.instance_id);
17316            shared.dispatch(ControlCommand::Stop { instance_id: session.session.instance_id, force })?;
17317            shared.retire_session_grok_leader(session.session.instance_id);
17318            Ok(NodeResponse::Accepted)
17319        }
17320        NodeRequest::Remove { session } => {
17321            controlled_session(shared, connection_id, role, &session)?;
17322            shared.remove_session(&session).await?;
17323            Ok(NodeResponse::Accepted)
17324        }
17325        NodeRequest::ResolveInteraction { session, correlation_id, response } => {
17326            shared.require_controller(connection_id, role)?;
17327            let snapshot = shared.require_acp_session(&session)?;
17328            let interaction = snapshot
17329                .provider
17330                .interactions
17331                .iter()
17332                .find(|interaction| {
17333                    interaction_correlation(
17334                        session.session.instance_id,
17335                        session.session.generation,
17336                        interaction.id.0,
17337                    ) == correlation_id
17338                })
17339                .ok_or_else(|| failure(
17340                    NodeFailureCode::InvalidRequest,
17341                    "unknown interaction correlation",
17342                ))?;
17343            if interaction.status != ProviderInteractionStatus::Pending {
17344                return Err(failure(
17345                    NodeFailureCode::BackendOperationFailed,
17346                    "interaction has already been resolved",
17347                ));
17348            }
17349            response
17350                .validate_for(interaction.interaction_kind)
17351                .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
17352            let interaction_id = interaction.id;
17353            let response_kind = response.kind();
17354            let response_outcome = response.outcome();
17355            shared.dispatch(ControlCommand::ResolveInteraction {
17356                instance_id: session.session.instance_id,
17357                generation: session.session.generation,
17358                interaction_id,
17359                response,
17360            })?;
17361            // The one line that lets an operator compare what THIS node
17362            // recorded against what the provider itself reports doing --
17363            // measured live on a kimi run where our side resolved `true`
17364            // while the agent reported "rejected" and neither layer had a
17365            // line to line up against the other.
17366            tracing::info!(
17367                node_id = %shared.node_id,
17368                session = ?session.session,
17369                correlation_id = %correlation_id,
17370                interaction_id = interaction_id.0,
17371                response_kind = ?response_kind,
17372                outcome = ?response_outcome,
17373                "interaction resolved",
17374            );
17375            Ok(NodeResponse::Accepted)
17376        }
17377        NodeRequest::SetSessionMode { session, mode_id } => {
17378            shared.require_controller(connection_id, role)?;
17379            shared.require_acp_session(&session)?;
17380            shared.dispatch(ControlCommand::SetSessionMode {
17381                instance_id: session.session.instance_id,
17382                mode_id,
17383            })?;
17384            Ok(NodeResponse::Accepted)
17385        }
17386        NodeRequest::SetSessionConfigOption {
17387            session,
17388            option_id,
17389            value_json,
17390        } => {
17391            shared.require_controller(connection_id, role)?;
17392            shared.require_acp_session(&session)?;
17393            shared.dispatch(ControlCommand::SetSessionConfigOption {
17394                instance_id: session.session.instance_id,
17395                option_id,
17396                value_json,
17397            })?;
17398            Ok(NodeResponse::Accepted)
17399        }
17400        NodeRequest::SetSessionModel { session, model_id } => {
17401            shared.require_controller(connection_id, role)?;
17402            shared.require_acp_session(&session)?;
17403            shared.dispatch(ControlCommand::SetSessionModel {
17404                instance_id: session.session.instance_id,
17405                model_id,
17406            })?;
17407            Ok(NodeResponse::Accepted)
17408        }
17409        NodeRequest::Shutdown => {
17410            shared.require_controller(connection_id, role)?;
17411            shared.begin_shutdown_locked().await?;
17412            Ok(NodeResponse::ShuttingDown)
17413        }
17414    }
17415}
17416
17417fn controlled_session(shared: &NodeShared, connection_id: u64, role: ClientRole, session: &SessionAddress) -> Result<AgentId, NodeFailure> {
17418    shared.require_controller(connection_id, role)?;
17419    shared.validate_address(session)
17420}
17421
17422/// Selects the sole history candidate matching `provider_session_id` when
17423/// the managed record carries a verified identity, or the sole candidate in
17424/// the discovered set at all when it does not (see
17425/// `export_context_pack_for_session_record_inner`'s identity match). Either
17426/// way, more than one match refuses instead of guessing which one is exact.
17427fn select_history_candidate(
17428    candidates: &[HistoryCandidateSummary],
17429    provider_session_id: Option<&str>,
17430) -> Result<String, NodeFailure> {
17431    let mut matches = candidates.iter().filter(|candidate| match provider_session_id {
17432        Some(id) => candidate.session_id_hint == id,
17433        None => true,
17434    });
17435    let candidate = matches.next().ok_or_else(|| {
17436        failure(
17437            NodeFailureCode::SessionRecordConflict,
17438            if provider_session_id.is_some() {
17439                "no history candidate matches the managed provider session"
17440            } else {
17441                "no history candidate exists for the managed session"
17442            },
17443        )
17444    })?;
17445    if matches.next().is_some() {
17446        return Err(failure(
17447            NodeFailureCode::SessionRecordConflict,
17448            if provider_session_id.is_some() {
17449                "history identity is ambiguous for the managed provider session"
17450            } else {
17451                "history is ambiguous without a verified provider session identity"
17452            },
17453        ));
17454    }
17455    Ok(candidate.id.clone())
17456}
17457
17458fn resolved_spawn_profile_summary(
17459    profile: &SpawnProfileDefaults,
17460    environment_profiles: &BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>,
17461) -> Option<SpawnProfileSummary> {
17462    let environment_profile = match profile.environment_profile_id.as_ref() {
17463        None => None,
17464        Some(profile_id) => {
17465            let binding = environment_profiles.get(profile_id)?;
17466            if binding.provider != profile.provider
17467                || binding.native_profile_id(profile.mode).is_none()
17468            {
17469                return None;
17470            }
17471            Some(ResolvedEnvironmentProfileReceipt {
17472                profile_id: binding.id.clone(),
17473                profile_revision: binding.revision.clone(),
17474                network_allowlist: None,
17475                browser_profile_id: None,
17476            })
17477        }
17478    };
17479    Some(SpawnProfileSummary {
17480        id: profile.profile_id.clone(),
17481        revision: profile.revision.clone(),
17482        environment_profile,
17483    })
17484}
17485
17486/// True if `current` is an acceptable version of `expected` at export
17487/// revalidation/commit time.
17488///
17489/// When `allow_clean_detachment` is `false` (the on-demand,
17490/// `NodeRequest::ExportContextPackForSessionRecord` API path), `current` must
17491/// be byte-identical to `expected` and still actively bound to `session` —
17492/// the original, unchanged invariant.
17493///
17494/// When `true` (the reactive auto-export-at-exit path, `§2.1`), `current` may
17495/// ALSO be the exact Live -> Dormant transition that the same clean exit this
17496/// export reacts to produces on its own: `state` Live -> Dormant,
17497/// `active_session` `Some(session)` -> `None`, `updated_at_unix_ms`
17498/// advancing. Every other field — including `provider_session`, `context`,
17499/// `bundle`, `task_binding` — must still match exactly. A record that changed
17500/// in any other way (rebind, a different active session, an identity
17501/// conflict, capacity eviction) is rejected either way; this widens nothing
17502/// about which sessions are eligible, only how the record's own expected
17503/// detachment is tolerated mid-export.
17504fn session_record_export_target_matches(
17505    expected: &ManagedSessionRecord,
17506    current: &ManagedSessionRecord,
17507    session: &SessionAddress,
17508    allow_clean_detachment: bool,
17509) -> bool {
17510    if current == expected && current.active_session.as_ref() == Some(session) {
17511        return current.state == ManagedSessionState::Live;
17512    }
17513    allow_clean_detachment
17514        && current.state == ManagedSessionState::Dormant
17515        && current.active_session.is_none()
17516        && current.record_id == expected.record_id
17517        && current.display_name == expected.display_name
17518        && current.provider == expected.provider
17519        && current.mode == expected.mode
17520        && current.workspace_id == expected.workspace_id
17521        && current.canonical_root == expected.canonical_root
17522        && current.provider_session == expected.provider_session
17523        && current.environment_profile == expected.environment_profile
17524        && current.bundle == expected.bundle
17525        && current.context_id == expected.context_id
17526        && current.context == expected.context
17527        && current.exported_context == expected.exported_context
17528        && current.task_binding == expected.task_binding
17529        && current.created_at_unix_ms == expected.created_at_unix_ms
17530        && current.last_error == expected.last_error
17531}
17532
17533fn session_record_context_export_binding_is_exact(
17534    expected: &ManagedSessionRecord,
17535    current: &ManagedSessionRecord,
17536    identity: Option<&ProviderSessionIdentity>,
17537    session: &SessionAddress,
17538    binding: &SessionBinding,
17539    provider: &AgentId,
17540    current_root: &str,
17541    allow_clean_detachment: bool,
17542) -> bool {
17543    session_record_export_target_matches(expected, current, session, allow_clean_detachment)
17544        && current.provider_session.as_ref() == identity
17545        && current.workspace_id == session.workspace_id
17546        && provider == &current.provider
17547        && binding.workspace_id == session.workspace_id
17548        && binding.generation == session.session.generation
17549        && binding.record_id.as_ref() == Some(&current.record_id)
17550        && platform::roots_equal(
17551            current_root,
17552            windows_path_text(&current.canonical_root),
17553        )
17554}
17555
17556fn session_record_context_export_source_is_usable(
17557    record: &ManagedSessionRecord,
17558    status: &SessionStatus,
17559    allow_clean_detachment: bool,
17560) -> bool {
17561    (record.state == ManagedSessionState::Live
17562        || (allow_clean_detachment && record.state == ManagedSessionState::Dormant))
17563        && context_pack_source_status_is_usable(status)
17564}
17565
17566fn prompt_framing(_agent_id: &AgentId) -> PromptFraming {
17567    PromptFraming::BracketedPaste
17568}
17569
17570fn validate_node_text(field: &str, text: &str) -> Result<(), NodeFailure> {
17571    if text.len() > MAX_NODE_TEXT_BYTES {
17572        return Err(failure(
17573            NodeFailureCode::InvalidRequest,
17574            &format!(
17575                "{field} length {} exceeds the {MAX_NODE_TEXT_BYTES}-byte node limit",
17576                text.len(),
17577            ),
17578        ));
17579    }
17580    Ok(())
17581}
17582
17583fn validate_terminal_bytes(bytes: &[u8]) -> Result<(), NodeFailure> {
17584    if bytes.is_empty() || bytes.len() > MAX_NODE_TERMINAL_BYTES {
17585        return Err(failure(
17586            NodeFailureCode::InvalidRequest,
17587            &format!(
17588                "terminal byte sequence must contain 1..={MAX_NODE_TERMINAL_BYTES} bytes; received {}",
17589                bytes.len(),
17590            ),
17591        ));
17592    }
17593    Ok(())
17594}
17595
17596fn terminal_bytes_action(bytes: Vec<u8>) -> Result<InputAction, NodeFailure> {
17597    validate_terminal_bytes(&bytes)?;
17598    Ok(InputAction::TerminalBytes(bytes))
17599}
17600
17601fn validate_workspace_request_root(root: &str) -> Result<(), NodeFailure> {
17602    if root.is_empty()
17603        || root.len() > MAX_WORKSPACE_ROOT_BYTES
17604        || root.chars().any(char::is_control)
17605    {
17606        return Err(failure(
17607            NodeFailureCode::InvalidWorkspaceRoot,
17608            &format!(
17609                "workspace root must contain 1..={MAX_WORKSPACE_ROOT_BYTES} bytes and no control characters",
17610            ),
17611        ));
17612    }
17613    Ok(())
17614}
17615
17616fn canonical_standalone_workspace_candidate(
17617    workspace_id: &WorkspaceId,
17618    root: &str,
17619) -> Result<String, NodeServerError> {
17620    let path = Path::new(root);
17621    match std::fs::symlink_metadata(path) {
17622        Ok(_) => WorkspaceConfig::new(workspace_id.clone(), path)
17623            .map(|workspace| workspace.canonical_root),
17624        Err(error) if error.kind() == io::ErrorKind::NotFound => {
17625            if !path.is_absolute() {
17626                return Err(NodeServerError::InvalidWorkspaceRoot {
17627                    workspace_id: workspace_id.clone(),
17628                    path: root.to_owned(),
17629                    message: "path is not absolute".to_owned(),
17630                });
17631            }
17632            let name = path.file_name().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
17633                workspace_id: workspace_id.clone(),
17634                path: root.to_owned(),
17635                message: "path has no standalone directory name".to_owned(),
17636            })?;
17637            let parent = path.parent().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
17638                workspace_id: workspace_id.clone(),
17639                path: root.to_owned(),
17640                message: "path has no parent directory".to_owned(),
17641            })?;
17642            let canonical = std::fs::canonicalize(parent)
17643                .map(|parent| parent.join(name))
17644                .map_err(|error| NodeServerError::InvalidWorkspaceRoot {
17645                    workspace_id: workspace_id.clone(),
17646                    path: root.to_owned(),
17647                    message: error.to_string(),
17648                })?;
17649            let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
17650                NodeServerError::InvalidWorkspaceRoot {
17651                    workspace_id: workspace_id.clone(),
17652                    path: path.to_string_lossy().into_owned(),
17653                    message: "canonical path is not valid Unicode".to_owned(),
17654                }
17655            })?;
17656            let canonical_root = platform::normalize_canonical_root(canonical_root);
17657            validate_workspace_root(workspace_id, &canonical_root)?;
17658            Ok(canonical_root)
17659        }
17660        Err(error) => Err(NodeServerError::InvalidWorkspaceRoot {
17661            workspace_id: workspace_id.clone(),
17662            path: root.to_owned(),
17663            message: error.to_string(),
17664        }),
17665    }
17666}
17667
17668fn validate_git_revision(field: &str, revision: Option<&str>) -> Result<(), NodeFailure> {
17669    let Some(revision) = revision else {
17670        return Ok(());
17671    };
17672    if revision.is_empty()
17673        || revision.len() > MAX_REPOSITORY_PATH_BYTES
17674        || revision.chars().any(char::is_control)
17675    {
17676        return Err(failure(
17677            NodeFailureCode::InvalidRequest,
17678            &format!(
17679                "{field} must contain 1..={MAX_REPOSITORY_PATH_BYTES} bytes and no control characters",
17680            ),
17681        ));
17682    }
17683    Ok(())
17684}
17685
17686fn git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
17687    let code = match error.kind {
17688        GitWorktreeErrorKind::Invalid => NodeFailureCode::InvalidRequest,
17689        GitWorktreeErrorKind::NotRepository => NodeFailureCode::NotGitRepository,
17690        GitWorktreeErrorKind::Conflict => NodeFailureCode::WorktreeConflict,
17691        GitWorktreeErrorKind::Protected => NodeFailureCode::WorktreeProtected,
17692        GitWorktreeErrorKind::Dirty => NodeFailureCode::WorktreeDirty,
17693        GitWorktreeErrorKind::Locked => NodeFailureCode::WorktreeLocked,
17694        GitWorktreeErrorKind::Failed => NodeFailureCode::BackendOperationFailed,
17695    };
17696    failure(code, &error.message)
17697}
17698
17699fn standalone_workspace_failure(error: StandaloneWorkspaceError) -> NodeFailure {
17700    let code = match error.kind {
17701        StandaloneWorkspaceErrorKind::InvalidRoot => NodeFailureCode::InvalidWorkspaceRoot,
17702        StandaloneWorkspaceErrorKind::InvalidInitialBranch => NodeFailureCode::InvalidRequest,
17703        StandaloneWorkspaceErrorKind::GitInitializationFailed => {
17704            NodeFailureCode::BackendOperationFailed
17705        }
17706        StandaloneWorkspaceErrorKind::RecoveryRequired => {
17707            NodeFailureCode::StandaloneWorkspaceRecoveryRequired
17708        }
17709    };
17710    failure(code, error.message)
17711}
17712
17713fn managed_git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
17714    let (code, message) = match error.kind {
17715        GitWorktreeErrorKind::Invalid => (
17716            NodeFailureCode::InvalidRequest,
17717            "managed Git input is invalid",
17718        ),
17719        GitWorktreeErrorKind::NotRepository => (
17720            NodeFailureCode::NotGitRepository,
17721            "managed source is not a Git repository",
17722        ),
17723        GitWorktreeErrorKind::Conflict => (
17724            NodeFailureCode::WorktreeConflict,
17725            "managed Git identity conflicts with existing repository state",
17726        ),
17727        GitWorktreeErrorKind::Protected => (
17728            NodeFailureCode::ManagedWorktreeOwnershipConflict,
17729            "managed worktree ownership validation failed",
17730        ),
17731        GitWorktreeErrorKind::Dirty => (
17732            NodeFailureCode::WorktreeDirty,
17733            "managed worktree contains uncommitted changes",
17734        ),
17735        GitWorktreeErrorKind::Locked => (
17736            NodeFailureCode::WorktreeLocked,
17737            "managed worktree is locked",
17738        ),
17739        GitWorktreeErrorKind::Failed => (
17740            NodeFailureCode::BackendOperationFailed,
17741            "managed Git operation failed",
17742        ),
17743    };
17744    NodeFailure { code, message: message.to_owned() }
17745}
17746
17747fn managed_spawn_receipt(
17748    resolved: &ResolvedSpawnSpec,
17749    incarnation_id: NodeIncarnationId,
17750    session: SessionAddress,
17751    lease: ManagedWorktreeLeaseSnapshot,
17752    environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
17753    bundle: Option<ResolvedBundleReceipt>,
17754    context: Option<ResolvedContextPackReceipt>,
17755) -> ManagedWorktreeSpawnReceipt {
17756    let mut managed = resolved.clone();
17757    managed.target.worktree_id = Some(lease.workspace_id.clone());
17758    ManagedWorktreeSpawnReceipt {
17759        spawn: managed.receipt_with_materialization(
17760            incarnation_id,
17761            session,
17762            environment_profile,
17763            bundle,
17764            context,
17765        ),
17766        lease,
17767    }
17768}
17769
17770fn validate_selected_worktree(
17771    source_root: &str,
17772    selected_root: &str,
17773    worktrees: &[NativeGitWorktreeSnapshot],
17774) -> Result<(), NodeFailure> {
17775    if !worktrees
17776        .iter()
17777        .any(|worktree| worktree_paths_equal(&worktree.path, source_root) && !worktree.is_bare)
17778    {
17779        return Err(failure(
17780            NodeFailureCode::WorktreeProtected,
17781            "source workspace is not an authoritative non-bare Git worktree root",
17782        ));
17783    }
17784    if !worktrees.iter().any(|worktree| {
17785        worktree_paths_equal(&worktree.path, selected_root)
17786            && !worktree.is_main
17787            && !worktree.is_bare
17788    }) {
17789        return Err(failure(
17790            NodeFailureCode::WorktreeProtected,
17791            "selected workspace is not an eligible Git worktree in the source repository",
17792        ));
17793    }
17794    Ok(())
17795}
17796
17797fn workspace_file_failure(error: WorkspaceFileReadError) -> NodeFailure {
17798    let code = match error.kind() {
17799        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17800        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17801            NodeFailureCode::RepositoryPathUnsafe
17802        }
17803        WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17804        WorkspaceFileReadErrorKind::NotRegularFile => {
17805            NodeFailureCode::RepositoryFileNotRegular
17806        }
17807        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17808            NodeFailureCode::RepositoryFileReadFailed
17809        }
17810        WorkspaceFileReadErrorKind::RevisionConflict => {
17811            NodeFailureCode::RepositoryFileRevisionConflict
17812        }
17813        WorkspaceFileReadErrorKind::AlreadyExists
17814        | WorkspaceFileReadErrorKind::ParentNotFound
17815        | WorkspaceFileReadErrorKind::ParentNotDirectory
17816        | WorkspaceFileReadErrorKind::Canceled => {
17817            NodeFailureCode::RepositoryFileReadFailed
17818        }
17819    };
17820    failure(code, "repository file read failed")
17821}
17822
17823fn workspace_file_write_failure(error: WorkspaceFileReadError) -> NodeFailure {
17824    let code = match error.kind() {
17825        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17826        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17827            NodeFailureCode::RepositoryPathUnsafe
17828        }
17829        WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17830        WorkspaceFileReadErrorKind::NotRegularFile => {
17831            NodeFailureCode::RepositoryFileNotRegular
17832        }
17833        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17834            NodeFailureCode::RepositoryFileWriteFailed
17835        }
17836        WorkspaceFileReadErrorKind::RevisionConflict => {
17837            NodeFailureCode::RepositoryFileRevisionConflict
17838        }
17839        WorkspaceFileReadErrorKind::AlreadyExists
17840        | WorkspaceFileReadErrorKind::ParentNotFound
17841        | WorkspaceFileReadErrorKind::ParentNotDirectory
17842        | WorkspaceFileReadErrorKind::Canceled => {
17843            NodeFailureCode::RepositoryFileWriteFailed
17844        }
17845    };
17846    failure(code, "repository file write failed")
17847}
17848
17849async fn settle_workspace_entry_create<T>(
17850    mut task: tokio::task::JoinHandle<Result<T, WorkspaceFileReadError>>,
17851    commit_state: Arc<AtomicU8>,
17852    deadline: Duration,
17853    timeout_message: &'static str,
17854    task_failure_message: &'static str,
17855) -> Result<T, NodeFailure> {
17856    let joined = match timeout(deadline, &mut task).await {
17857        Ok(joined) => joined,
17858        Err(_) => {
17859            if commit_state
17860                .compare_exchange(
17861                    WORKSPACE_ENTRY_CREATE_PENDING,
17862                    WORKSPACE_ENTRY_CREATE_CANCELED,
17863                    Ordering::AcqRel,
17864                    Ordering::Acquire,
17865                )
17866                .is_ok()
17867            {
17868                return Err(failure(
17869                    NodeFailureCode::RepositoryEntryCreateTimedOut,
17870                    timeout_message,
17871                ));
17872            }
17873            debug_assert_eq!(
17874                commit_state.load(Ordering::Acquire),
17875                WORKSPACE_ENTRY_CREATE_COMMITTING,
17876            );
17877            task.await
17878        }
17879    };
17880    joined
17881        .map_err(|_| {
17882            failure(
17883                NodeFailureCode::RepositoryEntryCreateFailed,
17884                task_failure_message,
17885            )
17886        })?
17887        .map_err(workspace_entry_create_failure)
17888}
17889
17890fn workspace_entry_create_failure(error: WorkspaceFileReadError) -> NodeFailure {
17891    let code = match error.kind() {
17892        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17893        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17894            NodeFailureCode::RepositoryPathUnsafe
17895        }
17896        WorkspaceFileReadErrorKind::AlreadyExists => {
17897            NodeFailureCode::RepositoryEntryAlreadyExists
17898        }
17899        WorkspaceFileReadErrorKind::ParentNotFound | WorkspaceFileReadErrorKind::NotFound => {
17900            NodeFailureCode::RepositoryParentNotFound
17901        }
17902        WorkspaceFileReadErrorKind::ParentNotDirectory
17903        | WorkspaceFileReadErrorKind::NotRegularFile => {
17904            NodeFailureCode::RepositoryParentNotDirectory
17905        }
17906        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17907            NodeFailureCode::RepositoryEntryCreateFailed
17908        }
17909        WorkspaceFileReadErrorKind::RevisionConflict => {
17910            NodeFailureCode::RepositoryEntryCreateFailed
17911        }
17912        WorkspaceFileReadErrorKind::Canceled => {
17913            NodeFailureCode::RepositoryEntryCreateTimedOut
17914        }
17915    };
17916    failure(code, "repository entry create failed")
17917}
17918
17919fn workspace_file_revision(bytes: &[u8]) -> WorkspaceFileRevision {
17920    let value = digest(&SHA256, bytes)
17921        .as_ref()
17922        .iter()
17923        .map(|byte| format!("{byte:02x}"))
17924        .collect();
17925    WorkspaceFileRevision::new(value).expect("SHA-256 produces a valid workspace revision")
17926}
17927
17928fn managed_spawn_request_digest_v2(
17929    request: &ManagedWorktreeSpawnRequestV2,
17930) -> Result<String, NodeFailure> {
17931    let encoded = serde_json::to_vec(request).map_err(|_| {
17932        failure(
17933            NodeFailureCode::InvalidRequest,
17934            "managed worktree V2 request could not be canonicalized",
17935        )
17936    })?;
17937    let mut value = String::with_capacity(71);
17938    value.push_str("sha256:");
17939    for byte in digest(&SHA256, &encoded).as_ref() {
17940        use std::fmt::Write as _;
17941        write!(&mut value, "{byte:02x}").expect("writing to a String cannot fail");
17942    }
17943    Ok(value)
17944}
17945
17946fn native_session_preview_failure(error: NativeSessionPreviewError) -> NodeFailure {
17947    let code = match error {
17948        NativeSessionPreviewError::InvalidLimit => NodeFailureCode::InvalidRequest,
17949        NativeSessionPreviewError::WorkspaceUnavailable => NodeFailureCode::UnknownWorkspace,
17950        NativeSessionPreviewError::StaleCatalog => NodeFailureCode::StaleNativeSessionCatalog,
17951        NativeSessionPreviewError::UnsupportedProvider
17952        | NativeSessionPreviewError::PreviewUnavailable
17953        | NativeSessionPreviewError::SessionNotFound
17954        | NativeSessionPreviewError::AmbiguousSession => {
17955            NodeFailureCode::BackendOperationFailed
17956        }
17957    };
17958    failure(code, "native session preview failed")
17959}
17960
17961fn host_directory_failure(error: HostDirectoryBrowseError) -> NodeFailure {
17962    let code = match error.kind() {
17963        HostDirectoryBrowseErrorKind::Invalid => NodeFailureCode::HostDirectoryInvalid,
17964        HostDirectoryBrowseErrorKind::ReadFailed => NodeFailureCode::HostDirectoryReadFailed,
17965    };
17966    failure(code, "host directory browse failed")
17967}
17968
17969fn delivery_failure(error: DeliveryStoreError) -> NodeFailure {
17970    let code = match error {
17971        DeliveryStoreError::InvalidManifest => NodeFailureCode::DeliveryManifestInvalid,
17972        DeliveryStoreError::UnknownStage => NodeFailureCode::UnknownDeliveryStage,
17973        DeliveryStoreError::Capacity | DeliveryStoreError::StageConflict => {
17974            NodeFailureCode::DeliveryStageConflict
17975        }
17976        DeliveryStoreError::UnexpectedBlob => NodeFailureCode::DeliveryBlobUnexpected,
17977        DeliveryStoreError::ChunkOutOfOrder | DeliveryStoreError::ChunkOverflow => {
17978            NodeFailureCode::DeliveryChunkOutOfOrder
17979        }
17980        DeliveryStoreError::BlobDigestMismatch => {
17981            NodeFailureCode::DeliveryBlobDigestMismatch
17982        }
17983        DeliveryStoreError::BundleDigestMismatch => {
17984            NodeFailureCode::DeliveryBundleDigestMismatch
17985        }
17986        DeliveryStoreError::StageIncomplete => NodeFailureCode::DeliveryStageIncomplete,
17987        DeliveryStoreError::Unavailable
17988        | DeliveryStoreError::Corrupt
17989        | DeliveryStoreError::Storage(_) => NodeFailureCode::DeliveryStageStorageFailed,
17990    };
17991    failure(code, "delivery operation failed")
17992}
17993
17994fn validate_workspace_root(
17995    workspace_id: &WorkspaceId,
17996    canonical_root: &str,
17997) -> Result<(), NodeServerError> {
17998    #[cfg(windows)]
17999    if canonical_root.starts_with(r"\\") {
18000        return Err(NodeServerError::InvalidWorkspaceRoot {
18001            workspace_id: workspace_id.clone(),
18002            path: canonical_root.to_owned(),
18003            message: "UNC workspace roots are unsupported by Windows PTY providers".to_owned(),
18004        });
18005    }
18006    if !platform::workspace_root_supported(canonical_root)
18007        || canonical_root.len() > WORKING_DIRECTORY_MAX_BYTES
18008    {
18009        return Err(NodeServerError::InvalidWorkspaceRoot {
18010            workspace_id: workspace_id.clone(),
18011            path: canonical_root.to_owned(),
18012            message: "canonical path must be absolute, bounded, and free of control characters"
18013                .to_owned(),
18014        });
18015    }
18016    Ok(())
18017}
18018
18019fn failure(code: NodeFailureCode, _message: &str) -> NodeFailure {
18020    NodeFailure {
18021        code,
18022        message: node_failure_category(code).to_owned(),
18023    }
18024}
18025
18026fn harness_mcp_failure(error: HarnessMcpProxyError) -> NodeFailure {
18027    let code = match error {
18028        HarnessMcpProxyError::Unavailable => NodeFailureCode::HarnessMcpUnavailable,
18029        HarnessMcpProxyError::NotFound => NodeFailureCode::ReservationNotFound,
18030        HarnessMcpProxyError::Conflict => NodeFailureCode::ReservationConflict,
18031        HarnessMcpProxyError::Expired => NodeFailureCode::ReservationExpired,
18032        HarnessMcpProxyError::BindingMismatch => NodeFailureCode::BindingMismatch,
18033        HarnessMcpProxyError::NotActivated => NodeFailureCode::NotActivated,
18034        HarnessMcpProxyError::CallNotFound => NodeFailureCode::CallNotFound,
18035        HarnessMcpProxyError::ChunkOutOfOrder => NodeFailureCode::ChunkOutOfOrder,
18036        HarnessMcpProxyError::ResponseTooLarge => NodeFailureCode::ResponseTooLarge,
18037    };
18038    failure(code, "harness MCP proxy operation failed")
18039}
18040
18041/// Numbers each harness-MCP overlay this process constructs, in
18042/// construction order, so two overlays built back to back in one node process
18043/// still get distinct trace files.
18044static NEXT_HARNESS_MCP_TRACE_SEQUENCE: AtomicU64 = AtomicU64::new(1);
18045
18046/// Builds the optional trace environment entry of one harness-MCP overlay.
18047/// The caller's launch description names both the entry and the node-process
18048/// variable that opts in: when that variable holds a non-empty directory, the
18049/// entry names a trace file under it. Returns `None` when the launch has no
18050/// trace, or the directory is unset, empty, or not valid Unicode. Never creates
18051/// the directory; that is the operator's job.
18052fn harness_mcp_trace_env_entry(launch: &HarnessMcpLaunchV1) -> Option<(OsString, OsString)> {
18053    let trace = launch.trace.as_ref()?;
18054    let trace_dir = std::env::var(&trace.dir_env).ok()?;
18055    if trace_dir.is_empty() {
18056        return None;
18057    }
18058    let sequence = NEXT_HARNESS_MCP_TRACE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
18059    let path = std::path::Path::new(&trace_dir).join(format!("harness-mcp-{sequence}.trace"));
18060    Some((OsString::from(trace.env.as_str()), path.into_os_string()))
18061}
18062
18063fn spawn_deadline_remaining(deadline: Instant) -> Result<Duration, NodeFailure> {
18064    let remaining = deadline.saturating_duration_since(Instant::now());
18065    if remaining.is_zero() {
18066        Err(failure(
18067            NodeFailureCode::SpawnDeadlineExceeded,
18068            "spawn deadline elapsed",
18069        ))
18070    } else {
18071        Ok(remaining)
18072    }
18073}
18074
18075fn spawn_dispatch_timeout(deadline: Option<Instant>) -> Result<Duration, NodeFailure> {
18076    let bounded = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
18077    match deadline {
18078        Some(deadline) => Ok(spawn_deadline_remaining(deadline)?.min(bounded)),
18079        None => Ok(bounded),
18080    }
18081}
18082
18083fn spawn_dispatch_error(error: NodeFailure, deadline: Option<Instant>) -> NodeFailure {
18084    if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
18085        failure(
18086            NodeFailureCode::SpawnDeadlineExceeded,
18087            "spawn dispatch exceeded the spawn deadline",
18088        )
18089    } else {
18090        error
18091    }
18092}
18093
18094fn spawn_runtime_capabilities(
18095    capabilities: &SpawnRequiredCapabilities,
18096) -> Result<Vec<ProviderRuntimeCapability>, NodeFailure> {
18097    capabilities
18098        .iter()
18099        .map(|capability| match capability.as_str() {
18100            SPAWN_RUNTIME_RAW_PTY_LIFECYCLE => Ok(ProviderRuntimeCapability::RawPtyLifecycle),
18101            SPAWN_RUNTIME_SEMANTIC_READINESS => Ok(ProviderRuntimeCapability::SemanticReadiness),
18102            SPAWN_RUNTIME_STRUCTURED_PROMPT => Ok(ProviderRuntimeCapability::StructuredPrompt),
18103            SPAWN_RUNTIME_PROVIDER_SESSION_IDENTITY => {
18104                Ok(ProviderRuntimeCapability::ProviderSessionIdentity)
18105            }
18106            SPAWN_RUNTIME_SEMANTIC_RESUME => Ok(ProviderRuntimeCapability::SemanticResume),
18107            _ => Err(failure(
18108                NodeFailureCode::UnsupportedSpawnCapability,
18109                "spawn required capability is unknown",
18110            )),
18111        })
18112        .collect()
18113}
18114
18115fn persistence_failure(_error: io::Error) -> NodeFailure {
18116    NodeFailure {
18117        code: NodeFailureCode::BackendOperationFailed,
18118        message: DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned(),
18119    }
18120}
18121
18122fn node_failure_category(code: NodeFailureCode) -> &'static str {
18123    match code {
18124        NodeFailureCode::InvalidRequest => "invalid-request",
18125        NodeFailureCode::UnsupportedCapability => "unsupported-capability",
18126        NodeFailureCode::HarnessMcpUnavailable => "harness-mcp-unavailable",
18127        NodeFailureCode::ReservationNotFound => "reservation-not-found",
18128        NodeFailureCode::ReservationConflict => "reservation-conflict",
18129        NodeFailureCode::ReservationExpired => "reservation-expired",
18130        NodeFailureCode::BindingMismatch => "binding-mismatch",
18131        NodeFailureCode::NotActivated => "not-activated",
18132        NodeFailureCode::CallNotFound => "call-not-found",
18133        NodeFailureCode::ChunkOutOfOrder => "chunk-out-of-order",
18134        NodeFailureCode::ResponseTooLarge => "response-too-large",
18135        NodeFailureCode::DeliveryManifestInvalid => "delivery-manifest-invalid",
18136        NodeFailureCode::UnknownDeliveryStage => "unknown-delivery-stage",
18137        NodeFailureCode::DeliveryStageConflict => "delivery-stage-conflict",
18138        NodeFailureCode::DeliveryBlobUnexpected => "delivery-blob-unexpected",
18139        NodeFailureCode::DeliveryChunkOutOfOrder => "delivery-chunk-out-of-order",
18140        NodeFailureCode::DeliveryBlobDigestMismatch => "delivery-blob-digest-mismatch",
18141        NodeFailureCode::DeliveryBundleDigestMismatch => "delivery-bundle-digest-mismatch",
18142        NodeFailureCode::DeliveryStageIncomplete => "delivery-stage-incomplete",
18143        NodeFailureCode::DeliveryStageStorageFailed => "delivery-stage-storage-failed",
18144        NodeFailureCode::Unauthorized => "unauthorized",
18145        NodeFailureCode::ObserverReadOnly => "observer-read-only",
18146        NodeFailureCode::ControllerBusy => "controller-busy",
18147        NodeFailureCode::ControllerRequired => "controller-required",
18148        NodeFailureCode::UnknownWorkspace => "unknown-workspace",
18149        NodeFailureCode::HostDirectoryInvalid => "host-directory-invalid",
18150        NodeFailureCode::HostDirectoryReadFailed => "host-directory-read-failed",
18151        NodeFailureCode::HostDirectoryReadTimedOut => "host-directory-read-timed-out",
18152        NodeFailureCode::InvalidRepositoryPath => "invalid-repository-path",
18153        NodeFailureCode::RepositoryFileNotFound => "repository-file-not-found",
18154        NodeFailureCode::RepositoryFileNotRegular => "repository-file-not-regular",
18155        NodeFailureCode::RepositoryPathUnsafe => "repository-path-unsafe",
18156        NodeFailureCode::RepositoryFileReadTimedOut => "repository-file-read-timed-out",
18157        NodeFailureCode::RepositoryFileReadFailed => "repository-file-read-failed",
18158        NodeFailureCode::RepositoryFileWriteTimedOut => "repository-file-write-timed-out",
18159        NodeFailureCode::RepositoryFileWriteFailed => "repository-file-write-failed",
18160        NodeFailureCode::RepositoryFileRevisionConflict => "repository-file-revision-conflict",
18161        NodeFailureCode::RepositoryEntryAlreadyExists => "repository-entry-already-exists",
18162        NodeFailureCode::RepositoryParentNotFound => "repository-parent-not-found",
18163        NodeFailureCode::RepositoryParentNotDirectory => "repository-parent-not-directory",
18164        NodeFailureCode::RepositoryEntryCreateTimedOut => "repository-entry-create-timed-out",
18165        NodeFailureCode::RepositoryEntryCreateFailed => "repository-entry-create-failed",
18166        NodeFailureCode::GitReadTimedOut => "git-read-timed-out",
18167        NodeFailureCode::GitReadFailed => "git-read-failed",
18168        NodeFailureCode::UnknownSpawnProfile => "unknown-spawn-profile",
18169        NodeFailureCode::SpawnProfileRevisionMismatch => "spawn-profile-revision-mismatch",
18170        NodeFailureCode::SpawnTargetMismatch => "spawn-target-mismatch",
18171        NodeFailureCode::SpawnIdempotencyConflict => "spawn-idempotency-conflict",
18172        NodeFailureCode::SpawnIdempotencyCapacity => "spawn-idempotency-capacity",
18173        NodeFailureCode::SpawnDeadlineExceeded => "spawn-deadline-exceeded",
18174        NodeFailureCode::UnsupportedSpawnCapability => "unsupported-spawn-capability",
18175        NodeFailureCode::UnsupportedTransport => "unsupported-transport",
18176        NodeFailureCode::TurnInFlight => "turn-in-flight",
18177        NodeFailureCode::UnknownEnvironmentProfile => "unknown-environment-profile",
18178        NodeFailureCode::UnknownNetworkAllowlist => "unknown-network-allowlist",
18179        NodeFailureCode::UnsupportedNetworkAllowlistMapping => {
18180            "unsupported-network-allowlist-mapping"
18181        }
18182        NodeFailureCode::BrowserStationProbeUnavailable => {
18183            "browser-station-probe-unavailable"
18184        }
18185        NodeFailureCode::BrowserStationUnreachable => "browser-station-unreachable",
18186        NodeFailureCode::BrowserStationProfileBusy => "browser-station-profile-busy",
18187        NodeFailureCode::EnvironmentProfileBindingMismatch => {
18188            "environment-profile-binding-mismatch"
18189        }
18190        NodeFailureCode::UnknownBundle => "unknown-bundle",
18191        NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
18192        NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
18193        NodeFailureCode::UnknownContextPack => "unknown-context-pack",
18194        NodeFailureCode::ContextPackBusy => "context-pack-busy",
18195        NodeFailureCode::ContextPackMaterializationFailed => {
18196            "context-pack-materialization-failed"
18197        }
18198        NodeFailureCode::InvalidWorkspaceRoot => "invalid-workspace-root",
18199        NodeFailureCode::DuplicateWorkspaceId => "duplicate-workspace-id",
18200        NodeFailureCode::DuplicateWorkspaceRoot => "duplicate-workspace-root",
18201        NodeFailureCode::WorkspaceBusy => "workspace-busy",
18202        NodeFailureCode::LastWorkspace => "last-workspace",
18203        NodeFailureCode::NotGitRepository => "not-git-repository",
18204        NodeFailureCode::WorktreeConflict => "worktree-conflict",
18205        NodeFailureCode::WorktreeProtected => "worktree-protected",
18206        NodeFailureCode::WorktreeDirty => "worktree-dirty",
18207        NodeFailureCode::WorktreeLocked => "worktree-locked",
18208        NodeFailureCode::UnknownManagedWorktreeLease => "unknown-managed-worktree-lease",
18209        NodeFailureCode::ManagedWorktreeBusy => "managed-worktree-busy",
18210        NodeFailureCode::ManagedWorktreeOwnershipConflict => "managed-worktree-ownership-conflict",
18211        NodeFailureCode::ManagedWorktreeProfileRevisionMismatch => {
18212            "managed-worktree-profile-revision-mismatch"
18213        }
18214        NodeFailureCode::ManagedWorktreeRecoveryRequired => "managed-worktree-recovery-required",
18215        NodeFailureCode::StandaloneWorkspaceRecoveryRequired => {
18216            "standalone-workspace-recovery-required"
18217        }
18218        NodeFailureCode::UnknownSession => "unknown-session",
18219        NodeFailureCode::UnknownSessionRecord => "unknown-session-record",
18220        NodeFailureCode::SessionRecordNotResumable => "session-record-not-resumable",
18221        NodeFailureCode::SessionRecordBusy => "session-record-busy",
18222        NodeFailureCode::SessionRecordConflict => "session-record-conflict",
18223        NodeFailureCode::SessionWorkspaceMismatch => "session-workspace-mismatch",
18224        NodeFailureCode::WorkspaceRegistrationRequired => {
18225            "workspace-registration-required"
18226        }
18227        NodeFailureCode::StaleNativeSessionCatalog => "stale-native-session-catalog",
18228        NodeFailureCode::StaleGeneration => "stale-generation",
18229        NodeFailureCode::BackendBusy => "backend-busy",
18230        NodeFailureCode::BackendDisconnected => "backend-disconnected",
18231        NodeFailureCode::BackendOperationFailed => "backend-operation-failed",
18232        NodeFailureCode::ShuttingDown => "shutting-down",
18233    }
18234}
18235
18236fn durable_state_server_error(error: io::Error, category: &'static str) -> NodeServerError {
18237    NodeServerError::DurableState(io::Error::new(error.kind(), category))
18238}
18239
18240fn durable_state_load_error(error: io::Error) -> NodeServerError {
18241    let category = match session_registry::state_load_refusal(&error) {
18242        Some(session_registry::StateLoadRefusal::UnsupportedSchema) => {
18243            DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR
18244        }
18245        Some(session_registry::StateLoadRefusal::PathSemanticsUnsupported) => {
18246            DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR
18247        }
18248        Some(session_registry::StateLoadRefusal::NodeIdentityMismatch) => {
18249            DURABLE_STATE_CONFLICT_ERROR
18250        }
18251        _ => DURABLE_STATE_LOAD_FAILED_ERROR,
18252    };
18253    durable_state_server_error(error, category)
18254}
18255
18256fn unix_time_ms() -> u64 {
18257    std::time::SystemTime::now()
18258        .duration_since(std::time::UNIX_EPOCH)
18259        .unwrap_or_default()
18260        .as_millis()
18261        .min(u64::MAX as u128) as u64
18262}
18263
18264fn detached_record_state(record: &ManagedSessionRecord) -> ManagedSessionState {
18265    if record.provider_session.is_some() {
18266        ManagedSessionState::Dormant
18267    } else {
18268        ManagedSessionState::Unavailable
18269    }
18270}
18271
18272fn active_record_state(
18273    identity_admitted: bool,
18274    provider_session_is_known: bool,
18275) -> ManagedSessionState {
18276    if identity_admitted && !provider_session_is_known {
18277        ManagedSessionState::IdentityPending
18278    } else {
18279        ManagedSessionState::Live
18280    }
18281}
18282
18283fn managed_worktree_record_holders(
18284    records: &[ManagedSessionRecord],
18285) -> Vec<(SessionRecordId, WorkspaceId)> {
18286    records
18287        .iter()
18288        .filter(|record| record.provider_session.is_some())
18289        .map(|record| (record.record_id.clone(), record.workspace_id.clone()))
18290        .collect()
18291}
18292
18293fn managed_spawn_record_ownership(
18294    binding: &SessionBinding,
18295    effective_runtime_policy: ProviderRuntimePolicy,
18296) -> (Option<SessionRecordId>, Option<SessionRecordId>) {
18297    debug_assert_eq!(binding.runtime_policy, effective_runtime_policy);
18298    if effective_runtime_policy.provider_session_identity {
18299        (binding.record_id.clone(), None)
18300    } else {
18301        (None, binding.record_id.clone())
18302    }
18303}
18304
18305#[derive(Debug, Error)]
18306pub enum NodeServerError {
18307    #[error("node endpoint must be a bounded absolute local transport path")]
18308    InvalidEndpoint,
18309    #[error("node access token must contain 1..=4096 bytes")]
18310    InvalidAccessToken,
18311    #[error("node HTTP observer must listen on a loopback address: {0}")]
18312    InvalidApiListen(std::net::SocketAddr),
18313    #[error("node browser bridge must listen on a loopback address: {0}")]
18314    InvalidBridgeListen(std::net::SocketAddr),
18315    #[error("node browser bridge token must contain 1..=4096 bytes")]
18316    InvalidBridgeToken,
18317    #[error("bridge underlay requires --bridge-listen (local HTTP+WS door)")]
18318    BridgeUnderlayRequiresBridgeListen,
18319    #[error("bridge underlay requires GATE4AGENT_BRIDGE_TOKEN (application barrier)")]
18320    BridgeUnderlayRequiresBridgeToken,
18321    #[error("mesh underlay auth token must contain 1..=4096 bytes")]
18322    InvalidBridgeUnderlayToken,
18323    #[error("mesh underlay transport key must be 32 bytes (64 hex digits)")]
18324    InvalidBridgeUnderlayKey,
18325    #[error("node call-home relay must be loopback or a concrete C2 tunnel address with a nonzero port: {0}")]
18326    InvalidCallHome(std::net::SocketAddr),
18327    #[error("node could not reach its call-home relay: {0}")]
18328    CallHomeTransport(#[source] std::io::Error),
18329    #[error("node durable state path must be an absolute file path: {0}")]
18330    InvalidStatePath(String),
18331    #[error("harness MCP helper must be an exact reviewed absolute regular file")]
18332    InvalidHarnessMcpHelper,
18333    #[error("network allowlist catalog path must be an absolute regular file: {0}")]
18334    InvalidNetworkAllowlistCatalogPath(String),
18335    #[error("network allowlist catalog could not be read: {0}")]
18336    NetworkAllowlistCatalogIo(#[source] std::io::Error),
18337    #[error("network allowlist catalog line {line}: {message}")]
18338    InvalidNetworkAllowlistCatalogEntry { line: usize, message: String },
18339    #[error("network allowlist catalog: {0}")]
18340    InvalidNetworkAllowlistCatalog(String),
18341    #[error("network allowlist catalog exceeds the {max}-entry limit")]
18342    NetworkAllowlistCatalogCapacity { max: usize },
18343    #[error("the local state directory is unavailable; supply an explicit state path")]
18344    LocalStateDirectoryUnavailable,
18345    #[error("the local runtime directory is unavailable; supply an explicit endpoint")]
18346    LocalRuntimeDirectoryUnavailable,
18347    #[error("node durable state failed: {0}")]
18348    DurableState(io::Error),
18349    #[error("node requires at least one configured workspace")]
18350    NoWorkspaces,
18351    #[error("workspace '{workspace_id}' root '{path}' is invalid: {message}")]
18352    InvalidWorkspaceRoot {
18353        workspace_id: WorkspaceId,
18354        path: String,
18355        message: String,
18356    },
18357    #[error("duplicate workspace ID: {0}")]
18358    DuplicateWorkspaceId(WorkspaceId),
18359    #[error("workspaces '{first}' and '{second}' resolve to the same root '{root}'")]
18360    DuplicateWorkspaceRoot {
18361        first: WorkspaceId,
18362        second: WorkspaceId,
18363        root: String,
18364    },
18365    #[error("workspace '{0}' uses managed worktrees but has no local profile")]
18366    ManagedWorktreeProfileRequired(WorkspaceId),
18367    #[error("workspace '{0}' has managed worktree profiles but is not in managed mode")]
18368    ManagedWorktreeProfileModeMismatch(WorkspaceId),
18369    #[error("workspace '{workspace_id}' has duplicate managed worktree profile '{profile_id}'")]
18370    DuplicateManagedWorktreeProfile {
18371        workspace_id: WorkspaceId,
18372        profile_id: WorktreeProfileId,
18373    },
18374    #[error("workspace '{workspace_id}' exceeds the {max}-profile managed worktree limit")]
18375    ManagedWorktreeProfileCapacity {
18376        workspace_id: WorkspaceId,
18377        max: usize,
18378    },
18379    #[error("workspace '{workspace_id}' managed worktree profile '{profile_id}' is invalid: {message}")]
18380    InvalidManagedWorktreeProfile {
18381        workspace_id: WorkspaceId,
18382        profile_id: WorktreeProfileId,
18383        message: String,
18384    },
18385    #[error("duplicate node environment profile: {0}")]
18386    DuplicateEnvironmentProfile(SpawnEnvironmentProfileId),
18387    #[error("node environment profile provider is unavailable: {0}")]
18388    EnvironmentProfileProviderUnavailable(AgentId),
18389    #[error("node environment profile capacity is {max}")]
18390    EnvironmentProfileCapacity { max: usize },
18391    #[error("node environment profiles reuse native profile ID '{0}'")]
18392    DuplicateNativeEnvironmentProfile(String),
18393    #[error("node native environment profile is invalid: {0}")]
18394    NativeEnvironmentProfile(String),
18395    #[error("node session-environment materialization root must be absolute")]
18396    InvalidSessionEnvironmentRoot,
18397    #[error("node session-environment materializer is unavailable")]
18398    SessionEnvironmentMaterializerRequired,
18399    #[error("node session-environment materializer failed to initialize")]
18400    SessionEnvironmentMaterializer,
18401    #[error("node bundle catalog is invalid: {0}")]
18402    BundleCatalog(String),
18403    #[error("node delivery store failed to initialize: {0}")]
18404    DeliveryStore(String),
18405    #[error("node context pack store failed to initialize")]
18406    ContextPackStore,
18407    #[error("active agent registry failed: {0}")]
18408    Registry(String),
18409    #[error("node provider contract manifest is invalid: {0}")]
18410    ProviderContractManifest(String),
18411    #[error("named pipe I/O failed: {0}")]
18412    Io(#[from] io::Error),
18413    #[error("node HTTP observer failed: {0}")]
18414    HttpApi(io::Error),
18415    #[error("node browser bridge failed: {0}")]
18416    BridgeApi(io::Error),
18417    #[error(transparent)]
18418    Frame(#[from] FrameError),
18419    #[error("node handshake failed: {0}")]
18420    Handshake(String),
18421    #[error("node authentication frame was not received before the bounded deadline")]
18422    AuthenticationTimedOut,
18423    #[error("node authentication primitive failed: {0}")]
18424    Authentication(String),
18425    #[error("node incarnation identity generation failed: {0}")]
18426    IncarnationIdentity(String),
18427    #[error("node pre-authentication limiter is closed")]
18428    PreauthClosed,
18429    #[error("node authenticated connection limit was reached")]
18430    AuthenticatedConnectionLimit,
18431    #[error("node Ctrl+C/CtrlBreak signal stream closed")]
18432    SignalStreamClosed,
18433    #[error("node stop-all dispatch failed: {0:?}")]
18434    ShutdownDispatch(NodeFailure),
18435    #[error("node shutdown timed out with {active_native_sessions} physical sessions retained")]
18436    ShutdownTimedOut { active_native_sessions: usize },
18437}
18438
18439impl From<crate::network_allowlist_catalog::NetworkAllowlistCatalogError> for NodeServerError {
18440    fn from(error: crate::network_allowlist_catalog::NetworkAllowlistCatalogError) -> Self {
18441        use crate::network_allowlist_catalog::NetworkAllowlistCatalogError as E;
18442        match error {
18443            E::InvalidPath(path) => Self::InvalidNetworkAllowlistCatalogPath(path),
18444            E::Io(error) => Self::NetworkAllowlistCatalogIo(error),
18445            E::InvalidEntry { line, message } => {
18446                Self::InvalidNetworkAllowlistCatalogEntry { line, message }
18447            }
18448            E::InvalidCatalog(message) => Self::InvalidNetworkAllowlistCatalog(message),
18449            E::Capacity { max } => Self::NetworkAllowlistCatalogCapacity { max },
18450        }
18451    }
18452}
18453
18454#[cfg(test)]
18455mod agent_stream_blocked_tests {
18456    use super::*;
18457    use gate4agent_types::{
18458        AdapterId, AdapterVerification, HostRequestOutcome as ProviderHostRequestOutcome,
18459        ProviderSource,
18460    };
18461
18462    fn provider_source(family: AdapterFamily, adapter: &str) -> ProviderSource {
18463        ProviderSource {
18464            family,
18465            binding: AdapterBinding::new(
18466                AdapterId::new(adapter).unwrap(),
18467                "fixture/v1",
18468                AdapterVerification::SyntheticFixture,
18469            )
18470            .unwrap(),
18471        }
18472    }
18473
18474    fn provider_control_event_at(
18475        family: AdapterFamily,
18476        adapter: &str,
18477        source_sequence: u64,
18478        provider_event: ProviderEvent,
18479    ) -> ControlEvent {
18480        ControlEvent {
18481            sequence: 41,
18482            command_id: None,
18483            instance_id: AgentInstanceId(7),
18484            generation: SessionGeneration(3),
18485            event: ControlEventKind::ProviderEvent {
18486                sequence: 8,
18487                source: provider_source(family, adapter),
18488                source_sequence,
18489                event: provider_event,
18490            },
18491        }
18492    }
18493
18494    fn provider_control_event(family: AdapterFamily, provider_event: ProviderEvent) -> ControlEvent {
18495        provider_control_event_at(family, "fixture", 9, provider_event)
18496    }
18497
18498    fn denied_host_request(
18499        by: ProviderHostDecisionAuthority,
18500        reason: Option<String>,
18501    ) -> ControlEvent {
18502        provider_control_event_at(
18503            AdapterFamily::Acp,
18504            "claude-code",
18505            2,
18506            ProviderEvent::HostRequestObserved {
18507                method: "terminal/create".to_owned(),
18508                params_json: String::new(),
18509                decision: ProviderHostRequestDecision::Denied { by },
18510                outcome: ProviderHostRequestOutcome::Executed,
18511                reason,
18512            },
18513        )
18514    }
18515
18516    fn blocked(
18517        event: &ControlEvent,
18518    ) -> (
18519        Option<String>,
18520        String,
18521        BlockAuthorityV1,
18522        Option<String>,
18523        String,
18524        Option<String>,
18525    ) {
18526        let chunk = agent_stream_chunk(event).expect("expected a Blocked chunk");
18527        chunk.validate().expect("minted chunk must validate");
18528        let AgentStreamChunkKindV1::Blocked {
18529            correlation_id,
18530            tool_class,
18531            authority,
18532            reason_kind,
18533            reason,
18534            help,
18535        } = chunk.kind
18536        else {
18537            panic!("expected AgentStreamChunkKindV1::Blocked");
18538        };
18539        (correlation_id, tool_class, authority, reason_kind, reason, help)
18540    }
18541
18542    /// Pins the fleet and every adapter each member declares: the fleet is
18543    /// exactly what `active_registry` says, and no member declares a hook
18544    /// adapter (lifecycle hooks are retired).
18545    #[test]
18546    fn the_enabled_provider_matrix_is_exact() {
18547        let registry = active_registry().unwrap();
18548        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
18549        assert_eq!(
18550            providers
18551                .iter()
18552                .map(|contract| contract.provider.as_str())
18553                .collect::<Vec<_>>(),
18554            // `shell` is the plain PTY provider (operator terminals and smoke tests).
18555            vec!["shell", "claude", "codex", "grok", "kimi"],
18556        );
18557        assert!(adapters.iter().all(|contract| {
18558            !matches!(contract.family, AdapterFamily::Hook | AdapterFamily::ManagedHook)
18559        }));
18560    }
18561
18562    /// A gate-denied host request, WITH its reason text carried all the way
18563    /// from the ACP host, mints a `Blocked` chunk naming the gate.
18564    #[test]
18565    fn gate_denied_host_request_mints_a_blocked_chunk() {
18566        let gate_text =
18567            "blocked by dangerous-command gate: rule=filesystem-wipe, argument=rm -rf /";
18568        let event = denied_host_request(
18569            ProviderHostDecisionAuthority::Gate,
18570            Some(gate_text.to_owned()),
18571        );
18572        let chunk = agent_stream_chunk(&event).expect("expected a Blocked chunk");
18573        assert_eq!(chunk.source_sequence, 2);
18574        let (correlation_id, tool_class, authority, reason_kind, reason, help) = blocked(&event);
18575        assert_eq!(authority, BlockAuthorityV1::HostGate);
18576        assert_eq!(reason, gate_text);
18577        assert_eq!(correlation_id, None);
18578        assert_eq!(reason_kind, None);
18579        assert_eq!(help, None);
18580        assert_eq!(tool_class, "Write");
18581    }
18582
18583    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.";
18584
18585    /// The owner's classifier sample, verbatim, arriving as a
18586    /// `tool_call_update`-derived `ProviderEvent::ToolCompleted` with
18587    /// `non_execution_kind: "permission-rule"` -- mints a `Blocked` chunk with
18588    /// `authority: ProviderPermissionRule`, the `Reason:` sentence as `reason`
18589    /// and the guidance tail as `help`.
18590    #[test]
18591    fn classifier_sample_permission_rule_mints_a_blocked_chunk() {
18592        let event = provider_control_event(
18593            AdapterFamily::Acp,
18594            ProviderEvent::ToolCompleted {
18595                id: "toolu_9".to_owned(),
18596                output: CLASSIFIER_SAMPLE.to_owned(),
18597                is_error: true,
18598                duration_ms: None,
18599                agent_id: None,
18600                non_execution_kind: Some("permission-rule".to_owned()),
18601            },
18602        );
18603        let (correlation_id, _, authority, reason_kind, reason, help) = blocked(&event);
18604        assert_eq!(authority, BlockAuthorityV1::ProviderPermissionRule);
18605        assert_eq!(reason, "Blocked by classifier");
18606        assert_eq!(reason_kind, Some("permission-rule".to_owned()));
18607        assert!(help.as_deref().is_some_and(|help| help.starts_with("If you have other tasks")));
18608        assert!(correlation_id.is_some(), "a tool-correlated block must carry a correlation id");
18609    }
18610
18611    /// A `session/prompt` RPC error with `data.codexErrorInfo:
18612    /// "usageLimitExceeded"` (measured live 2026-09-05) mints a `Blocked` chunk
18613    /// with `authority: ProviderQuota`, the message verbatim as `reason` and the
18614    /// vendor code as `reason_kind`.
18615    #[test]
18616    fn codex_quota_rpc_error_mints_a_blocked_chunk() {
18617        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.";
18618        let event = provider_control_event(
18619            AdapterFamily::Acp,
18620            ProviderEvent::SessionEnded {
18621                result: "JSON-RPC error -32603: Internal error".to_owned(),
18622                cost_usd: None,
18623                is_error: true,
18624                stop_reason: Some(ProviderStopReason::ProviderError {
18625                    code: -32603,
18626                    message: message.to_owned(),
18627                    vendor_code: Some("usageLimitExceeded".to_owned()),
18628                }),
18629            },
18630        );
18631        let (_, tool_class, authority, reason_kind, reason, _) = blocked(&event);
18632        assert_eq!(authority, BlockAuthorityV1::ProviderQuota);
18633        assert_eq!(reason, message);
18634        assert_eq!(reason_kind, Some("usageLimitExceeded".to_owned()));
18635        assert_eq!(tool_class, "Session");
18636    }
18637
18638    /// ACP's `stopReason: "refusal"` mints a `Blocked` chunk with
18639    /// `authority: ProviderRefusal`.
18640    #[test]
18641    fn session_end_stop_reason_refusal_mints_a_blocked_chunk() {
18642        let event = provider_control_event(
18643            AdapterFamily::Acp,
18644            ProviderEvent::SessionEnded {
18645                result: "refusal".to_owned(),
18646                cost_usd: None,
18647                is_error: true,
18648                stop_reason: Some(ProviderStopReason::Refusal),
18649            },
18650        );
18651        let (_, _, authority, _, _, _) = blocked(&event);
18652        assert_eq!(authority, BlockAuthorityV1::ProviderRefusal);
18653    }
18654
18655    /// A failed tool completion with no typed `non_execution_kind` -- or one
18656    /// that only says the turn stopped -- is a plain execution failure, never a
18657    /// block; nor is an agent's own narrative claiming one.
18658    #[test]
18659    fn failures_and_narrative_without_a_typed_block_mint_no_blocked_chunk() {
18660        for non_execution_kind in [None, Some("interrupted".to_owned()), Some("cancelled".to_owned())] {
18661            let event = provider_control_event(
18662                AdapterFamily::Acp,
18663                ProviderEvent::ToolCompleted {
18664                    id: "toolu_1".to_owned(),
18665                    output: "command exited with status 1".to_owned(),
18666                    is_error: true,
18667                    duration_ms: Some(12),
18668                    agent_id: None,
18669                    non_execution_kind,
18670                },
18671            );
18672            assert!(agent_stream_chunk(&event).is_none());
18673        }
18674        let narrative = provider_control_event(
18675            AdapterFamily::Acp,
18676            ProviderEvent::ToolCompleted {
18677                id: "toolu_2".to_owned(),
18678                output: "the harness blocked the write, approval needed".to_owned(),
18679                is_error: false,
18680                duration_ms: Some(3),
18681                agent_id: None,
18682                non_execution_kind: None,
18683            },
18684        );
18685        assert!(agent_stream_chunk(&narrative).is_none());
18686    }
18687
18688    /// `Granted`/`Deferred` decisions never mint a `Blocked` chunk, whatever the
18689    /// execution outcome: a granted request that failed while running was
18690    /// still authorized.
18691    #[test]
18692    fn granted_and_deferred_host_requests_never_mint_a_blocked_chunk() {
18693        for (decision, outcome) in [
18694            (
18695                ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
18696                ProviderHostRequestOutcome::Executed,
18697            ),
18698            (ProviderHostRequestDecision::Deferred, ProviderHostRequestOutcome::Executed),
18699            (
18700                ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
18701                ProviderHostRequestOutcome::Failed {
18702                    error: "terminal/create spawn failed: os error 3".to_owned(),
18703                },
18704            ),
18705        ] {
18706            let event = provider_control_event_at(
18707                AdapterFamily::Acp,
18708                "claude-code",
18709                3,
18710                ProviderEvent::HostRequestObserved {
18711                    method: "fs/read_text_file".to_owned(),
18712                    params_json: String::new(),
18713                    decision: decision.clone(),
18714                    outcome,
18715                    reason: None,
18716                },
18717            );
18718            assert!(agent_stream_chunk(&event).is_none(), "{decision:?}");
18719        }
18720    }
18721
18722    /// A deferred `session/request_permission` resolved by an operator or an
18723    /// expired deadline carries no reason text -- the chunk falls back to a
18724    /// categorical sentence naming the authority rather than inventing detail.
18725    #[test]
18726    fn denied_host_request_with_no_reason_falls_back_to_a_categorical_sentence() {
18727        for (by, expected_authority, expected_reason) in [
18728            (
18729                ProviderHostDecisionAuthority::Operator,
18730                BlockAuthorityV1::Operator,
18731                "declined by the operator",
18732            ),
18733            (
18734                ProviderHostDecisionAuthority::DeadlinePolicy,
18735                BlockAuthorityV1::HostDeadline,
18736                "denied by host policy after the operator deadline expired",
18737            ),
18738        ] {
18739            let (_, _, authority, _, reason, _) = blocked(&denied_host_request(by, None));
18740            assert_eq!(authority, expected_authority);
18741            assert_eq!(reason, expected_reason);
18742        }
18743    }
18744
18745    /// An over-long reason is cut to the wire bound BEFORE the chunk is built,
18746    /// at a safe UTF-8 boundary -- `validate` rejects an over-long value
18747    /// outright rather than truncating.
18748    #[test]
18749    fn overlong_blocked_reason_is_truncated_to_the_wire_bound() {
18750        let oversized = "x".repeat(MAX_ACP_BLOCKED_REASON_BYTES + 500);
18751        let (_, _, _, _, reason, _) = blocked(&denied_host_request(
18752            ProviderHostDecisionAuthority::Policy,
18753            Some(oversized),
18754        ));
18755        assert!(reason.len() <= MAX_ACP_BLOCKED_REASON_BYTES);
18756    }
18757}
18758
18759#[cfg(test)]
18760mod standalone_capability_platform_tests {
18761    use super::*;
18762
18763    #[test]
18764    fn standalone_workspace_capability_requires_windows_identity_guards() {
18765        let capabilities = baseline_capabilities().unwrap();
18766        let advertised = capabilities.iter().any(|capability| {
18767            capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
18768        });
18769        assert_eq!(advertised, cfg!(windows));
18770
18771        let request = NodeRequest::CreateStandaloneWorkspace {
18772            workspace_id: WorkspaceId::new("standalone-platform").unwrap(),
18773            root: OpaqueHostPath::utf8("standalone-platform-root".to_owned()).unwrap(),
18774            initial_branch: None,
18775        };
18776        assert_eq!(
18777            request_uses_unnegotiated_capability(&request, &capabilities),
18778            !cfg!(windows),
18779        );
18780    }
18781}
18782
18783
18784#[cfg(test)]
18785mod station_knobs_resolve_tests {
18786    use super::*;
18787    use gate4agent_catalog::EnvMutation;
18788    use gate4agent_runtime_native::{
18789        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
18790        NativeLaunchProfileId,
18791    };
18792    use std::ffi::OsString;
18793    use std::sync::Arc;
18794
18795    struct EmptyEnvironmentResolver;
18796
18797    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
18798        fn resolve_child_environment(
18799            &self,
18800        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
18801            Ok(vec![EnvMutation {
18802                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
18803                value: None,
18804            }])
18805        }
18806    }
18807
18808    fn agent(value: &str) -> AgentId {
18809        AgentId::new(value).unwrap()
18810    }
18811
18812    fn station_knobs_fixture() -> (NodeShared, crate::protocol::SpawnProfileDefaults) {
18813        let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
18814        let profile_revision =
18815            crate::protocol::SpawnEnvironmentProfileRevision::new("local-claude-r1").unwrap();
18816        let spawn_defaults = crate::protocol::SpawnProfileDefaults {
18817            profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
18818            revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
18819            provider: agent("claude"),
18820            mode: SessionMode::Pty,
18821            terminal_size: gate4agent_types::TerminalSize {
18822                rows: 24,
18823                columns: 80,
18824            },
18825            prompt: None,
18826            bundle_id: None,
18827            context_id: None,
18828            environment_profile_id: Some(profile_id.clone()),
18829        };
18830        let spawn_profiles = SpawnProfileRegistry::new([spawn_defaults.clone()]).unwrap();
18831        let catalog = active_registry().unwrap();
18832        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18833        let control = runtime.native_launch_profile_control();
18834        let node_profile = NodeEnvironmentProfile::new(
18835            profile_id.clone(),
18836            profile_revision,
18837            agent("claude"),
18838            [NativeLaunchProfile::new(
18839                NativeLaunchProfileId::new("local-claude-pty").unwrap(),
18840                agent("claude"),
18841                TransportKind::Pty,
18842                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
18843                Arc::new(EmptyEnvironmentResolver),
18844            )
18845            .unwrap()],
18846        )
18847        .unwrap();
18848        let (binding, native_profiles, _materialization) = node_profile.into_parts();
18849        for native_profile in native_profiles {
18850            runtime.upsert_native_launch_profile(native_profile).unwrap();
18851        }
18852        let workspace = WorkspaceConfig::new(
18853            WorkspaceId::new("primary").unwrap(),
18854            std::env::current_dir().unwrap(),
18855        )
18856        .unwrap();
18857        let shared = NodeShared::new_with_incarnation(
18858            handle,
18859            "fixture-token".to_owned(),
18860            NodeId::new("node-station-knobs-test").unwrap(),
18861            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
18862            vec![workspace],
18863            vec![agent("claude")],
18864            ProviderRuntimeStatuses::default(),
18865            None,
18866            Vec::new(),
18867            Vec::new(),
18868            spawn_profiles,
18869            Some(control),
18870            None,
18871            None,
18872            None,
18873            Vec::new(),
18874            Vec::new(),
18875            Vec::new(),
18876            Vec::new(),
18877            None,
18878            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
18879        );
18880        shared
18881            .environment_profiles
18882            .write()
18883            .unwrap_or_else(|poisoned| poisoned.into_inner())
18884            .insert(binding.id.clone(), binding);
18885        (shared, spawn_defaults)
18886    }
18887
18888    /// Empty-default catalog refuses unknown network_allowlist ids.
18889    /// Feature-off dig2-station-probe keeps browser_profile stub echo.
18890    #[test]
18891    fn resolve_environment_profile_refuses_unknown_network_allowlist() {
18892        let (shared, defaults) = station_knobs_fixture();
18893        let unknown = crate::protocol::SpawnNetworkAllowlistId::new("not-registered").unwrap();
18894        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18895        let spec = crate::protocol::SpawnSpec {
18896            target: crate::protocol::SpawnTarget {
18897                node_id: shared.node_id.clone(),
18898                workspace_id: WorkspaceId::new("primary").unwrap(),
18899                worktree_id: None,
18900            },
18901            profile_id: defaults.profile_id.clone(),
18902            expected_profile_revision: defaults.revision.clone(),
18903            overrides: crate::protocol::SpawnOverrides {
18904                network_allowlist: Some(unknown),
18905                browser_profile_id: Some(browser),
18906                ..crate::protocol::SpawnOverrides::default()
18907            },
18908            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18909            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18910                "station-knobs-unknown-allowlist",
18911            )
18912            .unwrap(),
18913            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18914        };
18915        let resolved = spec.resolve(&defaults).unwrap();
18916        let failure = shared
18917            .resolve_environment_profile(&resolved)
18918            .expect_err("unknown allowlist must refuse");
18919        assert_eq!(failure.code, NodeFailureCode::UnknownNetworkAllowlist);
18920    }
18921
18922    /// Registered network_allowlist (+ browser_profile_id when feature off)
18923    /// echo onto the env-profile receipt. Empty catalog still allows omit
18924    /// (None). Empty/whitespace refuse is type-level. Feature-off
18925    /// dig2-station-probe keeps stub browser echo; feature-on refuse is
18926    /// covered by `…_refuses_browser_profile_when_station_probe_fails`.
18927    #[test]
18928    fn resolve_environment_profile_echoes_registered_station_network_and_browser_knobs() {
18929        let (shared, defaults) = station_knobs_fixture();
18930        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18931        #[cfg(not(feature = "dig2-station-probe"))]
18932        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18933        shared
18934            .network_allowlist_catalog
18935            .write()
18936            .unwrap_or_else(|poisoned| poisoned.into_inner())
18937            .insert_id(allowlist.clone())
18938            .unwrap();
18939        let spec = crate::protocol::SpawnSpec {
18940            target: crate::protocol::SpawnTarget {
18941                node_id: shared.node_id.clone(),
18942                workspace_id: WorkspaceId::new("primary").unwrap(),
18943                worktree_id: None,
18944            },
18945            profile_id: defaults.profile_id.clone(),
18946            expected_profile_revision: defaults.revision.clone(),
18947            overrides: crate::protocol::SpawnOverrides {
18948                network_allowlist: Some(allowlist.clone()),
18949                #[cfg(not(feature = "dig2-station-probe"))]
18950                browser_profile_id: Some(browser.clone()),
18951                ..crate::protocol::SpawnOverrides::default()
18952            },
18953            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18954            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-echo")
18955                .unwrap(),
18956            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18957        };
18958        let resolved = spec.resolve(&defaults).unwrap();
18959        assert_eq!(resolved.network_allowlist.as_ref(), Some(&allowlist));
18960        #[cfg(not(feature = "dig2-station-probe"))]
18961        assert_eq!(resolved.browser_profile_id.as_ref(), Some(&browser));
18962        let receipt = shared
18963            .resolve_environment_profile(&resolved)
18964            .expect("resolve ok")
18965            .expect("env profile present");
18966        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18967        #[cfg(not(feature = "dig2-station-probe"))]
18968        assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
18969        #[cfg(feature = "dig2-station-probe")]
18970        assert_eq!(receipt.browser_profile_id, None);
18971        assert_eq!(receipt.profile_id.as_str(), "local-claude");
18972
18973        let omitted = crate::protocol::SpawnSpec {
18974            overrides: crate::protocol::SpawnOverrides::default(),
18975            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-omit")
18976                .unwrap(),
18977            ..spec
18978        };
18979        let resolved_omit = omitted.resolve(&defaults).unwrap();
18980        let bare = shared
18981            .resolve_environment_profile(&resolved_omit)
18982            .expect("resolve ok")
18983            .expect("env profile present");
18984        assert_eq!(bare.network_allowlist, None);
18985        assert_eq!(bare.browser_profile_id, None);
18986    }
18987
18988    /// Feature `dig2-station-probe`: browser_profile_id set refuses when the
18989    /// cheap station probe cannot succeed. On non-Windows (this CI host) that
18990    /// is `BrowserStationProbeUnavailable`. Never cookies on C2. Lease map is
18991    /// unit-tested separately (probe refuses before lease on Linux).
18992    #[cfg(feature = "dig2-station-probe")]
18993    #[test]
18994    fn resolve_environment_profile_refuses_browser_profile_when_station_probe_fails() {
18995        let (shared, defaults) = station_knobs_fixture();
18996        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18997        let spec = crate::protocol::SpawnSpec {
18998            target: crate::protocol::SpawnTarget {
18999                node_id: shared.node_id.clone(),
19000                workspace_id: WorkspaceId::new("primary").unwrap(),
19001                worktree_id: None,
19002            },
19003            profile_id: defaults.profile_id.clone(),
19004            expected_profile_revision: defaults.revision.clone(),
19005            overrides: crate::protocol::SpawnOverrides {
19006                browser_profile_id: Some(browser),
19007                ..crate::protocol::SpawnOverrides::default()
19008            },
19009            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19010            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19011                "station-knobs-probe-refuse",
19012            )
19013            .unwrap(),
19014            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19015        };
19016        let resolved = spec.resolve(&defaults).unwrap();
19017        let failure = shared
19018            .resolve_environment_profile(&resolved)
19019            .expect_err("browser_profile with failing probe must refuse");
19020        #[cfg(windows)]
19021        {
19022            assert!(
19023                matches!(
19024                    failure.code,
19025                    NodeFailureCode::BrowserStationUnreachable
19026                        | NodeFailureCode::BrowserStationProbeUnavailable
19027                ),
19028                "unexpected code {:?}",
19029                failure.code
19030            );
19031        }
19032        #[cfg(not(windows))]
19033        {
19034            assert_eq!(
19035                failure.code,
19036                NodeFailureCode::BrowserStationProbeUnavailable
19037            );
19038        }
19039    }
19040
19041    /// Feature on: omitting browser_profile_id still resolves (no probe).
19042    #[cfg(feature = "dig2-station-probe")]
19043    #[test]
19044    fn resolve_environment_profile_skips_station_probe_when_browser_profile_omitted() {
19045        let (shared, defaults) = station_knobs_fixture();
19046        let spec = crate::protocol::SpawnSpec {
19047            target: crate::protocol::SpawnTarget {
19048                node_id: shared.node_id.clone(),
19049                workspace_id: WorkspaceId::new("primary").unwrap(),
19050                worktree_id: None,
19051            },
19052            profile_id: defaults.profile_id.clone(),
19053            expected_profile_revision: defaults.revision.clone(),
19054            overrides: crate::protocol::SpawnOverrides::default(),
19055            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19056            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19057                "station-knobs-probe-omit",
19058            )
19059            .unwrap(),
19060            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19061        };
19062        let resolved = spec.resolve(&defaults).unwrap();
19063        let receipt = shared
19064            .resolve_environment_profile(&resolved)
19065            .expect("omit browser_profile must not probe")
19066            .expect("env profile present");
19067        assert_eq!(receipt.browser_profile_id, None);
19068    }
19069
19070    /// Feature dig2-station-probe: exclusive BrowserStationLease on NodeShared
19071    /// refuses a second holder for the same opaque profile id; release on
19072    /// remove_binding allows reacquire. Never ImportSession/cookies on C2.
19073    #[cfg(feature = "dig2-station-probe")]
19074    #[test]
19075    fn browser_station_lease_exclusive_and_clears_on_remove_binding() {
19076        let (shared, _defaults) = station_knobs_fixture();
19077        let profile = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
19078        let workspace_id = WorkspaceId::new("primary").unwrap();
19079        let address_a = SessionAddress {
19080            workspace_id: workspace_id.clone(),
19081            session: SessionKey {
19082                instance_id: AgentInstanceId(101),
19083                generation: SessionGeneration(1),
19084            },
19085        };
19086        let address_b = SessionAddress {
19087            workspace_id: workspace_id.clone(),
19088            session: SessionKey {
19089                instance_id: AgentInstanceId(102),
19090                generation: SessionGeneration(1),
19091            },
19092        };
19093        let guard_a = crate::dig2_station_lease::acquire_lease(
19094            &shared.browser_station_leases,
19095            profile.clone(),
19096            address_a.session.instance_id,
19097            1,
19098        )
19099        .expect("first lease");
19100        let busy = crate::dig2_station_lease::acquire_lease(
19101            &shared.browser_station_leases,
19102            profile.clone(),
19103            address_b.session.instance_id,
19104            2,
19105        )
19106        .expect_err("second holder must refuse");
19107        assert!(matches!(
19108            busy,
19109            crate::dig2_station_lease::BrowserStationLeaseError::ProfileBusy { .. }
19110        ));
19111        // Simulate a bound session so remove_binding clears the lease.
19112        shared.bind_session_with_policy(
19113            &address_a,
19114            ProviderRuntimePolicy::raw_pty(),
19115            None,
19116        );
19117        guard_a.retain();
19118        assert!(shared.remove_binding(&address_a).is_some());
19119        crate::dig2_station_lease::acquire_lease(
19120            &shared.browser_station_leases,
19121            profile,
19122            address_b.session.instance_id,
19123            3,
19124        )
19125        .expect("reacquire after clear")
19126        .retain();
19127        assert!(
19128            crate::dig2_station_lease::release_lease_for_holder(
19129                &shared.browser_station_leases,
19130                address_b.session.instance_id,
19131            )
19132        );
19133    }
19134
19135    /// Feature-off: no browser_station_leases field; stub echo documented in
19136    /// resolve_environment_profile_echoes_registered_station_network_and_browser_knobs.
19137    #[cfg(not(feature = "dig2-station-probe"))]
19138    #[test]
19139    fn browser_station_lease_feature_off_keeps_stub_without_lease_table() {
19140        let (shared, defaults) = station_knobs_fixture();
19141        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
19142        let spec = crate::protocol::SpawnSpec {
19143            target: crate::protocol::SpawnTarget {
19144                node_id: shared.node_id.clone(),
19145                workspace_id: WorkspaceId::new("primary").unwrap(),
19146                worktree_id: None,
19147            },
19148            profile_id: defaults.profile_id.clone(),
19149            expected_profile_revision: defaults.revision.clone(),
19150            overrides: crate::protocol::SpawnOverrides {
19151                browser_profile_id: Some(browser.clone()),
19152                ..crate::protocol::SpawnOverrides::default()
19153            },
19154            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19155            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("lease-stub-off")
19156                .unwrap(),
19157            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19158        };
19159        let resolved = spec.resolve(&defaults).unwrap();
19160        let receipt = shared
19161            .resolve_environment_profile(&resolved)
19162            .expect("stub resolve")
19163            .expect("env profile");
19164        assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
19165    }
19166
19167
19168    /// Startup file loader registers ids into the empty-default catalog.
19169    /// Feature-off dig2 probe stays stub. Never secrets on C2.
19170    #[test]
19171    fn network_allowlist_catalog_loads_from_temp_file() {
19172        let unique = std::time::SystemTime::now()
19173            .duration_since(std::time::UNIX_EPOCH)
19174            .unwrap()
19175            .as_nanos();
19176        let path = std::env::temp_dir().join(format!(
19177            "gate4agent-node-allowlist-config-{}-{unique}.txt",
19178            std::process::id(),
19179        ));
19180        std::fs::write(
19181            &path,
19182            "# opaque station allowlist ids\negress-default\nlab-egress\n",
19183        )
19184        .unwrap();
19185        let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
19186            .expect("temp catalog file must load");
19187        let _ = std::fs::remove_file(&path);
19188        assert_eq!(loaded.len(), 2);
19189        let (shared, defaults) = station_knobs_fixture();
19190        {
19191            let mut catalog = shared
19192                .network_allowlist_catalog
19193                .write()
19194                .unwrap_or_else(|poisoned| poisoned.into_inner());
19195            *catalog = loaded;
19196        }
19197        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19198        let spec = crate::protocol::SpawnSpec {
19199            target: crate::protocol::SpawnTarget {
19200                node_id: shared.node_id.clone(),
19201                workspace_id: WorkspaceId::new("primary").unwrap(),
19202                worktree_id: None,
19203            },
19204            profile_id: defaults.profile_id.clone(),
19205            expected_profile_revision: defaults.revision.clone(),
19206            overrides: crate::protocol::SpawnOverrides {
19207                network_allowlist: Some(allowlist.clone()),
19208                ..crate::protocol::SpawnOverrides::default()
19209            },
19210            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19211            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19212                "station-knobs-catalog-file",
19213            )
19214            .unwrap(),
19215            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19216        };
19217        let resolved = spec.resolve(&defaults).unwrap();
19218        let receipt = shared
19219            .resolve_environment_profile(&resolved)
19220            .expect("registered via file load")
19221            .expect("env profile present");
19222        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
19223    }
19224
19225    /// Snapshot launch inventory echoes registered catalog ids (opaque).
19226    /// Dig2 station probe is orthogonal (feature `dig2-station-probe`). Never secrets on C2.
19227    #[test]
19228    fn snapshot_launch_inventory_lists_network_allowlist_catalog_ids() {
19229        let (shared, _defaults) = station_knobs_fixture();
19230        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19231        shared
19232            .network_allowlist_catalog
19233            .write()
19234            .unwrap_or_else(|poisoned| poisoned.into_inner())
19235            .insert_id(allowlist.clone())
19236            .unwrap();
19237        let snapshot = shared.snapshot();
19238        let inventory = snapshot
19239            .launch_inventory
19240            .expect("launch inventory present");
19241        let ids = inventory
19242            .network_allowlists
19243            .expect("non-empty catalog surfaces ids");
19244        assert_eq!(ids, vec![allowlist]);
19245    }
19246
19247    /// Track B: registered id with provider_native codex_network_access refuses
19248    /// clearly for Claude (fixture provider). Unknown id still UnknownNetworkAllowlist.
19249    /// Never secrets on C2. Codex Moderate maps to `-c` overlay; RO/FullAuto refuse.
19250    #[test]
19251    fn resolve_environment_profile_refuses_unsupported_provider_native_mapping() {
19252        let (shared, defaults) = station_knobs_fixture();
19253        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-only").unwrap();
19254        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19255            id: allowlist.clone(),
19256            permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
19257                protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19258                peer: "127.0.0.1:443".to_owned(),
19259            }],
19260            provider_native: Some(
19261                crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
19262                    codex_network_access: Some(true),
19263                },
19264            ),
19265        };
19266        shared
19267            .network_allowlist_catalog
19268            .write()
19269            .unwrap_or_else(|poisoned| poisoned.into_inner())
19270            .insert(entry)
19271            .unwrap();
19272        let spec = crate::protocol::SpawnSpec {
19273            target: crate::protocol::SpawnTarget {
19274                node_id: shared.node_id.clone(),
19275                workspace_id: WorkspaceId::new("primary").unwrap(),
19276                worktree_id: None,
19277            },
19278            profile_id: defaults.profile_id.clone(),
19279            expected_profile_revision: defaults.revision.clone(),
19280            overrides: crate::protocol::SpawnOverrides {
19281                network_allowlist: Some(allowlist),
19282                ..crate::protocol::SpawnOverrides::default()
19283            },
19284            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19285            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19286                "station-knobs-unsupported-mapping",
19287            )
19288            .unwrap(),
19289            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19290        };
19291        let resolved = spec.resolve(&defaults).unwrap();
19292        assert_eq!(resolved.provider.as_str(), "claude");
19293        let failure = shared
19294            .resolve_environment_profile(&resolved)
19295            .expect_err("claude + codex_network_access must refuse");
19296        assert_eq!(
19297            failure.code,
19298            NodeFailureCode::UnsupportedNetworkAllowlistMapping
19299        );
19300    }
19301
19302    /// Codex Moderate + codex_network_access yields `-c` overlay argv.
19303    /// ReadOnly / FullAuto refuse UnsupportedNetworkAllowlistMapping.
19304    /// Never secrets on C2.
19305    #[test]
19306    fn resolve_codex_network_access_overlay_moderate_only() {
19307        let (shared, defaults) = station_knobs_fixture();
19308        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-net").unwrap();
19309        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19310            id: allowlist.clone(),
19311            permits: Vec::new(),
19312            provider_native: Some(
19313                crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
19314                    codex_network_access: Some(true),
19315                },
19316            ),
19317        };
19318        shared
19319            .network_allowlist_catalog
19320            .write()
19321            .unwrap_or_else(|poisoned| poisoned.into_inner())
19322            .insert(entry)
19323            .unwrap();
19324
19325        let overlay = shared
19326            .resolve_codex_network_access_overlay_args(
19327                &agent("codex"),
19328                SessionMode::Pty,
19329                ApprovalLevel::Moderate,
19330                Some(&allowlist),
19331            )
19332            .expect("Moderate Codex maps network_access");
19333        assert_eq!(
19334            overlay,
19335            vec![
19336                std::ffi::OsString::from("-c"),
19337                std::ffi::OsString::from("sandbox_workspace_write.network_access=true"),
19338            ]
19339        );
19340
19341        let acp = shared
19342            .resolve_codex_network_access_overlay_args(
19343                &agent("codex"),
19344                SessionMode::Acp,
19345                ApprovalLevel::Moderate,
19346                Some(&allowlist),
19347            )
19348            .unwrap();
19349        assert_eq!(acp, overlay);
19350
19351        let inline = shared
19352            .resolve_codex_network_access_overlay_args(
19353                &agent("codex"),
19354                SessionMode::Inline,
19355                ApprovalLevel::Moderate,
19356                Some(&allowlist),
19357            )
19358            .unwrap_err();
19359        assert_eq!(
19360            inline.code,
19361            NodeFailureCode::UnsupportedNetworkAllowlistMapping
19362        );
19363
19364        for level in [ApprovalLevel::ReadOnly, ApprovalLevel::FullAuto] {
19365            let spec = crate::protocol::SpawnSpec {
19366                target: crate::protocol::SpawnTarget {
19367                    node_id: shared.node_id.clone(),
19368                    workspace_id: WorkspaceId::new("primary").unwrap(),
19369                    worktree_id: None,
19370                },
19371                profile_id: defaults.profile_id.clone(),
19372                expected_profile_revision: defaults.revision.clone(),
19373                overrides: crate::protocol::SpawnOverrides {
19374                    provider: crate::protocol::SpawnOverride::Set {
19375                        value: agent("codex"),
19376                    },
19377                    approval_level: Some(level),
19378                    network_allowlist: Some(allowlist.clone()),
19379                    ..crate::protocol::SpawnOverrides::default()
19380                },
19381                deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19382                idempotency_key: crate::protocol::SpawnIdempotencyKey::new(format!(
19383                    "codex-net-refuse-{}",
19384                    match level {
19385                        ApprovalLevel::ReadOnly => "ro",
19386                        ApprovalLevel::FullAuto => "fa",
19387                        _ => "other",
19388                    }
19389                ))
19390                .unwrap(),
19391                required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19392            };
19393            let resolved = spec.resolve(&defaults).unwrap();
19394            let failure = shared
19395                .resolve_environment_profile(&resolved)
19396                .expect_err("RO/FullAuto must refuse");
19397            assert_eq!(
19398                failure.code,
19399                NodeFailureCode::UnsupportedNetworkAllowlistMapping
19400            );
19401        }
19402    }
19403
19404    /// Claude PTY + catalog permits → ProviderHome `--settings` sidecar with
19405    /// sandbox.network.allowedDomains. ACP / missing ProviderHome refuse.
19406    /// Never secrets on C2; does not invent provider_native Claude keys.
19407    #[test]
19408    fn resolve_claude_settings_network_overlay_pty_writes_sidecar() {
19409        let (shared, _defaults) = station_knobs_fixture();
19410        let allowlist =
19411            crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-domains").unwrap();
19412        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19413            id: allowlist.clone(),
19414            permits: vec![
19415                crate::network_allowlist_catalog::NetworkPermitSketch {
19416                    protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19417                    peer: "github.com:443".to_owned(),
19418                },
19419                crate::network_allowlist_catalog::NetworkPermitSketch {
19420                    protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19421                    peer: "*.npmjs.org".to_owned(),
19422                },
19423            ],
19424            provider_native: None,
19425        };
19426        shared
19427            .network_allowlist_catalog
19428            .write()
19429            .unwrap_or_else(|poisoned| poisoned.into_inner())
19430            .insert(entry)
19431            .unwrap();
19432
19433        let unique = std::time::SystemTime::now()
19434            .duration_since(std::time::UNIX_EPOCH)
19435            .unwrap()
19436            .as_nanos();
19437        let provider_home = std::env::temp_dir().join(format!(
19438            "gate4agent-claude-provider-home-{}-{unique}",
19439            std::process::id(),
19440        ));
19441        std::fs::create_dir_all(&provider_home).unwrap();
19442
19443        let overlay = shared
19444            .resolve_network_allowlist_overlay_args(
19445                &agent("claude"),
19446                SessionMode::Pty,
19447                ApprovalLevel::Moderate,
19448                Some(&allowlist),
19449                Some(provider_home.as_path()),
19450            )
19451            .expect("Claude PTY settings overlay on unix");
19452        let settings_path = provider_home.join(
19453            crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
19454        );
19455        assert_eq!(
19456            overlay,
19457            vec![
19458                std::ffi::OsString::from("--settings"),
19459                std::ffi::OsString::from(settings_path.as_os_str()),
19460            ]
19461        );
19462        let body = std::fs::read_to_string(&settings_path).unwrap();
19463        let value: serde_json::Value = serde_json::from_str(&body).unwrap();
19464        assert_eq!(value["sandbox"]["enabled"], true);
19465        assert_eq!(
19466            value["sandbox"]["network"]["allowedDomains"],
19467            serde_json::json!(["github.com", "*.npmjs.org"])
19468        );
19469        assert!(settings_path.starts_with(&provider_home));
19470
19471        let acp = shared
19472            .resolve_network_allowlist_overlay_args(
19473                &agent("claude"),
19474                SessionMode::Acp,
19475                ApprovalLevel::Moderate,
19476                Some(&allowlist),
19477                Some(provider_home.as_path()),
19478            )
19479            .expect_err("Claude ACP must refuse settings overlay");
19480        assert_eq!(
19481            acp.code,
19482            NodeFailureCode::UnsupportedNetworkAllowlistMapping
19483        );
19484
19485        let no_home = shared
19486            .resolve_network_allowlist_overlay_args(
19487                &agent("claude"),
19488                SessionMode::Pty,
19489                ApprovalLevel::Moderate,
19490                Some(&allowlist),
19491                None,
19492            )
19493            .expect_err("Claude overlay requires ProviderHome");
19494        assert_eq!(
19495            no_home.code,
19496            NodeFailureCode::UnsupportedNetworkAllowlistMapping
19497        );
19498
19499        let bare = crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-bare").unwrap();
19500        shared
19501            .network_allowlist_catalog
19502            .write()
19503            .unwrap_or_else(|poisoned| poisoned.into_inner())
19504            .insert_id(bare.clone())
19505            .unwrap();
19506        let empty = shared
19507            .resolve_network_allowlist_overlay_args(
19508                &agent("claude"),
19509                SessionMode::Pty,
19510                ApprovalLevel::Moderate,
19511                Some(&bare),
19512                Some(provider_home.as_path()),
19513            )
19514            .unwrap();
19515        assert!(empty.is_empty());
19516
19517        let _ = std::fs::remove_dir_all(&provider_home);
19518    }
19519
19520    /// Kimi / Grok with permits stay without invented argv overlays.
19521    #[test]
19522    fn resolve_kimi_grok_network_overlay_stays_empty_without_native() {
19523        let (shared, _defaults) = station_knobs_fixture();
19524        let allowlist =
19525            crate::protocol::SpawnNetworkAllowlistId::new("egress-kimi-permits").unwrap();
19526        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19527            id: allowlist.clone(),
19528            permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
19529                protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19530                peer: "example.com:443".to_owned(),
19531            }],
19532            provider_native: None,
19533        };
19534        shared
19535            .network_allowlist_catalog
19536            .write()
19537            .unwrap_or_else(|poisoned| poisoned.into_inner())
19538            .insert(entry)
19539            .unwrap();
19540        for provider in ["kimi", "grok"] {
19541            let overlay = shared
19542                .resolve_network_allowlist_overlay_args(
19543                    &agent(provider),
19544                    SessionMode::Pty,
19545                    ApprovalLevel::Moderate,
19546                    Some(&allowlist),
19547                    None,
19548                )
19549                .unwrap();
19550            assert!(
19551                overlay.is_empty(),
19552                "{provider} must not invent network argv from permits"
19553            );
19554        }
19555    }
19556
19557    /// Track B: JSON v2 catalog file loads permits; inventory still ids only.
19558    #[test]
19559    fn network_allowlist_catalog_loads_json_v2_and_lists_ids_only() {
19560        let unique = std::time::SystemTime::now()
19561            .duration_since(std::time::UNIX_EPOCH)
19562            .unwrap()
19563            .as_nanos();
19564        let path = std::env::temp_dir().join(format!(
19565            "gate4agent-node-allowlist-json-{}-{unique}.json",
19566            std::process::id(),
19567        ));
19568        std::fs::write(
19569            &path,
19570            r#"{
19571              "schema_version": 2,
19572              "entries": [
19573                {
19574                  "id": "egress-default",
19575                  "permits": [{ "protocol": "tcp", "peer": "127.0.0.1:443" }]
19576                },
19577                { "id": "lab-egress" }
19578              ]
19579            }"#,
19580        )
19581        .unwrap();
19582        let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
19583            .expect("json v2 catalog must load");
19584        let _ = std::fs::remove_file(&path);
19585        assert_eq!(loaded.len(), 2);
19586        let entry = loaded
19587            .get(&crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap())
19588            .unwrap();
19589        assert_eq!(entry.permits.len(), 1);
19590        let (shared, defaults) = station_knobs_fixture();
19591        {
19592            let mut catalog = shared
19593                .network_allowlist_catalog
19594                .write()
19595                .unwrap_or_else(|poisoned| poisoned.into_inner());
19596            *catalog = loaded;
19597        }
19598        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19599        let spec = crate::protocol::SpawnSpec {
19600            target: crate::protocol::SpawnTarget {
19601                node_id: shared.node_id.clone(),
19602                workspace_id: WorkspaceId::new("primary").unwrap(),
19603                worktree_id: None,
19604            },
19605            profile_id: defaults.profile_id.clone(),
19606            expected_profile_revision: defaults.revision.clone(),
19607            overrides: crate::protocol::SpawnOverrides {
19608                network_allowlist: Some(allowlist.clone()),
19609                ..crate::protocol::SpawnOverrides::default()
19610            },
19611            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19612            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19613                "station-knobs-catalog-json",
19614            )
19615            .unwrap(),
19616            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19617        };
19618        let resolved = spec.resolve(&defaults).unwrap();
19619        let receipt = shared
19620            .resolve_environment_profile(&resolved)
19621            .expect("permits-only entry ok for claude")
19622            .expect("env profile present");
19623        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
19624        let inventory = shared
19625            .snapshot()
19626            .launch_inventory
19627            .expect("launch inventory present");
19628        let ids = inventory.network_allowlists.expect("ids surface");
19629        assert_eq!(
19630            ids,
19631            vec![
19632                crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap(),
19633                crate::protocol::SpawnNetworkAllowlistId::new("lab-egress").unwrap(),
19634            ]
19635        );
19636    }
19637
19638
19639}
19640
19641
19642#[cfg(test)]
19643mod workspace_environment_collision_tests {
19644    use super::*;
19645    use crate::session_environment::{
19646        NodeSessionMaterializationProfile, NodeSessionPathBinding, NodeSessionPathClass,
19647    };
19648    use gate4agent_catalog::EnvMutation;
19649    use gate4agent_runtime_native::{
19650        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
19651        NativeLaunchProfileId,
19652    };
19653    use std::ffi::OsString;
19654    use std::sync::Arc;
19655
19656    struct EmptyEnvironmentResolver;
19657
19658    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19659        fn resolve_child_environment(
19660            &self,
19661        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19662            Ok(vec![EnvMutation {
19663                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19664                value: None,
19665            }])
19666        }
19667    }
19668
19669    fn agent(value: &str) -> AgentId {
19670        AgentId::new(value).unwrap()
19671    }
19672
19673    /// Two Claude ProviderHome profiles (`CLAUDE_CONFIG_DIR` A vs B) on one node.
19674    fn claude_config_dir_collision_fixture() -> (
19675        NodeShared,
19676        ResolvedEnvironmentProfileReceipt,
19677        ResolvedEnvironmentProfileReceipt,
19678    ) {
19679        let catalog = active_registry().unwrap();
19680        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19681        let control = runtime.native_launch_profile_control();
19682        let workspace = WorkspaceConfig::new(
19683            WorkspaceId::new("primary").unwrap(),
19684            std::env::current_dir().unwrap(),
19685        )
19686        .unwrap();
19687        let shared = NodeShared::new_with_incarnation(
19688            handle,
19689            "fixture-token".to_owned(),
19690            NodeId::new("node-env-collision-test").unwrap(),
19691            NodeIncarnationId::from_bytes([3; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19692            vec![workspace],
19693            vec![agent("claude")],
19694            ProviderRuntimeStatuses::default(),
19695            None,
19696            Vec::new(),
19697            Vec::new(),
19698            SpawnProfileRegistry::default(),
19699            Some(control),
19700            None,
19701            None,
19702            None,
19703            Vec::new(),
19704            Vec::new(),
19705            Vec::new(),
19706            Vec::new(),
19707            None,
19708            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19709        );
19710
19711        let mut receipts = Vec::new();
19712        for (suffix, native_suffix) in [("a", "a-pty"), ("b", "b-pty")] {
19713            let profile_id =
19714                SpawnEnvironmentProfileId::new(format!("claude-config-dir-{suffix}")).unwrap();
19715            let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(format!(
19716                "claude-config-dir-{suffix}-r1"
19717            ))
19718            .unwrap();
19719            let materialization = NodeSessionMaterializationProfile::new(
19720                Vec::new(),
19721                vec![NodeSessionPathBinding::new(
19722                    "CLAUDE_CONFIG_DIR",
19723                    NodeSessionPathClass::ProviderHome,
19724                )
19725                .unwrap()],
19726                Vec::new(),
19727            )
19728            .unwrap();
19729            let node_profile = NodeEnvironmentProfile::new_with_materialization(
19730                profile_id.clone(),
19731                profile_revision.clone(),
19732                agent("claude"),
19733                [NativeLaunchProfile::new(
19734                    NativeLaunchProfileId::new(format!("claude-config-dir-{native_suffix}"))
19735                        .unwrap(),
19736                    agent("claude"),
19737                    TransportKind::Pty,
19738                    vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19739                    Arc::new(EmptyEnvironmentResolver),
19740                )
19741                .unwrap()],
19742                Some(materialization),
19743            )
19744            .unwrap();
19745            let (binding, native_profiles, materialization) = node_profile.into_parts();
19746            for native_profile in native_profiles {
19747                runtime.upsert_native_launch_profile(native_profile).unwrap();
19748            }
19749            shared
19750                .environment_profiles
19751                .write()
19752                .unwrap_or_else(|poisoned| poisoned.into_inner())
19753                .insert(binding.id.clone(), binding);
19754            shared
19755                .environment_materialization_profiles
19756                .write()
19757                .unwrap_or_else(|poisoned| poisoned.into_inner())
19758                .insert(profile_id.clone(), materialization.unwrap());
19759            receipts.push(ResolvedEnvironmentProfileReceipt {
19760                profile_id,
19761                profile_revision,
19762                network_allowlist: None,
19763                browser_profile_id: None,
19764            });
19765        }
19766        let profile_b = receipts.pop().unwrap();
19767        let profile_a = receipts.pop().unwrap();
19768        (shared, profile_a, profile_b)
19769    }
19770
19771    #[test]
19772    fn refuse_distinct_claude_config_dir_profiles_on_same_workspace_cwd() {
19773        let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
19774        let primary = WorkspaceId::new("primary").unwrap();
19775        let worktree = WorkspaceId::new("worktree-b").unwrap();
19776        let address = SessionAddress {
19777            workspace_id: primary.clone(),
19778            session: SessionKey {
19779                instance_id: AgentInstanceId(41),
19780                generation: SessionGeneration(1),
19781            },
19782        };
19783        shared.bind_session_with_policy(
19784            &address,
19785            ProviderRuntimePolicy::raw_pty(),
19786            Some(profile_a.clone()),
19787        );
19788
19789        let refused = shared
19790            .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_b))
19791            .unwrap_err();
19792        assert_eq!(refused.code, NodeFailureCode::WorkspaceBusy);
19793
19794        // Some vs None on the same cwd is also a collision.
19795        let refused_none = shared
19796            .ensure_workspace_environment_profile_compatible(&primary, None)
19797            .unwrap_err();
19798        assert_eq!(refused_none.code, NodeFailureCode::WorkspaceBusy);
19799
19800        // Same profile on the same workspace remains allowed.
19801        shared
19802            .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_a))
19803            .unwrap();
19804
19805        // Distinct workspace id (managed-worktree parallel path) allows profile B.
19806        shared
19807            .ensure_workspace_environment_profile_compatible(&worktree, Some(&profile_b))
19808            .unwrap();
19809    }
19810
19811    #[tokio::test]
19812    async fn spawn_session_refuses_conflicting_environment_profile_before_mutation() {
19813        let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
19814        let primary = WorkspaceId::new("primary").unwrap();
19815        let address = SessionAddress {
19816            workspace_id: primary.clone(),
19817            session: SessionKey {
19818                instance_id: AgentInstanceId(41),
19819                generation: SessionGeneration(1),
19820            },
19821        };
19822        shared.bind_session_with_policy(
19823            &address,
19824            ProviderRuntimePolicy::raw_pty(),
19825            Some(profile_a),
19826        );
19827
19828        let failure = shared
19829            .spawn_session_with_deadline(
19830                primary,
19831                agent("claude"),
19832                SessionMode::Pty,
19833                gate4agent_types::TerminalSize {
19834                    rows: 24,
19835                    columns: 80,
19836                },
19837                None,
19838                Some(profile_b),
19839                None,
19840                None,
19841                None,
19842                None,
19843                SpawnRecordPolicy::ProviderIdentityOnly,
19844                Some(Instant::now() + Duration::from_secs(30)),
19845                &[],
19846                None,
19847                ApprovalLevel::default(),
19848                None,
19849                None,
19850            )
19851            .await
19852            .unwrap_err();
19853        assert_eq!(failure.code, NodeFailureCode::WorkspaceBusy);
19854        // Guard runs before instance id mint / bind.
19855        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
19856        assert!(shared.handle.snapshot().sessions.is_empty());
19857    }
19858}
19859
19860#[cfg(test)]
19861mod spawn_characterization_tests {
19862    use super::*;
19863    use crate::session_environment::{
19864        NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
19865        NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
19866        NodeSessionPathClass,
19867    };
19868    use gate4agent_catalog::EnvMutation;
19869    use gate4agent_runtime_native::{
19870        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
19871        NativeLaunchProfile, NativeLaunchProfileId,
19872    };
19873    use std::ffi::OsString;
19874    use std::sync::Arc;
19875
19876    struct EmptyEnvironmentResolver;
19877
19878    struct FixtureSecretResolver {
19879        deny: Arc<AtomicBool>,
19880    }
19881
19882    impl NodeSecretResolver for FixtureSecretResolver {
19883        fn resolve(
19884            &self,
19885            _reference: &NodeSecretReference,
19886        ) -> Result<NodeSecretValue, NodeSecretResolveError> {
19887            if self.deny.load(Ordering::Acquire) {
19888                Err(NodeSecretResolveError::Denied)
19889            } else {
19890                NodeSecretValue::text("fixture-secret")
19891                    .map_err(|_| NodeSecretResolveError::Unavailable)
19892            }
19893        }
19894    }
19895
19896    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19897        fn resolve_child_environment(
19898            &self,
19899        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19900            Ok(vec![EnvMutation {
19901                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19902                value: None,
19903            }])
19904        }
19905    }
19906
19907    fn agent(value: &str) -> AgentId {
19908        AgentId::new(value).unwrap()
19909    }
19910
19911    fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
19912        let unique = std::time::SystemTime::now()
19913            .duration_since(std::time::UNIX_EPOCH)
19914            .unwrap()
19915            .as_nanos();
19916        std::env::temp_dir().join(format!(
19917            "gate4agent-node-{label}-{}-{unique}",
19918            std::process::id(),
19919        ))
19920    }
19921
19922    fn materialization_test_shared(
19923        deny_secret: bool,
19924    ) -> (
19925        NodeShared,
19926        NativeRuntime,
19927        ResolvedEnvironmentProfileReceipt,
19928        PathBuf,
19929        Arc<AtomicBool>,
19930    ) {
19931        let workspace = WorkspaceConfig::new(
19932            WorkspaceId::new("primary").unwrap(),
19933            std::env::current_dir().unwrap(),
19934        )
19935        .unwrap();
19936        materialization_test_shared_with_workspace(deny_secret, workspace)
19937    }
19938
19939    fn materialization_test_shared_with_workspace(
19940        deny_secret: bool,
19941        workspace: WorkspaceConfig,
19942    ) -> (
19943        NodeShared,
19944        NativeRuntime,
19945        ResolvedEnvironmentProfileReceipt,
19946        PathBuf,
19947        Arc<AtomicBool>,
19948    ) {
19949        let root = temporary_workspace_root(if deny_secret {
19950            "materialization-denied"
19951        } else {
19952            "materialization-ready"
19953        });
19954        std::fs::create_dir_all(&root).unwrap();
19955        let materialization_root = root.join("session-environments");
19956        let deny = Arc::new(AtomicBool::new(deny_secret));
19957        let resolver = Arc::new(FixtureSecretResolver {
19958            deny: Arc::clone(&deny),
19959        });
19960        let materializer = SessionEnvironmentMaterializer::new(
19961            materialization_root,
19962            resolver,
19963        )
19964        .unwrap();
19965        let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
19966        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19967            "materialized-claude-r1",
19968        )
19969        .unwrap();
19970        let materialization = NodeSessionMaterializationProfile::new(
19971            vec![NodeSessionEnvironmentMutation::SetSecret {
19972                key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
19973                reference: NodeSecretReference::new("fixture-token").unwrap(),
19974            }],
19975            vec![NodeSessionPathBinding::new(
19976                "GATE4AGENT_PROVIDER_HOME",
19977                NodeSessionPathClass::ProviderHome,
19978            )
19979            .unwrap()],
19980            vec![NodeSessionFile::secret(
19981                NodeSessionPathClass::Config,
19982                "auth/token",
19983                NodeSecretReference::new("fixture-token").unwrap(),
19984            )
19985            .unwrap()],
19986        )
19987        .unwrap();
19988        let catalog = active_registry().unwrap();
19989        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19990        let control = runtime.native_launch_profile_control();
19991        let node_profile = NodeEnvironmentProfile::new_with_materialization(
19992            profile_id.clone(),
19993            profile_revision.clone(),
19994            agent("claude"),
19995            [NativeLaunchProfile::new(
19996                NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
19997                agent("claude"),
19998                TransportKind::Pty,
19999                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20000                Arc::new(EmptyEnvironmentResolver),
20001            )
20002            .unwrap()],
20003            Some(materialization),
20004        )
20005        .unwrap();
20006        let (binding, native_profiles, materialization) = node_profile.into_parts();
20007        for native_profile in native_profiles {
20008            runtime.upsert_native_launch_profile(native_profile).unwrap();
20009        }
20010        let shared = NodeShared::new_with_incarnation(
20011            handle,
20012            "fixture-token".to_owned(),
20013            NodeId::new("node-materialization-test").unwrap(),
20014            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20015            vec![workspace],
20016            vec![agent("claude")],
20017            ProviderRuntimeStatuses::default(),
20018            None,
20019            Vec::new(),
20020            Vec::new(),
20021            SpawnProfileRegistry::default(),
20022            Some(control),
20023            None,
20024            Some(materializer),
20025            None,
20026            Vec::new(),
20027            Vec::new(),
20028            Vec::new(),
20029            Vec::new(),
20030            None,
20031            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20032        );
20033        shared
20034            .environment_profiles
20035            .write()
20036            .unwrap_or_else(|poisoned| poisoned.into_inner())
20037            .insert(binding.id.clone(), binding);
20038        shared
20039            .environment_materialization_profiles
20040            .write()
20041            .unwrap_or_else(|poisoned| poisoned.into_inner())
20042            .insert(profile_id.clone(), materialization.unwrap());
20043        (
20044            shared,
20045            runtime,
20046            ResolvedEnvironmentProfileReceipt {
20047                profile_id,
20048                profile_revision,
20049                network_allowlist: None,
20050                browser_profile_id: None,
20051            },
20052            root,
20053            deny,
20054        )
20055    }
20056
20057
20058    // ---- Characterization of the spawn preparation block (pre-SpawnPreparer) ----
20059
20060    async fn characterization_spawn(
20061        shared: &NodeShared,
20062        mode: SessionMode,
20063        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
20064        required: &[ProviderRuntimeCapability],
20065        harness_mcp: Option<&PreparedHarnessMcpSpawn>,
20066    ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
20067        shared
20068            .spawn_session_with_deadline(
20069                WorkspaceId::new("primary").unwrap(),
20070                agent("claude"),
20071                mode,
20072                gate4agent_types::TerminalSize { rows: 24, columns: 80 },
20073                None,
20074                environment_profile,
20075                None,
20076                None,
20077                None,
20078                Some(ProviderRuntimePolicy::raw_pty()),
20079                SpawnRecordPolicy::ProviderIdentityOnly,
20080                Some(Instant::now() + Duration::from_secs(30)),
20081                required,
20082                harness_mcp,
20083                ApprovalLevel::default(),
20084                None,
20085                None,
20086            )
20087            .await
20088    }
20089
20090    fn assert_no_session_state(shared: &NodeShared) {
20091        assert!(shared.handle.snapshot().sessions.is_empty());
20092        assert!(shared
20093            .session_bindings
20094            .lock()
20095            .unwrap_or_else(|poisoned| poisoned.into_inner())
20096            .is_empty());
20097        assert!(shared
20098            .materializations
20099            .lock()
20100            .unwrap_or_else(|poisoned| poisoned.into_inner())
20101            .is_empty());
20102    }
20103
20104    #[tokio::test]
20105    async fn spawn_unadmitted_required_capability_refuses_before_id_mint() {
20106        let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
20107        let error = characterization_spawn(
20108            &shared,
20109            SessionMode::Pty,
20110            None,
20111            &[ProviderRuntimeCapability::SemanticReadiness],
20112            None,
20113        )
20114        .await
20115        .unwrap_err();
20116        assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
20117        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
20118        assert_no_session_state(&shared);
20119        drop(shared);
20120        drop(runtime);
20121        std::fs::remove_dir_all(root).unwrap();
20122    }
20123
20124    #[tokio::test]
20125    async fn spawn_denied_environment_materialization_fails_after_id_mint_with_no_state() {
20126        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
20127        let error = characterization_spawn(&shared, SessionMode::Pty, Some(receipt), &[], None)
20128            .await
20129            .unwrap_err();
20130        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
20131        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 2);
20132        assert_no_session_state(&shared);
20133        drop(shared);
20134        drop(runtime);
20135        std::fs::remove_dir_all(root).unwrap();
20136    }
20137
20138    #[test]
20139    fn remove_binding_returns_binding_and_is_idempotent_for_environment_session() {
20140        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20141        let address = SessionAddress {
20142            workspace_id: WorkspaceId::new("primary").unwrap(),
20143            session: SessionKey {
20144                instance_id: AgentInstanceId(77),
20145                generation: SessionGeneration(1),
20146            },
20147        };
20148        shared.bind_session_with_policy(&address, ProviderRuntimePolicy::raw_pty(), Some(receipt));
20149        let removed = shared.remove_binding(&address).expect("binding removed");
20150        assert!(removed.environment_profile.is_some());
20151        assert!(shared.remove_binding(&address).is_none());
20152        assert_no_session_state(&shared);
20153        drop(shared);
20154        drop(runtime);
20155        std::fs::remove_dir_all(root).unwrap();
20156    }
20157
20158    async fn characterization_prepared_harness(provider: &str) -> PreparedHarnessMcpSpawn {
20159        use crate::harness_mcp_proxy::{HarnessMcpProxyRegistry, ReviewedHarnessMcpProgram};
20160        use crate::protocol::{HarnessMcpActivationDigest, HarnessMcpLaunchV1, HarnessMcpReservationId};
20161        let helper = ReviewedHarnessMcpProgram::review(std::env::current_exe().unwrap())
20162            .expect("review current test executable as fixture helper");
20163        let registry = HarnessMcpProxyRegistry::new(helper);
20164        let spec = crate::protocol::SpawnSpec {
20165            target: crate::protocol::SpawnTarget {
20166                node_id: NodeId::new("node-characterization").unwrap(),
20167                workspace_id: WorkspaceId::new("primary").unwrap(),
20168                worktree_id: None,
20169            },
20170            profile_id: crate::protocol::SpawnProfileId::new("claude").unwrap(),
20171            expected_profile_revision: crate::protocol::SpawnProfileRevision::new("r1").unwrap(),
20172            overrides: crate::protocol::SpawnOverrides::default(),
20173            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
20174            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("characterization-harness").unwrap(),
20175            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
20176        };
20177        let reservation = HarnessMcpReservationId::new(format!("hmcpres_{}", "c".repeat(24))).unwrap();
20178        let digest = HarnessMcpActivationDigest::new(format!("sha256:{}", "c".repeat(64))).unwrap();
20179        let launch = HarnessMcpLaunchV1 {
20180            server_name: "fixture-harness".to_owned(),
20181            args: vec!["--session-proxy".to_owned()],
20182            endpoint_env: "FIXTURE_SESSION_ENDPOINT".to_owned(),
20183            token_env: "FIXTURE_SESSION_TOKEN".to_owned(),
20184            program_env: "FIXTURE_MCP_PROGRAM".to_owned(),
20185            trace: None,
20186            scrub_env: Vec::new(),
20187        };
20188        let expires = unix_time_ms().saturating_add(30_000);
20189        registry.arm(reservation.clone(), digest.clone(), spec.clone(), launch, expires).await.unwrap();
20190        registry
20191            .prepare_spawn(&reservation, &digest, &spec, expires, agent(provider))
20192            .unwrap()
20193    }
20194
20195    #[tokio::test]
20196    async fn spawn_harness_overlay_refuses_inline_and_provider_mismatch_with_no_state() {
20197        let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
20198        let prepared = characterization_prepared_harness("claude").await;
20199        let error = characterization_spawn(&shared, SessionMode::Inline, None, &[], Some(&prepared))
20200            .await
20201            .unwrap_err();
20202        // Inline is stopped by runtime admission before the harness check runs.
20203        assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
20204        let mismatched = characterization_prepared_harness("codex").await;
20205        let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&mismatched))
20206            .await
20207            .unwrap_err();
20208        assert_eq!(error.code, NodeFailureCode::BindingMismatch);
20209        assert_no_session_state(&shared);
20210        drop(shared);
20211        drop(runtime);
20212        std::fs::remove_dir_all(root).unwrap();
20213    }
20214
20215    #[tokio::test]
20216    async fn spawn_harness_overlay_without_launch_control_is_unavailable_with_no_state() {
20217        let (mut shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
20218        shared.native_launch_profile_control = None;
20219        let prepared = characterization_prepared_harness("claude").await;
20220        let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&prepared))
20221            .await
20222            .unwrap_err();
20223        assert_eq!(error.code, NodeFailureCode::HarnessMcpUnavailable);
20224        assert_no_session_state(&shared);
20225        drop(shared);
20226        drop(runtime);
20227        std::fs::remove_dir_all(root).unwrap();
20228    }
20229}
20230
20231#[cfg(all(test, windows))]
20232mod tests {
20233    use super::*;
20234    use gate4agent_types::ProviderActivity;
20235    use crate::session_environment::{
20236        NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
20237        NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
20238        NodeSessionPathClass,
20239    };
20240    use gate4agent_catalog::EnvMutation;
20241    use gate4agent_runtime_native::{
20242        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
20243        NativeLaunchProfile, NativeLaunchProfileId,
20244    };
20245    use std::ffi::OsString;
20246    use std::sync::Arc;
20247
20248    struct EmptyEnvironmentResolver;
20249
20250    struct FixtureSecretResolver {
20251        deny: Arc<AtomicBool>,
20252    }
20253
20254    impl NodeSecretResolver for FixtureSecretResolver {
20255        fn resolve(
20256            &self,
20257            _reference: &NodeSecretReference,
20258        ) -> Result<NodeSecretValue, NodeSecretResolveError> {
20259            if self.deny.load(Ordering::Acquire) {
20260                Err(NodeSecretResolveError::Denied)
20261            } else {
20262                NodeSecretValue::text("fixture-secret")
20263                    .map_err(|_| NodeSecretResolveError::Unavailable)
20264            }
20265        }
20266    }
20267
20268    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
20269        fn resolve_child_environment(
20270            &self,
20271        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
20272            Ok(vec![EnvMutation {
20273                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
20274                value: None,
20275            }])
20276        }
20277    }
20278
20279    fn agent(value: &str) -> AgentId {
20280        AgentId::new(value).unwrap()
20281    }
20282
20283    fn terminal_test_shared_with_mode(mode: WorktreeServiceMode) -> NodeShared {
20284        let catalog = active_registry().unwrap();
20285        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20286        let workspace = WorkspaceConfig::new(
20287            WorkspaceId::new("primary").unwrap(),
20288            std::env::current_dir().unwrap(),
20289        )
20290        .unwrap()
20291        .with_worktree_service_mode(mode);
20292        NodeShared::new(
20293            handle,
20294            "fixture-token".to_owned(),
20295            NodeId::new("node-terminal-test").unwrap(),
20296            vec![workspace],
20297            vec![agent("claude")],
20298        )
20299    }
20300
20301    fn terminal_test_shared() -> NodeShared {
20302        terminal_test_shared_with_mode(WorktreeServiceMode::Manual)
20303    }
20304
20305    #[cfg(feature = "fixture")]
20306    #[test]
20307    fn managed_worktree_v2_failure_probe_keeps_only_the_latest_code() {
20308        let probe = SpawnManagedWorktreeV2FailureProbe::default();
20309        assert_eq!(probe.latest_code(), None);
20310
20311        probe.record(Some(NodeFailureCode::ManagedWorktreeOwnershipConflict));
20312        assert_eq!(
20313            probe.latest_code(),
20314            Some(NodeFailureCode::ManagedWorktreeOwnershipConflict),
20315        );
20316
20317        probe.record(Some(NodeFailureCode::SpawnDeadlineExceeded));
20318        assert_eq!(
20319            probe.latest_code(),
20320            Some(NodeFailureCode::SpawnDeadlineExceeded),
20321        );
20322
20323        probe.record(None);
20324        assert_eq!(probe.latest_code(), None);
20325    }
20326
20327    #[test]
20328    fn launch_snapshot_is_authoritative_bounded_and_path_free() {
20329        let source = temporary_workspace_root("launch-inventory-source");
20330        let allocation = temporary_workspace_root("launch-inventory-allocation");
20331        std::fs::create_dir_all(&source).unwrap();
20332        std::fs::create_dir_all(&allocation).unwrap();
20333        let workspace_id = WorkspaceId::new("primary").unwrap();
20334        let mut workspace = WorkspaceConfig::new(workspace_id.clone(), &source)
20335            .unwrap()
20336            .with_worktree_service_mode(WorktreeServiceMode::Managed);
20337        for index in 0..MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE {
20338            workspace = workspace
20339                .with_managed_worktree_profile(
20340                    ManagedWorktreeProfile::new(
20341                        WorktreeProfileId::new(format!("profile-{index}")).unwrap(),
20342                        crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
20343                        &allocation,
20344                        "gate4agent",
20345                        "HEAD",
20346                        ManagedWorktreeRetention::RemoveWhenReleased,
20347                    )
20348                    .unwrap(),
20349                )
20350                .unwrap();
20351        }
20352        let overflow = workspace.clone().with_managed_worktree_profile(
20353            ManagedWorktreeProfile::new(
20354                WorktreeProfileId::new("overflow").unwrap(),
20355                crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
20356                &allocation,
20357                "gate4agent",
20358                "HEAD",
20359                ManagedWorktreeRetention::Retain,
20360            )
20361            .unwrap(),
20362        );
20363        assert!(matches!(
20364            overflow,
20365            Err(NodeServerError::ManagedWorktreeProfileCapacity {
20366                max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
20367                ..
20368            }),
20369        ));
20370
20371        let catalog = active_registry().unwrap();
20372        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20373        let shared = NodeShared::new(
20374            handle,
20375            "fixture-token".to_owned(),
20376            NodeId::new("node-launch-inventory").unwrap(),
20377            vec![workspace],
20378            vec![agent("claude")],
20379        );
20380        let snapshot = shared.snapshot();
20381        let launch = snapshot.launch_inventory.as_ref().unwrap();
20382        assert_eq!(launch.spawn_profiles.as_ref().unwrap().len(), 1);
20383        assert!(launch.bundles.as_ref().unwrap().is_empty());
20384        let profiles = snapshot.workspaces[0]
20385            .managed_worktree_profiles
20386            .as_ref()
20387            .unwrap();
20388        assert_eq!(
20389            snapshot.workspaces[0].worktree_service_mode,
20390            Some(WorktreeServiceMode::Managed),
20391        );
20392        assert_eq!(
20393            profiles.profiles.len(),
20394            MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
20395        );
20396        let public_inventory = serde_json::to_string(&(launch, profiles)).unwrap();
20397        for forbidden in ["allocation_root", "branch_prefix", "base", "prompt", "environment", "home"] {
20398            assert!(!public_inventory.contains(forbidden), "launch inventory leaked {forbidden}");
20399        }
20400        let legacy = snapshot_for_wire(
20401            &shared,
20402            true,
20403            true,
20404            false,
20405            false,
20406            true,
20407            false,
20408            true,
20409            false,
20410            true,
20411        );
20412        assert!(legacy.launch_inventory.is_none());
20413        assert!(legacy.workspaces[0].managed_worktree_profiles.is_none());
20414
20415        drop(shared);
20416        std::fs::remove_dir_all(source).unwrap();
20417        std::fs::remove_dir_all(allocation).unwrap();
20418    }
20419
20420    #[test]
20421    fn agent_progress_projection_omits_sensitive_provider_payloads_and_is_capability_gated() {
20422        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20423            capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
20424        }));
20425        let source = gate4agent_types::ProviderSource {
20426            family: AdapterFamily::PtySemantic,
20427            binding: AdapterBinding::new(
20428                gate4agent_types::AdapterId::new("claude").unwrap(),
20429                "fixture/v1",
20430                gate4agent_types::AdapterVerification::SyntheticFixture,
20431            )
20432            .unwrap(),
20433        };
20434        let provider = ProviderSnapshot {
20435            sequence: 17,
20436            session: Some(ProviderSessionIdentity {
20437                key: ProviderSessionKey::SessionId,
20438                id: "PRIVATE_PROVIDER_SESSION".to_owned(),
20439                transcript_path: Some(r"C:\PRIVATE_PROVIDER_PATH".to_owned()),
20440            }),
20441            model: Some("PRIVATE_MODEL".to_owned()),
20442            tools: vec!["PRIVATE_TOOL_INVENTORY".to_owned()],
20443            completed_turns: 3,
20444            usage: gate4agent_types::TokenUsage {
20445                input_tokens: 10,
20446                output_tokens: 20,
20447                cache_read_tokens: 30,
20448                cache_write_tokens: 40,
20449                reasoning_tokens: 50,
20450                context_window: Some(200_000),
20451            },
20452            lead_activity: ProviderActivity::WaitingForInput,
20453            activity: ProviderActivity::WaitingForInput,
20454            current_prompt: Some("PRIVATE_CURRENT_PROMPT".to_owned()),
20455            active_tools: (0..9)
20456                .map(|index| gate4agent_types::ActiveProviderTool {
20457                    id: format!("private-tool-id-{index}"),
20458                    name: if index == 0 {
20459                        format!("  Read\u{0000}{}", "x".repeat(80))
20460                    } else {
20461                        format!("Tool-{index}")
20462                    },
20463                    input_json: "PRIVATE_TOOL_INPUT_JSON".to_owned(),
20464                })
20465                .collect(),
20466            interactions: vec![gate4agent_types::ProviderInteraction {
20467                id: gate4agent_types::ProviderInteractionId(17),
20468                source: source.clone(),
20469                provider_request_id: Some("PRIVATE_PROVIDER_REQUEST".to_owned()),
20470                interaction_kind: ProviderInteractionKind::Approval,
20471                tool_name: " Approve\u{0007}Tool ".to_owned(),
20472                prompt: "PRIVATE_INTERACTION_PROMPT".to_owned(),
20473                agent_id: Some("PRIVATE_INTERACTION_AGENT".to_owned()),
20474                resume_lead_activity: Some(ProviderActivity::Working),
20475                status: ProviderInteractionStatus::Pending,
20476            }],
20477            subagents: vec![gate4agent_types::ProviderSubagent {
20478                source: source.clone(),
20479                provider_agent_id: "PRIVATE_SUBAGENT_ID".to_owned(),
20480                agent_type: Some("PRIVATE_SUBAGENT_TYPE".to_owned()),
20481                description: Some("PRIVATE_SUBAGENT_DESCRIPTION".to_owned()),
20482            }],
20483            sources: vec![gate4agent_types::ProviderSourceCursor {
20484                source,
20485                sequence: 17,
20486                gap_count: 2,
20487                stale: true,
20488            }],
20489            last_event: Some(ProviderEvent::Error {
20490                message: "PRIVATE_PROVIDER_ERROR".to_owned(),
20491            }),
20492            gap_count: 2,
20493            stale: true,
20494        };
20495        let address = terminal_address(3);
20496        let entry = agent_progress_from_provider_snapshot(address.clone(), &provider).unwrap();
20497        assert_eq!(entry.address, address);
20498        assert_eq!(entry.progress.provider_sequence, 17);
20499        assert_eq!(entry.progress.active_tool_count, 9);
20500        assert_eq!(
20501            entry.progress.active_tool_labels.len(),
20502            MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS,
20503        );
20504        assert_eq!(entry.progress.subagent_count, 1);
20505        assert_eq!(entry.progress.last_event_kind, Some(AgentProgressEventKindV1::Error));
20506        assert!(entry.progress.truncated);
20507        let encoded = serde_json::to_string(&entry).unwrap();
20508        for forbidden in [
20509            "PRIVATE_PROVIDER_SESSION",
20510            "PRIVATE_PROVIDER_PATH",
20511            "PRIVATE_MODEL",
20512            "PRIVATE_TOOL_INVENTORY",
20513            "PRIVATE_CURRENT_PROMPT",
20514            "PRIVATE_TOOL_INPUT_JSON",
20515            "PRIVATE_PROVIDER_REQUEST",
20516            "PRIVATE_INTERACTION_PROMPT",
20517            "PRIVATE_INTERACTION_AGENT",
20518            "PRIVATE_SUBAGENT_ID",
20519            "PRIVATE_SUBAGENT_TYPE",
20520            "PRIVATE_SUBAGENT_DESCRIPTION",
20521            "PRIVATE_PROVIDER_ERROR",
20522            "input_json",
20523            "current_prompt",
20524            "session_id",
20525        ] {
20526            assert!(!encoded.contains(forbidden), "agent progress leaked {forbidden}");
20527        }
20528
20529        let snapshot = NodeSnapshot {
20530            node_id: NodeId::new("node-progress-test").unwrap(),
20531            enabled_providers: vec![agent("claude")],
20532            provider_runtime_statuses: ProviderRuntimeStatuses::default(),
20533            workspaces: Vec::new(),
20534            session_records: Vec::new(),
20535            managed_worktrees: Vec::new(),
20536            launch_inventory: None,
20537            agent_progress: vec![entry],
20538        };
20539        let mut legacy_reply = ResponseEnvelope {
20540            request_id: 1,
20541            result: Ok(NodeResponse::Snapshot {
20542                event_sequence: 0,
20543                controller: None,
20544                snapshot: snapshot.clone(),
20545            }),
20546        };
20547        project_response_without_agent_progress(&mut legacy_reply);
20548        let Ok(NodeResponse::Snapshot { snapshot: legacy, .. }) = legacy_reply.result else {
20549            panic!("agent progress projection changed response kind");
20550        };
20551        assert!(legacy.agent_progress.is_empty());
20552        assert_eq!(snapshot.agent_progress.len(), 1);
20553    }
20554
20555    fn terminal_address(generation: u64) -> SessionAddress {
20556        SessionAddress {
20557            workspace_id: WorkspaceId::new("primary").unwrap(),
20558            session: SessionKey {
20559                instance_id: AgentInstanceId(41),
20560                generation: SessionGeneration(generation),
20561            },
20562        }
20563    }
20564
20565    fn spawn_spec_fixture() -> SpawnSpec {
20566        SpawnSpec {
20567            target: crate::protocol::SpawnTarget {
20568                node_id: NodeId::new("node-terminal-test").unwrap(),
20569                workspace_id: WorkspaceId::new("primary").unwrap(),
20570                worktree_id: None,
20571            },
20572            profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
20573            expected_profile_revision: crate::protocol::SpawnProfileRevision::new("builtin-v1")
20574                .unwrap(),
20575            overrides: crate::protocol::SpawnOverrides::default(),
20576            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
20577            idempotency_key: SpawnIdempotencyKey::new("spawn-spec-fixture").unwrap(),
20578            required_capabilities: SpawnRequiredCapabilities::default(),
20579        }
20580    }
20581
20582    fn environment_profile_test_shared(
20583    ) -> (NodeShared, NativeRuntime, ResolvedEnvironmentProfileReceipt) {
20584        let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
20585        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20586            "local-claude-r1",
20587        )
20588        .unwrap();
20589        let spawn_profiles = SpawnProfileRegistry::new([
20590            crate::protocol::SpawnProfileDefaults {
20591                profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
20592                revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
20593                provider: agent("claude"),
20594                mode: SessionMode::Pty,
20595                terminal_size: gate4agent_types::TerminalSize {
20596                    rows: 24,
20597                    columns: 80,
20598                },
20599                prompt: None,
20600                bundle_id: None,
20601                context_id: None,
20602                environment_profile_id: Some(profile_id.clone()),
20603            },
20604        ])
20605        .unwrap();
20606        let catalog = active_registry().unwrap();
20607        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20608        let control = runtime.native_launch_profile_control();
20609        let node_profile = NodeEnvironmentProfile::new(
20610            profile_id.clone(),
20611            profile_revision.clone(),
20612            agent("claude"),
20613            [NativeLaunchProfile::new(
20614                NativeLaunchProfileId::new("local-claude-pty").unwrap(),
20615                agent("claude"),
20616                TransportKind::Pty,
20617                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20618                Arc::new(EmptyEnvironmentResolver),
20619            )
20620            .unwrap()],
20621        )
20622        .unwrap();
20623        let (binding, native_profiles, _materialization) = node_profile.into_parts();
20624        for native_profile in native_profiles {
20625            runtime.upsert_native_launch_profile(native_profile).unwrap();
20626        }
20627        let workspace = WorkspaceConfig::new(
20628            WorkspaceId::new("primary").unwrap(),
20629            std::env::current_dir().unwrap(),
20630        )
20631        .unwrap();
20632        let shared = NodeShared::new_with_incarnation(
20633            handle,
20634            "fixture-token".to_owned(),
20635            NodeId::new("node-terminal-test").unwrap(),
20636            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20637            vec![workspace],
20638            vec![agent("claude")],
20639            ProviderRuntimeStatuses::default(),
20640            None,
20641            Vec::new(),
20642            Vec::new(),
20643            spawn_profiles,
20644            Some(control),
20645            None,
20646            None,
20647            None,
20648            Vec::new(),
20649            Vec::new(),
20650            Vec::new(),
20651            Vec::new(),
20652            None,
20653            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20654        );
20655        shared
20656            .environment_profiles
20657            .write()
20658            .unwrap_or_else(|poisoned| poisoned.into_inner())
20659            .insert(binding.id.clone(), binding);
20660        (
20661            shared,
20662            runtime,
20663            ResolvedEnvironmentProfileReceipt {
20664                profile_id,
20665                profile_revision,
20666                network_allowlist: None,
20667                browser_profile_id: None,
20668            },
20669        )
20670    }
20671
20672    fn materialization_test_shared(
20673        deny_secret: bool,
20674    ) -> (
20675        NodeShared,
20676        NativeRuntime,
20677        ResolvedEnvironmentProfileReceipt,
20678        PathBuf,
20679        Arc<AtomicBool>,
20680    ) {
20681        let workspace = WorkspaceConfig::new(
20682            WorkspaceId::new("primary").unwrap(),
20683            std::env::current_dir().unwrap(),
20684        )
20685        .unwrap();
20686        materialization_test_shared_with_workspace(deny_secret, workspace)
20687    }
20688
20689    fn materialization_test_shared_with_workspace(
20690        deny_secret: bool,
20691        workspace: WorkspaceConfig,
20692    ) -> (
20693        NodeShared,
20694        NativeRuntime,
20695        ResolvedEnvironmentProfileReceipt,
20696        PathBuf,
20697        Arc<AtomicBool>,
20698    ) {
20699        let root = temporary_workspace_root(if deny_secret {
20700            "materialization-denied"
20701        } else {
20702            "materialization-ready"
20703        });
20704        std::fs::create_dir_all(&root).unwrap();
20705        let materialization_root = root.join("session-environments");
20706        let deny = Arc::new(AtomicBool::new(deny_secret));
20707        let resolver = Arc::new(FixtureSecretResolver {
20708            deny: Arc::clone(&deny),
20709        });
20710        let materializer = SessionEnvironmentMaterializer::new(
20711            materialization_root,
20712            resolver,
20713        )
20714        .unwrap();
20715        let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
20716        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20717            "materialized-claude-r1",
20718        )
20719        .unwrap();
20720        let materialization = NodeSessionMaterializationProfile::new(
20721            vec![NodeSessionEnvironmentMutation::SetSecret {
20722                key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
20723                reference: NodeSecretReference::new("fixture-token").unwrap(),
20724            }],
20725            vec![NodeSessionPathBinding::new(
20726                "GATE4AGENT_PROVIDER_HOME",
20727                NodeSessionPathClass::ProviderHome,
20728            )
20729            .unwrap()],
20730            vec![NodeSessionFile::secret(
20731                NodeSessionPathClass::Config,
20732                "auth/token",
20733                NodeSecretReference::new("fixture-token").unwrap(),
20734            )
20735            .unwrap()],
20736        )
20737        .unwrap();
20738        let catalog = active_registry().unwrap();
20739        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20740        let control = runtime.native_launch_profile_control();
20741        let node_profile = NodeEnvironmentProfile::new_with_materialization(
20742            profile_id.clone(),
20743            profile_revision.clone(),
20744            agent("claude"),
20745            [NativeLaunchProfile::new(
20746                NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
20747                agent("claude"),
20748                TransportKind::Pty,
20749                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20750                Arc::new(EmptyEnvironmentResolver),
20751            )
20752            .unwrap()],
20753            Some(materialization),
20754        )
20755        .unwrap();
20756        let (binding, native_profiles, materialization) = node_profile.into_parts();
20757        for native_profile in native_profiles {
20758            runtime.upsert_native_launch_profile(native_profile).unwrap();
20759        }
20760        let shared = NodeShared::new_with_incarnation(
20761            handle,
20762            "fixture-token".to_owned(),
20763            NodeId::new("node-materialization-test").unwrap(),
20764            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20765            vec![workspace],
20766            vec![agent("claude")],
20767            ProviderRuntimeStatuses::default(),
20768            None,
20769            Vec::new(),
20770            Vec::new(),
20771            SpawnProfileRegistry::default(),
20772            Some(control),
20773            None,
20774            Some(materializer),
20775            None,
20776            Vec::new(),
20777            Vec::new(),
20778            Vec::new(),
20779            Vec::new(),
20780            None,
20781            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20782        );
20783        shared
20784            .environment_profiles
20785            .write()
20786            .unwrap_or_else(|poisoned| poisoned.into_inner())
20787            .insert(binding.id.clone(), binding);
20788        shared
20789            .environment_materialization_profiles
20790            .write()
20791            .unwrap_or_else(|poisoned| poisoned.into_inner())
20792            .insert(profile_id.clone(), materialization.unwrap());
20793        (
20794            shared,
20795            runtime,
20796            ResolvedEnvironmentProfileReceipt {
20797                profile_id,
20798                profile_revision,
20799                network_allowlist: None,
20800                browser_profile_id: None,
20801            },
20802            root,
20803            deny,
20804        )
20805    }
20806
20807    #[cfg(feature = "fixture")]
20808    #[derive(Clone, Copy)]
20809    enum CodexHomeProfile {
20810        Missing,
20811        WrongClass,
20812        Exact,
20813    }
20814
20815    #[cfg(feature = "fixture")]
20816    fn codex_bundle_test_shared(
20817        home_profile: CodexHomeProfile,
20818    ) -> (
20819        NodeShared,
20820        NativeRuntime,
20821        ResolvedEnvironmentProfileReceipt,
20822        NodeBundle,
20823        PathBuf,
20824    ) {
20825        const ROOT_MANIFEST: &[u8] = br#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"review-tools"}"#;
20826        const CLAUDE_MANIFEST: &[u8] =
20827            br#"{"name":"review-tools","version":"1.0.0","description":"Review helpers"}"#;
20828        const SKILL: &[u8] = b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n";
20829        const DIGEST: &str =
20830            "sha256:941f20868a6ef49b4329bf1bb1368515763f5b64d8279b05cd9700966083d707";
20831
20832        let (shared, mut runtime, _claude_receipt, root, _deny) =
20833            materialization_test_shared(false);
20834        let profile_id = SpawnEnvironmentProfileId::new(match home_profile {
20835            CodexHomeProfile::Missing => "codex-home-missing",
20836            CodexHomeProfile::WrongClass => "codex-home-wrong",
20837            CodexHomeProfile::Exact => "codex-home-exact",
20838        })
20839        .unwrap();
20840        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20841            "codex-home-r1",
20842        )
20843        .unwrap();
20844        let materialization = match home_profile {
20845            CodexHomeProfile::Missing => None,
20846            CodexHomeProfile::WrongClass | CodexHomeProfile::Exact => {
20847                Some(NodeSessionMaterializationProfile::new(
20848                    Vec::new(),
20849                    vec![NodeSessionPathBinding::new(
20850                        "CODEX_HOME",
20851                        if matches!(home_profile, CodexHomeProfile::Exact) {
20852                            NodeSessionPathClass::ProviderHome
20853                        } else {
20854                            NodeSessionPathClass::Config
20855                        },
20856                    )
20857                    .unwrap()],
20858                    Vec::new(),
20859                )
20860                .unwrap())
20861            }
20862        };
20863        let node_profile = NodeEnvironmentProfile::new_with_materialization(
20864            profile_id.clone(),
20865            profile_revision.clone(),
20866            agent("codex"),
20867            [NativeLaunchProfile::new(
20868                NativeLaunchProfileId::new(format!("{profile_id}-pty")).unwrap(),
20869                agent("codex"),
20870                TransportKind::Pty,
20871                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20872                Arc::new(EmptyEnvironmentResolver),
20873            )
20874            .unwrap()],
20875            materialization,
20876        )
20877        .unwrap();
20878        let (binding, native_profiles, materialization) = node_profile.into_parts();
20879        for native_profile in native_profiles {
20880            runtime.upsert_native_launch_profile(native_profile).unwrap();
20881        }
20882        shared
20883            .environment_profiles
20884            .write()
20885            .unwrap_or_else(|poisoned| poisoned.into_inner())
20886            .insert(binding.id.clone(), binding);
20887        if let Some(materialization) = materialization {
20888            shared
20889                .environment_materialization_profiles
20890                .write()
20891                .unwrap_or_else(|poisoned| poisoned.into_inner())
20892                .insert(profile_id.clone(), materialization);
20893        }
20894
20895        let bundle_source = root.join(format!("bundle-source-{profile_id}"));
20896        std::fs::create_dir_all(bundle_source.join(".claude-plugin")).unwrap();
20897        std::fs::create_dir_all(bundle_source.join("skills/review-code")).unwrap();
20898        std::fs::write(bundle_source.join("plugin.json"), ROOT_MANIFEST).unwrap();
20899        std::fs::write(
20900            bundle_source.join(".claude-plugin/plugin.json"),
20901            CLAUDE_MANIFEST,
20902        )
20903        .unwrap();
20904        std::fs::write(bundle_source.join("skills/review-code/SKILL.md"), SKILL).unwrap();
20905        crate::bundle_catalog::protect_bundle_source_tree_fixture(&bundle_source).unwrap();
20906        let bundle = NodeBundle::new(
20907            crate::protocol::SpawnBundleId::new("review-tools").unwrap(),
20908            crate::protocol::SpawnBundleRevision::new("review-tools-r1").unwrap(),
20909            crate::protocol::SpawnBundleDigest::new(DIGEST).unwrap(),
20910            &bundle_source,
20911        )
20912        .unwrap();
20913        *shared
20914            .bundle_catalog
20915            .write()
20916            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
20917            BundleCatalog::new([bundle.clone()]).unwrap();
20918        (
20919            shared,
20920            runtime,
20921            ResolvedEnvironmentProfileReceipt {
20922                profile_id,
20923                profile_revision,
20924                network_allowlist: None,
20925                browser_profile_id: None,
20926            },
20927            bundle,
20928            root,
20929        )
20930    }
20931
20932    #[cfg(feature = "fixture")]
20933    #[test]
20934    fn codex_bundle_preflight_rejects_missing_or_wrong_home_before_materialization() {
20935        for home_profile in [CodexHomeProfile::Missing, CodexHomeProfile::WrongClass] {
20936            let (shared, runtime, receipt, bundle, root) =
20937                codex_bundle_test_shared(home_profile);
20938            let error = shared
20939                .bundle_layout(
20940                    &agent("codex"),
20941                    SessionMode::Pty,
20942                    Some(&receipt),
20943                    &bundle,
20944                )
20945                .unwrap_err();
20946            assert_eq!(error.code, NodeFailureCode::BundleBindingMismatch);
20947            assert!(shared
20948                .materializations
20949                .lock()
20950                .unwrap_or_else(|poisoned| poisoned.into_inner())
20951                .is_empty());
20952            let materialization_root = root.join("session-environments");
20953            assert!(!std::fs::read_dir(&materialization_root)
20954                .unwrap()
20955                .filter_map(Result::ok)
20956                .any(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())));
20957            drop(shared);
20958            drop(runtime);
20959            let _ = std::fs::remove_dir_all(root);
20960        }
20961    }
20962
20963    #[cfg(feature = "fixture")]
20964    #[test]
20965    fn codex_bundle_exact_home_profile_materializes_only_the_skill_tree() {
20966        let (shared, runtime, receipt, bundle, root) =
20967            codex_bundle_test_shared(CodexHomeProfile::Exact);
20968        let address = SessionAddress {
20969            workspace_id: WorkspaceId::new("primary").unwrap(),
20970            session: SessionKey {
20971                instance_id: AgentInstanceId(310),
20972                generation: SessionGeneration(1),
20973            },
20974        };
20975        let (overlay, guard) = shared
20976            .prepare_session_materialization(
20977                &address,
20978                &agent("codex"),
20979                SessionMode::Pty,
20980                Some(&receipt),
20981                Some(&bundle.receipt()),
20982                None,
20983                None,
20984            )
20985            .unwrap()
20986            .unwrap();
20987        assert!(matches!(overlay, Some(PreparedNativeLaunchOverlay::Instance(_))));
20988        let ownership = shared
20989            .materializations
20990            .lock()
20991            .unwrap_or_else(|poisoned| poisoned.into_inner())
20992            .get(guard.id().unwrap())
20993            .unwrap()
20994            .clone();
20995        let (profile, _, _) = shared
20996            .materialization_profile(
20997                &agent("codex"),
20998                SessionMode::Pty,
20999                Some(&receipt),
21000                Some(&bundle.receipt()),
21001                None,
21002            )
21003            .unwrap()
21004            .unwrap();
21005        let environment = shared
21006            .session_environment_materializer
21007            .as_ref()
21008            .unwrap()
21009            .resolve_environment(&ownership, &profile)
21010            .unwrap();
21011        assert!(environment.iter().any(|mutation| {
21012            mutation.key == OsString::from("CODEX_HOME")
21013                && mutation.value.as_deref() == Some(ownership.provider_home().as_os_str())
21014        }));
21015        assert_eq!(
21016            std::fs::read(ownership.provider_home().join("skills/review-code/SKILL.md"))
21017                .unwrap(),
21018            b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n",
21019        );
21020        assert!(std::fs::read_dir(ownership.bundle_root())
21021            .unwrap()
21022            .next()
21023            .is_none());
21024        drop(guard);
21025        drop(shared);
21026        drop(runtime);
21027        let _ = std::fs::remove_dir_all(root);
21028    }
21029
21030    #[cfg(feature = "fixture")]
21031    #[test]
21032    fn codex_cold_record_resume_revalidates_the_isolated_skill_tree() {
21033        let (shared, runtime, receipt, bundle, root) =
21034            codex_bundle_test_shared(CodexHomeProfile::Exact);
21035        let bundle_receipt = bundle.receipt();
21036        let address = SessionAddress {
21037            workspace_id: WorkspaceId::new("primary").unwrap(),
21038            session: SessionKey {
21039                instance_id: AgentInstanceId(311),
21040                generation: SessionGeneration(1),
21041            },
21042        };
21043        let (_overlay, guard) = shared
21044            .prepare_session_materialization(
21045                &address,
21046                &agent("codex"),
21047                SessionMode::Pty,
21048                Some(&receipt),
21049                Some(&bundle_receipt),
21050                None,
21051                None,
21052            )
21053            .unwrap()
21054            .unwrap();
21055        let materialization_id = guard.id().unwrap().clone();
21056        let skill_path = shared
21057            .materializations
21058            .lock()
21059            .unwrap_or_else(|poisoned| poisoned.into_inner())
21060            .get(&materialization_id)
21061            .unwrap()
21062            .provider_home()
21063            .join("skills/review-code/SKILL.md");
21064        let record_id = shared
21065            .bind_spawn_session_with_materialization(
21066                &address,
21067                agent("codex"),
21068                SessionMode::Pty,
21069                ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap(),
21070                SpawnRecordPolicy::ProviderIdentityOnly,
21071                Some(receipt.clone()),
21072                Some(bundle_receipt.clone()),
21073                None,
21074                Some(materialization_id.clone()),
21075            )
21076            .unwrap()
21077            .unwrap();
21078        guard.retain();
21079        std::fs::write(skill_path, b"changed-after-record-bind").unwrap();
21080
21081        let error = match shared.resolve_record_materialization(
21082                &record_id,
21083                &agent("codex"),
21084                SessionMode::Pty,
21085                Some(&receipt),
21086                Some(&bundle_receipt),
21087                None,
21088            ) {
21089                Ok(_) => panic!("changed Codex skill tree unexpectedly revalidated"),
21090                Err(error) => error,
21091            };
21092        assert_eq!(error.code, NodeFailureCode::BundleMaterializationFailed);
21093        assert_eq!(
21094            shared
21095                .materializations
21096                .lock()
21097                .unwrap_or_else(|poisoned| poisoned.into_inner())
21098                .get(&materialization_id)
21099                .unwrap()
21100                .state(),
21101            MaterializationState::RecoveryRequired,
21102        );
21103        drop(shared);
21104        drop(runtime);
21105        let _ = std::fs::remove_dir_all(root);
21106    }
21107
21108    fn bind_record_owned_materialization_fixture(
21109        shared: &NodeShared,
21110        receipt: &ResolvedEnvironmentProfileReceipt,
21111        instance_id: u64,
21112    ) -> (SessionAddress, SessionRecordId, MaterializationId, PathBuf) {
21113        let address = SessionAddress {
21114            workspace_id: WorkspaceId::new("primary").unwrap(),
21115            session: SessionKey {
21116                instance_id: AgentInstanceId(instance_id),
21117                generation: SessionGeneration(1),
21118            },
21119        };
21120        let (overlay, materialization_guard) = shared
21121            .prepare_session_materialization(
21122                &address,
21123                &agent("claude"),
21124                SessionMode::Pty,
21125                Some(receipt),
21126                None,
21127                None,
21128                None,
21129            )
21130            .unwrap()
21131            .unwrap();
21132        let materialization_id = materialization_guard.id().unwrap().clone();
21133        let materialization_root = shared
21134            .materializations
21135            .lock()
21136            .unwrap_or_else(|poisoned| poisoned.into_inner())
21137            .get(&materialization_id)
21138            .unwrap()
21139            .root()
21140            .to_path_buf();
21141        let selection = shared
21142            .select_environment_profile(
21143                address.session.instance_id,
21144                &agent("claude"),
21145                SessionMode::Pty,
21146                Some(receipt),
21147            )
21148            .unwrap()
21149            .unwrap();
21150        assert!(shared
21151            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21152            .unwrap()
21153            .is_none());
21154        let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
21155        let record_id = shared
21156            .bind_spawn_session_with_materialization(
21157                &address,
21158                agent("claude"),
21159                SessionMode::Pty,
21160                policy,
21161                SpawnRecordPolicy::ProviderIdentityOnly,
21162                Some(receipt.clone()),
21163                None,
21164                None,
21165                Some(materialization_id.clone()),
21166            )
21167            .unwrap()
21168            .unwrap();
21169        selection.retain();
21170        materialization_guard.retain();
21171        (address, record_id, materialization_id, materialization_root)
21172    }
21173
21174    #[test]
21175    fn child_environment_profile_capability_is_state_aware_and_legacy_safe() {
21176        let (shared, _runtime, environment_profile) = environment_profile_test_shared();
21177        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21178            capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
21179        }));
21180        let support = node_compatibility_support_for_manifest(&[], &[]).unwrap();
21181        assert_eq!(support.state_schema.versions.maximum(), NODE_STATE_SCHEMA_V10);
21182        let mut spec = spawn_spec_fixture();
21183        spec.expected_profile_revision =
21184            crate::protocol::SpawnProfileRevision::new("test-r1").unwrap();
21185        assert!(matches!(
21186            &spec.overrides.environment_profile_id,
21187            crate::protocol::SpawnOverride::Inherit,
21188        ));
21189        assert!(!NodeRequest::SpawnSpec { spec: spec.clone() }
21190            .requires_child_environment_profile_capability());
21191        assert!(request_requires_child_environment_profile(
21192            &shared,
21193            &NodeRequest::SpawnSpec { spec: spec.clone() },
21194        ));
21195
21196        let address = terminal_address(1);
21197        shared.bind_session_with_policy(
21198            &address,
21199            ProviderRuntimePolicy::raw_pty(),
21200            Some(environment_profile.clone()),
21201        );
21202        assert!(request_requires_child_environment_profile(
21203            &shared,
21204            &NodeRequest::Input {
21205                session: address.clone(),
21206                text: "x".to_owned(),
21207            },
21208        ));
21209
21210        let mut record = record("claude", "profile-record");
21211        record.active_session = Some(address.clone());
21212        record.environment_profile = Some(environment_profile.clone());
21213        let record_id = record.record_id.clone();
21214        shared.insert_record(record.clone()).unwrap();
21215        assert!(request_requires_child_environment_profile(
21216            &shared,
21217            &NodeRequest::ResumeSessionRecord {
21218                record_id,
21219                terminal_size: gate4agent_types::TerminalSize {
21220                    rows: 24,
21221                    columns: 80,
21222                },
21223                initial_prompt: None,
21224            },
21225        ));
21226
21227        let snapshot = snapshot_for_wire(
21228            &shared, false, true, true, true, false, false, false, false, true,
21229        );
21230        assert_eq!(snapshot.session_records.len(), 1);
21231        assert!(snapshot.session_records[0].environment_profile.is_none());
21232        let event = project_event_without_child_environment_profile(NodeEventEnvelope {
21233            sequence: 1,
21234            event: NodeEvent::SessionRecordUpserted { record },
21235        });
21236        let NodeEvent::SessionRecordUpserted { record } = event.event else {
21237            panic!("record projection changed the event kind");
21238        };
21239        assert!(record.environment_profile.is_none());
21240
21241        let resolved = shared.resolve_spawn_spec(&spec).unwrap();
21242        let mut reply = ResponseEnvelope {
21243            request_id: 1,
21244            result: Ok(NodeResponse::SpawnSpecAccepted {
21245                receipt: resolved.receipt_with_environment(
21246                    shared.incarnation_id,
21247                    address,
21248                    Some(environment_profile),
21249                ),
21250            }),
21251        };
21252        project_response_without_child_environment_profile(&mut reply);
21253        assert_eq!(
21254            reply.result.unwrap_err().code,
21255            NodeFailureCode::UnsupportedCapability,
21256        );
21257    }
21258
21259    #[test]
21260    fn spawn_profile_environment_authority_is_exact_and_legacy_safe() {
21261        let (shared, _runtime, environment_profile) = environment_profile_test_shared();
21262        let snapshot = shared.snapshot();
21263        let profiles = snapshot
21264            .launch_inventory
21265            .as_ref()
21266            .and_then(|inventory| inventory.spawn_profiles.as_ref())
21267            .unwrap();
21268        assert_eq!(profiles.len(), 1);
21269        assert_eq!(profiles[0].environment_profile, Some(environment_profile));
21270        assert!(snapshot.requires_child_environment_profile_capability());
21271
21272        let mut legacy = snapshot.clone();
21273        clear_snapshot_child_environment_profiles(&mut legacy);
21274        assert!(legacy
21275            .launch_inventory
21276            .as_ref()
21277            .unwrap()
21278            .spawn_profiles
21279            .as_ref()
21280            .unwrap()[0]
21281            .environment_profile
21282            .is_none());
21283        assert!(!legacy.requires_child_environment_profile_capability());
21284
21285        let profile = shared.spawn_profiles.iter().next().unwrap().clone();
21286        let bindings = shared
21287            .environment_profiles
21288            .read()
21289            .unwrap_or_else(|poisoned| poisoned.into_inner())
21290            .clone();
21291        let mut provider_mismatch = bindings.clone();
21292        provider_mismatch
21293            .values_mut()
21294            .next()
21295            .unwrap()
21296            .provider = agent("codex");
21297        assert!(resolved_spawn_profile_summary(&profile, &provider_mismatch).is_none());
21298        let mut mode_mismatch = profile;
21299        mode_mismatch.mode = SessionMode::Inline;
21300        assert!(resolved_spawn_profile_summary(&mode_mismatch, &bindings).is_none());
21301
21302        shared
21303            .environment_profiles
21304            .write()
21305            .unwrap_or_else(|poisoned| poisoned.into_inner())
21306            .clear();
21307        assert!(shared
21308            .snapshot()
21309            .launch_inventory
21310            .unwrap()
21311            .spawn_profiles
21312            .unwrap()
21313            .is_empty());
21314    }
21315
21316    #[test]
21317    fn session_bundle_capability_is_state_aware_and_legacy_safe() {
21318        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21319            capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
21320        }));
21321        let shared = terminal_test_shared();
21322        let bundle = ResolvedBundleReceipt {
21323            id: crate::protocol::SpawnBundleId::new("review-bundle").unwrap(),
21324            revision: crate::protocol::SpawnBundleRevision::new("r1").unwrap(),
21325            digest: crate::protocol::SpawnBundleDigest::new(format!(
21326                "sha256:{}",
21327                "0".repeat(64),
21328            ))
21329            .unwrap(),
21330        };
21331        let mut bundled = record("claude", "bundle-record");
21332        bundled.bundle = Some(bundle.clone());
21333        let record_id = bundled.record_id.clone();
21334        shared.insert_record(bundled.clone()).unwrap();
21335        assert!(request_requires_session_bundle(
21336            &shared,
21337            &NodeRequest::ForgetSessionRecord {
21338                record_id: record_id.clone(),
21339            },
21340        ));
21341
21342        let snapshot = snapshot_for_wire(
21343            &shared, false, true, true, true, true, false, false, false, true,
21344        );
21345        assert!(snapshot.session_records[0].bundle.is_none());
21346        let launch = snapshot.launch_inventory.as_ref().unwrap();
21347        assert!(launch.spawn_profiles.is_some());
21348        assert!(launch.bundles.is_none());
21349        let event = project_event_without_session_bundle(NodeEventEnvelope {
21350            sequence: 1,
21351            event: NodeEvent::SessionRecordUpserted { record: bundled },
21352        });
21353        let NodeEvent::SessionRecordUpserted { record } = event.event else {
21354            panic!("record projection changed the event kind");
21355        };
21356        assert!(record.bundle.is_none());
21357
21358        let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
21359        let mut reply = ResponseEnvelope {
21360            request_id: 1,
21361            result: Ok(NodeResponse::SpawnSpecAccepted {
21362                receipt: resolved.receipt_with_materialization(
21363                    shared.incarnation_id,
21364                    terminal_address(17),
21365                    None,
21366                    Some(bundle),
21367                    None,
21368                ),
21369            }),
21370        };
21371        project_response_without_session_bundle(&mut reply);
21372        assert_eq!(
21373            reply.result.unwrap_err().code,
21374            NodeFailureCode::UnsupportedCapability,
21375        );
21376    }
21377
21378    #[test]
21379    fn history_context_pack_capability_redacts_loaded_messages_and_legacy_metadata() {
21380        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21381            capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
21382        }));
21383        let context_id = SpawnContextId::new("context-review").unwrap();
21384        let context = ResolvedContextPackReceipt {
21385            id: context_id.clone(),
21386            digest: crate::protocol::SpawnContextDigest::new(format!(
21387                "sha256:{}",
21388                "c".repeat(64),
21389            ))
21390            .unwrap(),
21391            lineage: ContextPackLineageReceipt {
21392                source_node_id: NodeId::new("node-source").unwrap(),
21393                source_session: terminal_address(1),
21394                source_provider: agent("kimi"),
21395            },
21396            source_message_count: 2,
21397            retained_message_count: 2,
21398            byte_len: 256,
21399            truncated: false,
21400        };
21401        assert!(context.is_valid());
21402        let mut bound_record = record("codex", "context-record");
21403        bound_record.context_id = Some(context_id);
21404        bound_record.context = Some(context.clone());
21405        let session = gate4agent_types::SessionSnapshot {
21406            instance_id: AgentInstanceId(91),
21407            agent_id: agent("kimi"),
21408            transport: TransportKind::Pty,
21409            generation: SessionGeneration(3),
21410            status: SessionStatus::Running,
21411            pending_operation: None,
21412            pending_input: None,
21413            process_id: Some(9001),
21414            terminal_size: None,
21415            terminal_frame: None,
21416            terminal_stale: None,
21417            session_options: None,
21418            capabilities: gate4agent_types::CapabilitySnapshot::default(),
21419            history: gate4agent_types::HistorySnapshot {
21420                pending: None,
21421                candidates: vec![HistoryCandidateSummary {
21422                    id: "candidate-opaque".to_owned(),
21423                    session_id_hint: "session-hint".to_owned(),
21424                    modified_at_unix_ms: Some(1),
21425                }],
21426                loaded_candidate_id: Some("candidate-opaque".to_owned()),
21427                loaded: Some(HistorySessionRecord {
21428                    session_id: "private-vendor-session".to_owned(),
21429                    title: Some("private title".to_owned()),
21430                    cwd: Some(r"C:\private\history-root".to_owned()),
21431                    model: Some("private-model".to_owned()),
21432                    message_count: 2,
21433                    completed_turn_count: None,
21434                    total_tokens: 19,
21435                    messages: vec![
21436                        gate4agent_types::HistoryMessageRecord {
21437                            role: gate4agent_types::HistoryMessageRole::User,
21438                            text: "F7_PRIVATE_HISTORY_MESSAGE".to_owned(),
21439                        },
21440                        gate4agent_types::HistoryMessageRecord {
21441                            role: gate4agent_types::HistoryMessageRole::Assistant,
21442                            text: "private answer".to_owned(),
21443                        },
21444                    ],
21445                }),
21446                last_error: None,
21447            },
21448            resume: gate4agent_types::ResumeSnapshot::default(),
21449            foreground: gate4agent_types::ForegroundSnapshot::default(),
21450            provider: gate4agent_types::ProviderSnapshot::default(),
21451            screen_state: gate4agent_types::PtyScreenState::default(),
21452        };
21453        let original = NodeSnapshot {
21454            node_id: NodeId::new("node-terminal-test").unwrap(),
21455            enabled_providers: vec![agent("kimi"), agent("codex")],
21456            provider_runtime_statuses: ProviderRuntimeStatuses::default(),
21457            workspaces: vec![WorkspaceSnapshot {
21458                workspace_id: WorkspaceId::new("primary").unwrap(),
21459                canonical_root: opaque_windows_path(
21460                    std::env::current_dir().unwrap().to_string_lossy().into_owned(),
21461                ),
21462                sessions: vec![session],
21463                worktree_service_mode: None,
21464                managed_worktree_profiles: None,
21465            }],
21466            session_records: vec![bound_record],
21467            managed_worktrees: Vec::new(),
21468            launch_inventory: None,
21469            agent_progress: Vec::new(),
21470        };
21471
21472        let mut negotiated = original.clone();
21473        project_snapshot_history_for_wire(&mut negotiated, true);
21474        assert_eq!(negotiated.workspaces[0].sessions[0].history.candidates.len(), 1);
21475        assert!(negotiated.workspaces[0].sessions[0].history.loaded.is_none());
21476        assert_eq!(negotiated.session_records[0].context.as_ref(), Some(&context));
21477        assert!(!serde_json::to_string(&negotiated)
21478            .unwrap()
21479            .contains("F7_PRIVATE_HISTORY_MESSAGE"));
21480
21481        let mut legacy = original;
21482        project_snapshot_history_for_wire(&mut legacy, false);
21483        clear_snapshot_context_packs(&mut legacy);
21484        assert_eq!(
21485            legacy.workspaces[0].sessions[0].history,
21486            gate4agent_types::HistorySnapshot::default(),
21487        );
21488        assert!(legacy.session_records[0].context_id.is_none());
21489        assert!(legacy.session_records[0].context.is_none());
21490        let encoded = serde_json::to_string(&legacy).unwrap();
21491        assert!(!encoded.contains("F7_PRIVATE_HISTORY_MESSAGE"));
21492        assert!(!encoded.contains("context-review"));
21493
21494        let mut reply = ResponseEnvelope {
21495            request_id: 7,
21496            result: Ok(NodeResponse::ContextPackExported { context }),
21497        };
21498        project_response_without_context_pack(&mut reply);
21499        assert_eq!(
21500            reply.result.unwrap_err().code,
21501            NodeFailureCode::UnsupportedCapability,
21502        );
21503    }
21504
21505    #[test]
21506    fn forgetting_unbound_context_pack_evicts_replay_but_retained_record_is_busy() {
21507        let shared = terminal_test_shared();
21508        let history = HistorySessionRecord {
21509            session_id: "fixture-history".to_owned(),
21510            title: None,
21511            cwd: None,
21512            model: None,
21513            message_count: 1,
21514            completed_turn_count: None,
21515            total_tokens: 0,
21516            messages: vec![gate4agent_types::HistoryMessageRecord {
21517                role: gate4agent_types::HistoryMessageRole::User,
21518                text: "bounded handoff".to_owned(),
21519            }],
21520        };
21521        let pack = NodeContextPack::export(
21522            ContextPackLineageReceipt {
21523                source_node_id: shared.node_id.clone(),
21524                source_session: terminal_address(1),
21525                source_provider: agent("claude"),
21526            },
21527            &history,
21528        )
21529        .unwrap();
21530        let context = shared
21531            .context_catalog
21532            .write()
21533            .unwrap_or_else(|poisoned| poisoned.into_inner())
21534            .insert(pack)
21535            .unwrap();
21536        let mut retained = record("codex", "context-retained-record");
21537        retained.context_id = Some(context.id.clone());
21538        retained.context = Some(context.clone());
21539        let record_id = retained.record_id.clone();
21540        shared.insert_record(retained).unwrap();
21541        assert_eq!(
21542            shared.forget_context_pack(&context.id).unwrap_err().code,
21543            NodeFailureCode::ContextPackBusy,
21544        );
21545        assert!(shared
21546            .context_catalog
21547            .read()
21548            .unwrap_or_else(|poisoned| poisoned.into_inner())
21549            .get(&context.id)
21550            .is_some());
21551        shared
21552            .session_records
21553            .lock()
21554            .unwrap_or_else(|poisoned| poisoned.into_inner())
21555            .records
21556            .remove(&record_id);
21557
21558        let mut spec = spawn_spec_fixture();
21559        spec.overrides.context_id = crate::protocol::SpawnOverride::Set {
21560            value: context.id.clone(),
21561        };
21562        let resolved = shared.resolve_spawn_spec(&spec).unwrap();
21563        let receipt = resolved.receipt_with_materialization(
21564            shared.incarnation_id,
21565            terminal_address(2),
21566            None,
21567            None,
21568            Some(context.clone()),
21569        );
21570        assert!(receipt.context_binding_is_valid());
21571        shared.remember_spawn_spec(spec.clone(), Ok(receipt), Instant::now());
21572
21573        shared.forget_context_pack(&context.id).unwrap();
21574        assert!(shared
21575            .context_catalog
21576            .read()
21577            .unwrap_or_else(|poisoned| poisoned.into_inner())
21578            .get(&context.id)
21579            .is_none());
21580        assert!(shared
21581            .replay_spawn_spec(&spec, Instant::now())
21582            .unwrap()
21583            .is_none());
21584    }
21585
21586    #[test]
21587    fn environment_profile_selection_is_owned_by_the_exact_session_binding() {
21588        let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
21589        let address = terminal_address(1);
21590        let selection = shared
21591            .select_environment_profile(
21592                address.session.instance_id,
21593                &agent("claude"),
21594                SessionMode::Pty,
21595                Some(&environment_profile),
21596            )
21597            .unwrap()
21598            .unwrap();
21599        shared.bind_session_with_policy(
21600            &address,
21601            ProviderRuntimePolicy::raw_pty(),
21602            Some(environment_profile.clone()),
21603        );
21604        selection.retain();
21605        let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
21606        assert!(matches!(
21607            runtime.remove_native_launch_profile(&native_id),
21608            Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
21609        ));
21610        assert!(shared.remove_binding(&address).is_some());
21611        assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
21612
21613        let mismatched = ResolvedEnvironmentProfileReceipt {
21614            profile_id: environment_profile.profile_id,
21615            profile_revision: crate::protocol::SpawnEnvironmentProfileRevision::new(
21616                "local-claude-r2",
21617            )
21618            .unwrap(),
21619            network_allowlist: None,
21620            browser_profile_id: None,
21621        };
21622        let error = match shared.select_environment_profile(
21623                AgentInstanceId(42),
21624                &agent("claude"),
21625                SessionMode::Pty,
21626                Some(&mismatched),
21627            ) {
21628            Ok(_) => panic!("mismatched environment profile revision was selected"),
21629            Err(error) => error,
21630        };
21631        assert_eq!(
21632            error.code,
21633            NodeFailureCode::EnvironmentProfileBindingMismatch,
21634        );
21635        assert!(!shared
21636            .session_bindings
21637            .lock()
21638            .unwrap_or_else(|poisoned| poisoned.into_inner())
21639            .contains_key(&AgentInstanceId(42)));
21640    }
21641
21642    #[test]
21643    fn combined_environment_and_bundle_overlay_clears_with_its_binding() {
21644        let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
21645        let address = terminal_address(1);
21646        let selection = shared
21647            .select_environment_profile(
21648                address.session.instance_id,
21649                &agent("claude"),
21650                SessionMode::Pty,
21651                Some(&environment_profile),
21652            )
21653            .unwrap()
21654            .unwrap();
21655        let overlay = NativeInstanceLaunchOverlay::new(
21656            agent("claude"),
21657            TransportKind::Pty,
21658            vec![EnvMutation {
21659                key: OsString::from("GATE4AGENT_SESSION_TOKEN"),
21660                value: Some(OsString::from("fixture-secret")),
21661            }],
21662            vec![
21663                OsString::from("--plugin-dir"),
21664                std::env::current_dir().unwrap().into_os_string(),
21665            ],
21666        )
21667        .unwrap();
21668        let instance_overlay = shared
21669            .install_prepared_launch_overlay(
21670                address.session.instance_id,
21671                PreparedNativeLaunchOverlay::Instance(overlay),
21672            )
21673            .unwrap()
21674            .unwrap();
21675        let bundle = ResolvedBundleReceipt {
21676            id: crate::protocol::SpawnBundleId::new("combined-bundle").unwrap(),
21677            revision: crate::protocol::SpawnBundleRevision::new("combined-r1").unwrap(),
21678            digest: crate::protocol::SpawnBundleDigest::new(format!(
21679                "sha256:{}",
21680                "0".repeat(64),
21681            ))
21682            .unwrap(),
21683        };
21684        shared.bind_session_with_materialization(
21685            &address,
21686            ProviderRuntimePolicy::raw_pty(),
21687            Some(environment_profile),
21688            Some(bundle),
21689            None,
21690            None,
21691        );
21692        selection.retain();
21693        instance_overlay.retain();
21694
21695        let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
21696        assert!(matches!(
21697            runtime.remove_native_launch_profile(&native_id),
21698            Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
21699        ));
21700        assert!(shared.remove_binding(&address).is_some());
21701        assert!(!shared
21702            .native_launch_profile_control
21703            .as_ref()
21704            .unwrap()
21705            .clear_native_instance_launch_overlay(address.session.instance_id));
21706        assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
21707    }
21708
21709    #[test]
21710    fn session_environment_materialization_failure_is_pre_child_and_durable() {
21711        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
21712        let address = SessionAddress {
21713            workspace_id: WorkspaceId::new("primary").unwrap(),
21714            session: SessionKey {
21715                instance_id: AgentInstanceId(201),
21716                generation: SessionGeneration(1),
21717            },
21718        };
21719        let error = match shared.prepare_session_materialization(
21720            &address,
21721            &agent("claude"),
21722            SessionMode::Pty,
21723            Some(&receipt),
21724            None,
21725            None,
21726            None,
21727        ) {
21728            Ok(_) => panic!("denied local reference unexpectedly materialized"),
21729            Err(error) => error,
21730        };
21731        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21732        assert!(shared.handle.snapshot().sessions.is_empty());
21733        assert!(shared
21734            .session_bindings
21735            .lock()
21736            .unwrap_or_else(|poisoned| poisoned.into_inner())
21737            .is_empty());
21738        assert!(shared
21739            .materializations
21740            .lock()
21741            .unwrap_or_else(|poisoned| poisoned.into_inner())
21742            .is_empty());
21743        drop(shared);
21744        drop(runtime);
21745        std::fs::remove_dir_all(root).unwrap();
21746    }
21747
21748    #[test]
21749    fn session_environment_binding_retains_on_stop_and_cleans_after_remove_reap() {
21750        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21751        let address = SessionAddress {
21752            workspace_id: WorkspaceId::new("primary").unwrap(),
21753            session: SessionKey {
21754                instance_id: AgentInstanceId(202),
21755                generation: SessionGeneration(1),
21756            },
21757        };
21758        let (overlay, materialization_guard) = shared
21759            .prepare_session_materialization(
21760                &address,
21761                &agent("claude"),
21762                SessionMode::Pty,
21763                Some(&receipt),
21764                None,
21765                None,
21766                None,
21767            )
21768            .unwrap()
21769            .unwrap();
21770        let materialization_id = materialization_guard.id().unwrap().clone();
21771        let materialization_root = shared
21772            .materializations
21773            .lock()
21774            .unwrap_or_else(|poisoned| poisoned.into_inner())
21775            .get(&materialization_id)
21776            .unwrap()
21777            .root()
21778            .to_path_buf();
21779        let selection = shared
21780            .select_environment_profile(
21781                address.session.instance_id,
21782                &agent("claude"),
21783                SessionMode::Pty,
21784                Some(&receipt),
21785            )
21786            .unwrap()
21787            .unwrap();
21788        assert!(shared
21789            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21790            .unwrap()
21791            .is_none());
21792        shared.bind_session_with_materialization(
21793            &address,
21794            ProviderRuntimePolicy::raw_pty(),
21795            Some(receipt),
21796            None,
21797            None,
21798            Some(materialization_id),
21799        );
21800        selection.retain();
21801        materialization_guard.retain();
21802
21803        assert!(materialization_root.is_dir());
21804        assert!(shared
21805            .session_bindings
21806            .lock()
21807            .unwrap_or_else(|poisoned| poisoned.into_inner())
21808            .contains_key(&address.session.instance_id));
21809
21810        let removed = shared.remove_binding(&address).unwrap();
21811        shared
21812            .cleanup_session_owned_materialization(&address, &removed)
21813            .unwrap();
21814        assert!(!materialization_root.exists());
21815        assert!(shared
21816            .materializations
21817            .lock()
21818            .unwrap_or_else(|poisoned| poisoned.into_inner())
21819            .is_empty());
21820        drop(shared);
21821        drop(runtime);
21822        std::fs::remove_dir_all(root).unwrap();
21823    }
21824
21825    #[tokio::test]
21826    async fn record_owned_session_environment_reuses_on_resume_and_forget_cleans() {
21827        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21828        let address = SessionAddress {
21829            workspace_id: WorkspaceId::new("primary").unwrap(),
21830            session: SessionKey {
21831                instance_id: AgentInstanceId(203),
21832                generation: SessionGeneration(1),
21833            },
21834        };
21835        let (overlay, materialization_guard) = shared
21836            .prepare_session_materialization(
21837                &address,
21838                &agent("claude"),
21839                SessionMode::Pty,
21840                Some(&receipt),
21841                None,
21842                None,
21843                None,
21844            )
21845            .unwrap()
21846            .unwrap();
21847        let materialization_id = materialization_guard.id().unwrap().clone();
21848        let materialization_root = shared
21849            .materializations
21850            .lock()
21851            .unwrap_or_else(|poisoned| poisoned.into_inner())
21852            .get(&materialization_id)
21853            .unwrap()
21854            .root()
21855            .to_path_buf();
21856        let selection = shared
21857            .select_environment_profile(
21858                address.session.instance_id,
21859                &agent("claude"),
21860                SessionMode::Pty,
21861                Some(&receipt),
21862            )
21863            .unwrap()
21864            .unwrap();
21865        assert!(shared
21866            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21867            .unwrap()
21868            .is_none());
21869        let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
21870        let record_id = shared
21871            .bind_spawn_session_with_materialization(
21872                &address,
21873                agent("claude"),
21874                SessionMode::Pty,
21875                policy,
21876                SpawnRecordPolicy::ProviderIdentityOnly,
21877                Some(receipt.clone()),
21878                None,
21879                None,
21880                Some(materialization_id.clone()),
21881            )
21882            .unwrap()
21883            .unwrap();
21884        selection.retain();
21885        materialization_guard.retain();
21886        assert_eq!(
21887            shared
21888                .materializations
21889                .lock()
21890                .unwrap_or_else(|poisoned| poisoned.into_inner())
21891                .get(&materialization_id)
21892                .unwrap()
21893                .owner(),
21894            &MaterializationOwner::Record {
21895                record_id: record_id.clone(),
21896            },
21897        );
21898
21899        let removed = shared.remove_binding(&address).unwrap();
21900        shared
21901            .cleanup_session_owned_materialization(&address, &removed)
21902            .unwrap();
21903        assert!(materialization_root.is_dir());
21904        assert!(shared
21905            .resolve_record_materialization(
21906                &record_id,
21907                &agent("claude"),
21908                SessionMode::Pty,
21909                Some(&receipt),
21910                None,
21911                None,
21912            )
21913            .unwrap()
21914            .is_some());
21915        {
21916            let mut records = shared
21917                .session_records
21918                .lock()
21919                .unwrap_or_else(|poisoned| poisoned.into_inner());
21920            let record = records.records.get_mut(&record_id).unwrap();
21921            record.active_session = None;
21922            record.state = ManagedSessionState::Unavailable;
21923        }
21924        shared.forget_session_record(&record_id).await.unwrap();
21925        assert!(!materialization_root.exists());
21926        assert!(shared
21927            .materializations
21928            .lock()
21929            .unwrap_or_else(|poisoned| poisoned.into_inner())
21930            .is_empty());
21931        drop(shared);
21932        drop(runtime);
21933        std::fs::remove_dir_all(root).unwrap();
21934    }
21935
21936    #[test]
21937    fn record_materialization_revalidation_failure_persists_recovery_required() {
21938        let (mut shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21939        let state_path = root.join("node-state-v6.json");
21940        shared.state_path = Some(state_path.clone());
21941        let (_address, _record_id, materialization_id, materialization_root) =
21942            bind_record_owned_materialization_fixture(&shared, &receipt, 205);
21943        std::fs::write(
21944            materialization_root.join(".gate4agent-materialization-owner"),
21945            b"tampered",
21946        )
21947        .unwrap();
21948
21949        let error = match shared.resolve_record_materialization(
21950            &_record_id,
21951            &agent("claude"),
21952            SessionMode::Pty,
21953            Some(&receipt),
21954            None,
21955            None,
21956        ) {
21957            Ok(_) => panic!("tampered owner marker unexpectedly revalidated"),
21958            Err(error) => error,
21959        };
21960        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21961        assert_eq!(
21962            shared
21963                .materializations
21964                .lock()
21965                .unwrap_or_else(|poisoned| poisoned.into_inner())
21966                .get(&materialization_id)
21967                .unwrap()
21968                .state(),
21969            MaterializationState::RecoveryRequired,
21970        );
21971        let loaded = session_registry::load(Some(&state_path), &shared.node_id).unwrap();
21972        assert_eq!(loaded.materializations.len(), 1);
21973        assert_eq!(
21974            loaded.materializations[0].state(),
21975            MaterializationState::RecoveryRequired,
21976        );
21977        drop(shared);
21978        drop(runtime);
21979        std::fs::remove_dir_all(root).unwrap();
21980    }
21981
21982    #[test]
21983    fn transient_record_materialization_resolution_failure_remains_ready() {
21984        let (shared, runtime, receipt, root, deny) = materialization_test_shared(false);
21985        let (_address, record_id, materialization_id, _materialization_root) =
21986            bind_record_owned_materialization_fixture(&shared, &receipt, 206);
21987        deny.store(true, Ordering::Release);
21988
21989        let error = match shared.resolve_record_materialization(
21990            &record_id,
21991            &agent("claude"),
21992            SessionMode::Pty,
21993            Some(&receipt),
21994            None,
21995            None,
21996        ) {
21997            Ok(_) => panic!("denied transient resolver unexpectedly succeeded"),
21998            Err(error) => error,
21999        };
22000        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
22001        assert_eq!(
22002            shared
22003                .materializations
22004                .lock()
22005                .unwrap_or_else(|poisoned| poisoned.into_inner())
22006                .get(&materialization_id)
22007                .unwrap()
22008                .state(),
22009            MaterializationState::Ready,
22010        );
22011        drop(shared);
22012        drop(runtime);
22013        std::fs::remove_dir_all(root).unwrap();
22014    }
22015
22016    #[test]
22017    fn startup_missing_record_materialization_is_not_resumable() {
22018        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
22019        let record_id = SessionRecordId::new("sr-missing-materialization").unwrap();
22020        shared
22021            .insert_record(ManagedSessionRecord {
22022                record_id: record_id.clone(),
22023                display_name: "missing materialization".to_owned(),
22024                provider: agent("claude"),
22025                mode: SessionMode::Pty,
22026                state: ManagedSessionState::Dormant,
22027                workspace_id: WorkspaceId::new("primary").unwrap(),
22028                canonical_root: opaque_windows_path(
22029                    std::env::current_dir().unwrap().to_string_lossy().into_owned(),
22030                ),
22031                provider_session: Some(gate4agent_types::ProviderSessionIdentity {
22032                    key: gate4agent_types::ProviderSessionKey::SessionId,
22033                    id: "provider-session-missing-materialization".to_owned(),
22034                    transcript_path: None,
22035                }),
22036                active_session: None,
22037                environment_profile: Some(receipt),
22038                bundle: None,
22039                context_id: None,
22040                context: None,
22041                exported_context: None,
22042                task_binding: None,
22043                created_at_unix_ms: 1,
22044                updated_at_unix_ms: 1,
22045                last_error: None,
22046            })
22047            .unwrap();
22048
22049        shared.reconcile_materializations();
22050        let record = shared.record(&record_id).unwrap();
22051        assert_eq!(record.state, ManagedSessionState::Unavailable);
22052        assert!(record.provider_session.is_none());
22053        assert_eq!(
22054            record.last_error.as_deref(),
22055            Some("environment-profile-unavailable"),
22056        );
22057        drop(shared);
22058        drop(runtime);
22059        std::fs::remove_dir_all(root).unwrap();
22060    }
22061
22062    #[tokio::test]
22063    async fn managed_worktree_cleanup_waits_for_session_environment_cleanup() {
22064        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
22065        let lease = install_managed_lease(&shared);
22066        let address = SessionAddress {
22067            workspace_id: WorkspaceId::new("primary").unwrap(),
22068            session: SessionKey {
22069                instance_id: AgentInstanceId(204),
22070                generation: SessionGeneration(1),
22071            },
22072        };
22073        let (overlay, materialization_guard) = shared
22074            .prepare_session_materialization(
22075                &address,
22076                &agent("claude"),
22077                SessionMode::Pty,
22078                Some(&receipt),
22079                None,
22080                None,
22081                Some(lease.lease_id.clone()),
22082            )
22083            .unwrap()
22084            .unwrap();
22085        let materialization_id = materialization_guard.id().unwrap().clone();
22086        let materialization_root = shared
22087            .materializations
22088            .lock()
22089            .unwrap_or_else(|poisoned| poisoned.into_inner())
22090            .get(&materialization_id)
22091            .unwrap()
22092            .root()
22093            .to_path_buf();
22094        let selection = shared
22095            .select_environment_profile(
22096                address.session.instance_id,
22097                &agent("claude"),
22098                SessionMode::Pty,
22099                Some(&receipt),
22100            )
22101            .unwrap()
22102            .unwrap();
22103        assert!(shared
22104            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
22105            .unwrap()
22106            .is_none());
22107        shared.bind_session_with_materialization(
22108            &address,
22109            ProviderRuntimePolicy::raw_pty(),
22110            Some(receipt),
22111            None,
22112            None,
22113            Some(materialization_id),
22114        );
22115        selection.retain();
22116        materialization_guard.retain();
22117
22118        let blocked = shared
22119            .cleanup_managed_worktree(&lease.lease_id, false)
22120            .await
22121            .unwrap_err();
22122        assert_eq!(blocked.code, NodeFailureCode::ManagedWorktreeRecoveryRequired);
22123        assert!(materialization_root.is_dir());
22124        let removed = shared.remove_binding(&address).unwrap();
22125        shared
22126            .cleanup_session_owned_materialization(&address, &removed)
22127            .unwrap();
22128        assert!(!materialization_root.exists());
22129        drop(shared);
22130        drop(runtime);
22131        std::fs::remove_dir_all(root).unwrap();
22132    }
22133
22134    #[tokio::test]
22135    async fn raw_managed_record_is_inventory_only_and_does_not_block_session_cleanup() {
22136        let git_root = temporary_workspace_root("raw-managed-inventory");
22137        let source = git_root.join("source");
22138        let allocation = git_root.join("allocation");
22139        std::fs::create_dir_all(&source).unwrap();
22140        std::fs::create_dir_all(&allocation).unwrap();
22141        run_test_git(&source, &["init"]);
22142        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
22143        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
22144        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
22145        run_test_git(&source, &["add", "seed.txt"]);
22146        run_test_git(&source, &["commit", "-m", "seed"]);
22147        let profile = ManagedWorktreeProfile::new(
22148            WorktreeProfileId::new("default").unwrap(),
22149            crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
22150            &allocation,
22151            "gate4agent",
22152            "HEAD",
22153            ManagedWorktreeRetention::RemoveWhenReleased,
22154        )
22155        .unwrap();
22156        let workspace = WorkspaceConfig::new(
22157            WorkspaceId::new("primary").unwrap(),
22158            &source,
22159        )
22160        .unwrap()
22161        .with_worktree_service_mode(WorktreeServiceMode::Managed)
22162        .with_managed_worktree_profile(profile.clone())
22163        .unwrap();
22164        let (shared, mut runtime, receipt, materialization_fixture_root, _deny) =
22165            materialization_test_shared_with_workspace(false, workspace);
22166        let target = PathBuf::from(profile.allocation_root()).join("mw-raw-inventory");
22167        let source_text = source.to_string_lossy().into_owned();
22168        let target_text = target.to_string_lossy().into_owned();
22169        let created = create_git_worktree(
22170            &source_text,
22171            &target_text,
22172            "gate4agent/mw-raw-inventory",
22173            Some("HEAD"),
22174        )
22175        .await
22176        .unwrap();
22177        let lease = ManagedWorktreeLeaseRecord {
22178            lease_id: ManagedWorktreeLeaseId::new("mw-raw-inventory").unwrap(),
22179            source_workspace_id: WorkspaceId::new("primary").unwrap(),
22180            workspace_id: WorkspaceId::new("managed-raw-inventory").unwrap(),
22181            profile_id: profile.profile_id().clone(),
22182            profile_revision: profile.revision().clone(),
22183            target_root: created.path.clone(),
22184            branch: "gate4agent/mw-raw-inventory".to_owned(),
22185            base_commit: created.head.clone(),
22186            expected_head: Some(created.head.clone()),
22187            retention: ManagedWorktreeRetention::RemoveWhenReleased,
22188            state: ManagedWorktreeLeaseState::Ready,
22189            session_holders: Vec::new(),
22190            record_holders: Vec::new(),
22191            cleanup_failure: None,
22192            created_at_unix_ms: 1,
22193            updated_at_unix_ms: 1,
22194        };
22195        shared
22196            .workspaces
22197            .write()
22198            .unwrap_or_else(|poisoned| poisoned.into_inner())
22199            .insert(lease.workspace_id.clone(), created.path.clone());
22200        *shared
22201            .managed_worktrees
22202            .lock()
22203            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22204            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
22205        let address = SessionAddress {
22206            workspace_id: lease.workspace_id.clone(),
22207            session: SessionKey {
22208                instance_id: AgentInstanceId(205),
22209                generation: SessionGeneration(1),
22210            },
22211        };
22212        let (overlay, materialization_guard) = shared
22213            .prepare_session_materialization(
22214                &address,
22215                &agent("claude"),
22216                SessionMode::Pty,
22217                Some(&receipt),
22218                None,
22219                None,
22220                Some(lease.lease_id.clone()),
22221            )
22222            .unwrap()
22223            .unwrap();
22224        let materialization_id = materialization_guard.id().unwrap().clone();
22225        let materialization_root = shared
22226            .materializations
22227            .lock()
22228            .unwrap_or_else(|poisoned| poisoned.into_inner())
22229            .get(&materialization_id)
22230            .unwrap()
22231            .root()
22232            .to_path_buf();
22233        let selection = shared
22234            .select_environment_profile(
22235                address.session.instance_id,
22236                &agent("claude"),
22237                SessionMode::Pty,
22238                Some(&receipt),
22239            )
22240            .unwrap()
22241            .unwrap();
22242        assert!(shared
22243            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
22244            .unwrap()
22245            .is_none());
22246        let record_id = shared
22247            .bind_spawn_session_with_materialization(
22248                &address,
22249                agent("claude"),
22250                SessionMode::Pty,
22251                ProviderRuntimePolicy::raw_pty(),
22252                SpawnRecordPolicy::Always,
22253                Some(receipt.clone()),
22254                None,
22255                None,
22256                Some(materialization_id.clone()),
22257            )
22258            .unwrap()
22259            .unwrap();
22260        selection.retain();
22261        materialization_guard.retain();
22262        let record = shared.record(&record_id).unwrap();
22263        assert_eq!(record.state, ManagedSessionState::Live);
22264        assert!(record.provider_session.is_none());
22265        assert!(record.environment_profile.is_none());
22266        assert!(record.bundle.is_none());
22267        assert!(record.context_id.is_none());
22268        assert!(record.context.is_none());
22269        assert_eq!(record.active_session.as_ref(), Some(&address));
22270        assert_eq!(
22271            shared
22272                .materializations
22273                .lock()
22274                .unwrap_or_else(|poisoned| poisoned.into_inner())
22275                .get(&materialization_id)
22276                .unwrap()
22277                .owner(),
22278            &MaterializationOwner::Session {
22279                incarnation_id: shared.incarnation_id,
22280                instance_id: address.session.instance_id,
22281                generation: address.session.generation,
22282            },
22283        );
22284        {
22285            let mut bindings = shared
22286                .session_bindings
22287                .lock()
22288                .unwrap_or_else(|poisoned| poisoned.into_inner());
22289            bindings
22290                .get_mut(&address.session.instance_id)
22291                .unwrap()
22292                .managed_worktree_lease_id = Some(lease.lease_id.clone());
22293        }
22294        let lease_snapshot = shared
22295            .managed_worktrees
22296            .lock()
22297            .unwrap_or_else(|poisoned| poisoned.into_inner())
22298            .bind_session(
22299                &lease.lease_id,
22300                ManagedWorktreeSessionHolder {
22301                    incarnation_id: shared.incarnation_id,
22302                    instance_id: address.session.instance_id,
22303                    generation: address.session.generation,
22304                },
22305                None,
22306                unix_time_ms(),
22307            )
22308            .unwrap();
22309        assert_eq!(lease_snapshot.active_session_count, 1);
22310        assert_eq!(lease_snapshot.managed_record_count, 0);
22311        shared.persist_state().unwrap();
22312        assert!(shared
22313            .managed_worktrees
22314            .lock()
22315            .unwrap_or_else(|poisoned| poisoned.into_inner())
22316            .get(&lease.lease_id)
22317            .unwrap()
22318            .record_holders
22319            .is_empty());
22320        shared
22321            .handle
22322            .dispatch(shared.prepare_command(ControlCommand::Register {
22323                instance_id: address.session.instance_id,
22324                agent_id: agent("claude"),
22325                transport: TransportKind::Pty,
22326            }))
22327            .unwrap();
22328        runtime.tick().await;
22329        shared.reconcile_managed_record(
22330            &record_id,
22331            &address,
22332            &ControlEvent {
22333                sequence: 1,
22334                command_id: None,
22335                instance_id: address.session.instance_id,
22336                generation: address.session.generation,
22337                event: ControlEventKind::Removed,
22338            },
22339            false,
22340            false,
22341        );
22342        let remove = shared.remove_session(&address);
22343        let drive_runtime = async {
22344            runtime.tick().await;
22345        };
22346        let (removed, ()) = tokio::join!(remove, drive_runtime);
22347        removed.unwrap();
22348
22349        assert!(!materialization_root.exists());
22350        assert!(!Path::new(&created.path).exists());
22351        assert_eq!(
22352            shared
22353                .managed_worktrees
22354                .lock()
22355                .unwrap_or_else(|poisoned| poisoned.into_inner())
22356                .get(&lease.lease_id)
22357                .unwrap()
22358                .state,
22359            ManagedWorktreeLeaseState::Removed,
22360        );
22361        let renamed = shared
22362            .rename_session_record(&record_id, "raw managed inventory".to_owned())
22363            .unwrap();
22364        assert_eq!(renamed.record_id, record_id);
22365        assert_eq!(renamed.display_name, "raw managed inventory");
22366        assert_eq!(renamed.state, ManagedSessionState::Unavailable);
22367        assert!(renamed.active_session.is_none());
22368
22369        drop(shared);
22370        drop(runtime);
22371        std::fs::remove_dir_all(materialization_fixture_root).unwrap();
22372        std::fs::remove_dir_all(git_root).unwrap();
22373    }
22374
22375    fn install_managed_lease(shared: &NodeShared) -> ManagedWorktreeLeaseRecord {
22376        let root = std::env::current_dir().unwrap()
22377            .join("managed-test-target")
22378            .to_string_lossy()
22379            .into_owned();
22380        let lease = ManagedWorktreeLeaseRecord {
22381            lease_id: ManagedWorktreeLeaseId::new("mw-test").unwrap(),
22382            source_workspace_id: WorkspaceId::new("primary").unwrap(),
22383            workspace_id: WorkspaceId::new("managed-test").unwrap(),
22384            profile_id: WorktreeProfileId::new("default").unwrap(),
22385            profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
22386            target_root: root.clone(),
22387            branch: "gate4agent/mw-test".to_owned(),
22388            base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
22389            expected_head: Some("0123456789abcdef0123456789abcdef01234567".to_owned()),
22390            retention: ManagedWorktreeRetention::RemoveWhenReleased,
22391            state: ManagedWorktreeLeaseState::Ready,
22392            session_holders: Vec::new(),
22393            record_holders: Vec::new(),
22394            cleanup_failure: None,
22395            created_at_unix_ms: 1,
22396            updated_at_unix_ms: 1,
22397        };
22398        shared.workspaces.write()
22399            .unwrap_or_else(|poisoned| poisoned.into_inner())
22400            .insert(lease.workspace_id.clone(), root);
22401        *shared.managed_worktrees.lock()
22402            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22403            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
22404        lease
22405    }
22406
22407    #[cfg(feature = "fixture")]
22408    #[test]
22409    fn monitoring_fixture_managed_spawn_retains_effective_record_holder_and_converges_live() {
22410        let mut shared = terminal_test_shared();
22411        shared.fixture_semantic_hook_policy = true;
22412        let lease = install_managed_lease(&shared);
22413        let address = SessionAddress {
22414            workspace_id: lease.workspace_id.clone(),
22415            session: SessionKey {
22416                instance_id: AgentInstanceId(206),
22417                generation: SessionGeneration(1),
22418            },
22419        };
22420        let pre_admission_policy = ProviderRuntimePolicy::raw_pty();
22421        let effective_runtime_policy =
22422            shared.effective_spawn_runtime_policy(pre_admission_policy);
22423        assert_ne!(effective_runtime_policy, pre_admission_policy);
22424        assert!(effective_runtime_policy.provider_session_identity);
22425
22426        let record_id = shared
22427            .bind_spawn_session(
22428                &address,
22429                agent("claude"),
22430                SessionMode::Pty,
22431                effective_runtime_policy,
22432                SpawnRecordPolicy::Always,
22433                None,
22434            )
22435            .unwrap()
22436            .unwrap();
22437        assert_eq!(
22438            shared.record(&record_id).unwrap().state,
22439            ManagedSessionState::IdentityPending,
22440        );
22441        let (record_holder, raw_inventory_record_id) = {
22442            let mut bindings = shared
22443                .session_bindings
22444                .lock()
22445                .unwrap_or_else(|poisoned| poisoned.into_inner());
22446            let binding = bindings.get_mut(&address.session.instance_id).unwrap();
22447            binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
22448            managed_spawn_record_ownership(binding, effective_runtime_policy)
22449        };
22450        assert_eq!(record_holder.as_ref(), Some(&record_id));
22451        assert!(raw_inventory_record_id.is_none());
22452        let snapshot = shared
22453            .managed_worktrees
22454            .lock()
22455            .unwrap_or_else(|poisoned| poisoned.into_inner())
22456            .bind_session(
22457                &lease.lease_id,
22458                ManagedWorktreeSessionHolder {
22459                    incarnation_id: shared.incarnation_id,
22460                    instance_id: address.session.instance_id,
22461                    generation: address.session.generation,
22462                },
22463                record_holder,
22464                unix_time_ms(),
22465            )
22466            .unwrap();
22467        assert_eq!(snapshot.managed_record_count, 1);
22468        assert_eq!(
22469            shared
22470                .managed_worktrees
22471                .lock()
22472                .unwrap_or_else(|poisoned| poisoned.into_inner())
22473                .get(&lease.lease_id)
22474                .unwrap()
22475                .record_holders,
22476            vec![record_id.clone()],
22477        );
22478
22479        let identity = ProviderSessionIdentity {
22480            key: ProviderSessionKey::SessionId,
22481            id: "monitoring-fixture-managed-session".to_owned(),
22482            transcript_path: None,
22483        };
22484        shared.publish_control(ControlEvent {
22485            sequence: 1,
22486            command_id: None,
22487            instance_id: address.session.instance_id,
22488            generation: address.session.generation,
22489            event: ControlEventKind::ProviderEvent {
22490                sequence: 1,
22491                source: gate4agent_types::ProviderSource {
22492                    family: AdapterFamily::Hook,
22493                    binding: AdapterBinding::new(
22494                        gate4agent_types::AdapterId::new("claude-code").unwrap(),
22495                        "fixture/v1",
22496                        gate4agent_types::AdapterVerification::SyntheticFixture,
22497                    )
22498                    .unwrap(),
22499                },
22500                source_sequence: 1,
22501                event: ProviderEvent::SessionIdentityObserved {
22502                    identity: identity.clone(),
22503                },
22504            },
22505        });
22506
22507        let live = shared.record(&record_id).unwrap();
22508        assert_eq!(live.state, ManagedSessionState::Live);
22509        assert_eq!(live.provider_session.as_ref(), Some(&identity));
22510        assert_eq!(live.active_session.as_ref(), Some(&address));
22511        assert_eq!(
22512            shared
22513                .managed_worktrees
22514                .lock()
22515                .unwrap_or_else(|poisoned| poisoned.into_inner())
22516                .get(&lease.lease_id)
22517                .unwrap()
22518                .record_holders,
22519            vec![record_id],
22520        );
22521    }
22522
22523    fn managed_v2_test_shared(
22524        allocation_root: &Path,
22525        state_path: Option<PathBuf>,
22526        profile_revision: &str,
22527    ) -> NodeShared {
22528        let profile = ManagedWorktreeProfile::new(
22529            WorktreeProfileId::new("default").unwrap(),
22530            WorktreeProfileRevision::new(profile_revision).unwrap(),
22531            allocation_root,
22532            "gate4agent",
22533            "HEAD",
22534            ManagedWorktreeRetention::RemoveWhenReleased,
22535        )
22536        .unwrap();
22537        let workspace = WorkspaceConfig::new(
22538            WorkspaceId::new("primary").unwrap(),
22539            std::env::current_dir().unwrap(),
22540        )
22541        .unwrap()
22542        .with_worktree_service_mode(WorktreeServiceMode::Managed)
22543        .with_managed_worktree_profile(profile)
22544        .unwrap();
22545        let catalog = active_registry().unwrap();
22546        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22547        let mut shared = NodeShared::new(
22548            handle,
22549            "fixture-token".to_owned(),
22550            NodeId::new("node-terminal-test").unwrap(),
22551            vec![workspace],
22552            vec![agent("claude")],
22553        );
22554        shared.state_path = state_path;
22555        shared
22556    }
22557
22558    fn managed_v2_request(idempotency_key: &str) -> ManagedWorktreeSpawnRequestV2 {
22559        let mut spawn_spec = spawn_spec_fixture();
22560        spawn_spec.idempotency_key = SpawnIdempotencyKey::new(idempotency_key).unwrap();
22561        ManagedWorktreeSpawnRequestV2 {
22562            spawn_spec,
22563            worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
22564            expected_profile_revision: WorktreeProfileRevision::new("v1").unwrap(),
22565        }
22566    }
22567
22568    #[test]
22569    fn managed_spawn_receipt_rebinds_allocated_worktree_and_roundtrips_wire() {
22570        let shared = terminal_test_shared();
22571        let mut lease = install_managed_lease(&shared);
22572        let session = SessionAddress {
22573            workspace_id: lease.workspace_id.clone(),
22574            session: SessionKey {
22575                instance_id: AgentInstanceId(73),
22576                generation: SessionGeneration(4),
22577            },
22578        };
22579        lease.state = ManagedWorktreeLeaseState::InUse;
22580        lease.session_holders.push(ManagedWorktreeSessionHolder {
22581            incarnation_id: shared.incarnation_id,
22582            instance_id: session.session.instance_id,
22583            generation: session.session.generation,
22584        });
22585        let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
22586        assert!(resolved.target.worktree_id.is_none());
22587
22588        let receipt = managed_spawn_receipt(
22589            &resolved,
22590            shared.incarnation_id,
22591            session.clone(),
22592            lease.snapshot(),
22593            None,
22594            None,
22595            None,
22596        );
22597        assert!(resolved.target.worktree_id.is_none());
22598        assert_eq!(receipt.spawn.target.workspace_id, lease.source_workspace_id);
22599        assert_eq!(receipt.spawn.target.worktree_id.as_ref(), Some(&lease.workspace_id));
22600        assert_eq!(receipt.spawn.session, session);
22601        assert_eq!(receipt.lease.workspace_id, lease.workspace_id);
22602        let encoded = serde_json::to_vec(&receipt).unwrap();
22603        let decoded: ManagedWorktreeSpawnReceipt = serde_json::from_slice(&encoded).unwrap();
22604        assert_eq!(decoded, receipt);
22605    }
22606
22607    #[tokio::test]
22608    async fn managed_spawn_v2_rejects_profile_revision_before_allocation() {
22609        let root = temporary_workspace_root("managed-v2-revision");
22610        let allocation = root.join("allocation");
22611        std::fs::create_dir_all(&allocation).unwrap();
22612        let shared = managed_v2_test_shared(
22613            &allocation,
22614            Some(root.join("node-state.json")),
22615            "v1",
22616        );
22617        let mut request = managed_v2_request("managed-v2-revision");
22618        request.expected_profile_revision = WorktreeProfileRevision::new("v2").unwrap();
22619
22620        let failure = shared.spawn_managed_worktree_v2(request).await.unwrap_err();
22621
22622        assert_eq!(
22623            failure.code,
22624            NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
22625        );
22626        assert!(shared
22627            .managed_worktrees
22628            .lock()
22629            .unwrap_or_else(|poisoned| poisoned.into_inner())
22630            .records()
22631            .is_empty());
22632        assert!(shared
22633            .managed_spawn_replays
22634            .lock()
22635            .unwrap_or_else(|poisoned| poisoned.into_inner())
22636            .is_empty());
22637        std::fs::remove_dir_all(root).unwrap();
22638    }
22639
22640    #[test]
22641    fn managed_spawn_v2_concurrent_same_key_has_one_durable_reservation() {
22642        let root = temporary_workspace_root("managed-v2-concurrent");
22643        let allocation = root.join("allocation");
22644        std::fs::create_dir_all(&allocation).unwrap();
22645        let shared = Arc::new(managed_v2_test_shared(
22646            &allocation,
22647            Some(root.join("node-state.json")),
22648            "v1",
22649        ));
22650        let request = managed_v2_request("managed-v2-concurrent");
22651        let barrier = Arc::new(std::sync::Barrier::new(3));
22652        let mut joins = Vec::new();
22653        for _ in 0..2 {
22654            let shared = Arc::clone(&shared);
22655            let request = request.clone();
22656            let barrier = Arc::clone(&barrier);
22657            joins.push(std::thread::spawn(move || {
22658                let digest = managed_spawn_request_digest_v2(&request).unwrap();
22659                barrier.wait();
22660                shared.acquire_durable_managed_spawn_v2(&request, digest)
22661            }));
22662        }
22663        barrier.wait();
22664        let results = joins
22665            .into_iter()
22666            .map(|join| join.join().unwrap())
22667            .collect::<Vec<_>>();
22668        assert_eq!(
22669            results
22670                .iter()
22671                .filter(|result| {
22672                    matches!(result, Ok(DurableManagedSpawnReplayDecision::Reserved))
22673                })
22674                .count(),
22675            1,
22676        );
22677        assert_eq!(
22678            results
22679                .iter()
22680                .filter(|result| {
22681                    matches!(result, Err(error) if error.code == NodeFailureCode::BackendBusy)
22682                })
22683                .count(),
22684            1,
22685        );
22686        assert_eq!(
22687            shared
22688                .managed_spawn_replays
22689                .lock()
22690                .unwrap_or_else(|poisoned| poisoned.into_inner())
22691                .len(),
22692            1,
22693        );
22694        assert!(shared
22695            .managed_worktrees
22696            .lock()
22697            .unwrap_or_else(|poisoned| poisoned.into_inner())
22698            .records()
22699            .is_empty());
22700        drop(shared);
22701        std::fs::remove_dir_all(root).unwrap();
22702    }
22703
22704    #[tokio::test]
22705    async fn managed_spawn_v2_linked_pending_reopen_fails_closed_without_reallocation() {
22706        let root = temporary_workspace_root("managed-v2-linked-pending");
22707        let allocation = root.join("allocation");
22708        std::fs::create_dir_all(&allocation).unwrap();
22709        let state_path = root.join("node-state.json");
22710        let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
22711        let lease = install_managed_lease(&shared);
22712        let request = managed_v2_request("managed-v2-linked-pending");
22713        assert!(matches!(
22714            shared
22715                .acquire_durable_managed_spawn_v2(
22716                    &request,
22717                    managed_spawn_request_digest_v2(&request).unwrap(),
22718                )
22719                .unwrap(),
22720            DurableManagedSpawnReplayDecision::Reserved,
22721        ));
22722        {
22723            let _transaction = shared
22724                .state_transaction
22725                .lock()
22726                .unwrap_or_else(|poisoned| poisoned.into_inner());
22727            shared
22728                .link_durable_managed_spawn_v2_lease_locked(
22729                    &request.spawn_spec.idempotency_key,
22730                    &lease.lease_id,
22731                )
22732                .unwrap();
22733            shared.persist_state_locked().unwrap();
22734        }
22735        drop(shared);
22736
22737        let mut loaded = session_registry::load(
22738            Some(&state_path),
22739            &NodeId::new("node-terminal-test").unwrap(),
22740        )
22741        .unwrap();
22742        let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22743        *reopened
22744            .managed_worktrees
22745            .lock()
22746            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22747            ManagedWorktreeRegistry::from_records(
22748                std::mem::take(&mut loaded.managed_worktrees),
22749                std::mem::take(&mut loaded.managed_worktree_tombstones),
22750            )
22751            .unwrap();
22752        *reopened
22753            .managed_spawn_replays
22754            .lock()
22755            .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22756            .managed_spawn_replays
22757            .into_iter()
22758            .map(|record| (record.idempotency_key.clone(), record))
22759            .collect();
22760        let lease_count = reopened
22761            .managed_worktrees
22762            .lock()
22763            .unwrap_or_else(|poisoned| poisoned.into_inner())
22764            .records()
22765            .len();
22766
22767        let failure = reopened
22768            .spawn_managed_worktree_v2(request)
22769            .await
22770            .unwrap_err();
22771
22772        assert_eq!(
22773            failure.code,
22774            NodeFailureCode::ManagedWorktreeRecoveryRequired,
22775        );
22776        assert_eq!(
22777            reopened
22778                .managed_worktrees
22779                .lock()
22780                .unwrap_or_else(|poisoned| poisoned.into_inner())
22781                .records()
22782                .len(),
22783            lease_count,
22784        );
22785        std::fs::remove_dir_all(root).unwrap();
22786    }
22787
22788    #[tokio::test]
22789    async fn managed_spawn_v2_replays_exact_receipt_after_durable_reopen_and_conflicts() {
22790        let root = temporary_workspace_root("managed-v2-reopen");
22791        let allocation = root.join("allocation");
22792        std::fs::create_dir_all(&allocation).unwrap();
22793        let state_path = root.join("node-state.json");
22794        let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
22795        let lease = install_managed_lease(&shared);
22796        let request = managed_v2_request("managed-v2-reopen");
22797        let session = SessionAddress {
22798            workspace_id: lease.workspace_id.clone(),
22799            session: SessionKey {
22800                instance_id: AgentInstanceId(91),
22801                generation: SessionGeneration(1),
22802            },
22803        };
22804        let snapshot = {
22805            let mut registry = shared
22806                .managed_worktrees
22807                .lock()
22808                .unwrap_or_else(|poisoned| poisoned.into_inner());
22809            let current = registry.get_mut(&lease.lease_id).unwrap();
22810            current.state = ManagedWorktreeLeaseState::InUse;
22811            current.session_holders.push(ManagedWorktreeSessionHolder {
22812                incarnation_id: shared.incarnation_id,
22813                instance_id: session.session.instance_id,
22814                generation: session.session.generation,
22815            });
22816            current.snapshot()
22817        };
22818        let resolved = shared.resolve_spawn_spec(&request.spawn_spec).unwrap();
22819        let receipt = managed_spawn_receipt(
22820            &resolved,
22821            shared.incarnation_id,
22822            session,
22823            snapshot,
22824            None,
22825            None,
22826            None,
22827        );
22828        assert!(matches!(
22829            shared
22830                .acquire_durable_managed_spawn_v2(
22831                    &request,
22832                    managed_spawn_request_digest_v2(&request).unwrap(),
22833                )
22834                .unwrap(),
22835            DurableManagedSpawnReplayDecision::Reserved,
22836        ));
22837        {
22838            let _transaction = shared
22839                .state_transaction
22840                .lock()
22841                .unwrap_or_else(|poisoned| poisoned.into_inner());
22842            shared
22843                .link_durable_managed_spawn_v2_lease_locked(
22844                    &request.spawn_spec.idempotency_key,
22845                    &lease.lease_id,
22846                )
22847                .unwrap();
22848            shared.persist_state_locked().unwrap();
22849        }
22850        shared
22851            .commit_durable_managed_spawn_v2(&request, receipt.clone())
22852            .unwrap();
22853        let legacy_same_key = ManagedWorktreeSpawnRequest {
22854            spawn_spec: request.spawn_spec.clone(),
22855            worktree_profile_id: request.worktree_profile_id.clone(),
22856        };
22857        shared.remember_managed_spawn_attempt(
22858            Some(&request.spawn_spec.idempotency_key),
22859            legacy_same_key.clone(),
22860            Ok(receipt.clone()),
22861            Instant::now(),
22862        );
22863        assert!(shared
22864            .replay_managed_spawn(&legacy_same_key, Instant::now())
22865            .unwrap()
22866            .is_none());
22867        drop(shared);
22868
22869        let mut loaded = session_registry::load(
22870            Some(&state_path),
22871            &NodeId::new("node-terminal-test").unwrap(),
22872        )
22873        .unwrap();
22874        assert_eq!(loaded.managed_spawn_replays.len(), 1);
22875        let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22876        *reopened
22877            .managed_worktrees
22878            .lock()
22879            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22880            ManagedWorktreeRegistry::from_records(
22881                std::mem::take(&mut loaded.managed_worktrees),
22882                std::mem::take(&mut loaded.managed_worktree_tombstones),
22883            )
22884            .unwrap();
22885        *reopened
22886            .managed_spawn_replays
22887            .lock()
22888            .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22889            .managed_spawn_replays
22890            .into_iter()
22891            .map(|record| (record.idempotency_key.clone(), record))
22892            .collect();
22893        let lease_count_before = reopened
22894            .managed_worktrees
22895            .lock()
22896            .unwrap_or_else(|poisoned| poisoned.into_inner())
22897            .records()
22898            .len();
22899
22900        assert_eq!(
22901            reopened
22902                .spawn_managed_worktree_v2(request.clone())
22903                .await
22904                .unwrap(),
22905            receipt,
22906        );
22907        assert_eq!(
22908            reopened
22909                .managed_worktrees
22910                .lock()
22911                .unwrap_or_else(|poisoned| poisoned.into_inner())
22912                .records()
22913                .len(),
22914            lease_count_before,
22915        );
22916        let mut changed = request;
22917        changed.spawn_spec.deadline_ms = crate::protocol::SpawnDeadlineMs::new(29_000).unwrap();
22918        let failure = reopened
22919            .spawn_managed_worktree_v2(changed)
22920            .await
22921            .unwrap_err();
22922        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
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        let mut new_key = managed_v2_request("managed-v2-new-after-profile-change");
22933        new_key.expected_profile_revision = WorktreeProfileRevision::new("v1").unwrap();
22934        let failure = reopened
22935            .spawn_managed_worktree_v2(new_key)
22936            .await
22937            .unwrap_err();
22938        assert_eq!(
22939            failure.code,
22940            NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
22941        );
22942        assert_eq!(
22943            reopened
22944                .managed_worktrees
22945                .lock()
22946                .unwrap_or_else(|poisoned| poisoned.into_inner())
22947                .records()
22948                .len(),
22949            lease_count_before,
22950        );
22951        assert_eq!(
22952            reopened
22953                .managed_spawn_replays
22954                .lock()
22955                .unwrap_or_else(|poisoned| poisoned.into_inner())
22956                .len(),
22957            1,
22958        );
22959        std::fs::remove_dir_all(root).unwrap();
22960    }
22961
22962    #[test]
22963    fn legacy_managed_spawn_idempotency_remains_transient_and_exact() {
22964        let shared = terminal_test_shared();
22965        let request = ManagedWorktreeSpawnRequest {
22966            spawn_spec: spawn_spec_fixture(),
22967            worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
22968        };
22969        let expected = failure(NodeFailureCode::BackendBusy, "legacy fixture failure");
22970        shared.remember_managed_spawn(
22971            request.clone(),
22972            Err(expected.clone()),
22973            Instant::now(),
22974        );
22975
22976        assert_eq!(
22977            shared
22978                .replay_managed_spawn(&request, Instant::now())
22979                .unwrap()
22980                .unwrap(),
22981            Err(expected),
22982        );
22983        assert!(shared
22984            .managed_spawn_replays
22985            .lock()
22986            .unwrap_or_else(|poisoned| poisoned.into_inner())
22987            .is_empty());
22988    }
22989
22990    #[tokio::test]
22991    async fn ordinary_spawn_and_spawn_spec_cannot_bypass_managed_lease_holder_fence() {
22992        let shared = terminal_test_shared();
22993        let lease = install_managed_lease(&shared);
22994        let raw = shared.spawn_session(
22995            lease.workspace_id.clone(),
22996            agent("claude"),
22997            SessionMode::Pty,
22998            gate4agent_types::TerminalSize { rows: 24, columns: 80 },
22999            None,
23000        ).await.unwrap_err();
23001        assert_eq!(raw.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
23002        let mut spec = spawn_spec_fixture();
23003        spec.target.workspace_id = lease.workspace_id.clone();
23004        let specified = shared.spawn_from_spec(spec).await.unwrap_err();
23005        assert_eq!(specified.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
23006        let mut selected = spawn_spec_fixture();
23007        selected.idempotency_key = SpawnIdempotencyKey::new("managed-selected-bypass").unwrap();
23008        selected.target.worktree_id = Some(lease.workspace_id);
23009        let selected = shared.spawn_from_spec(selected).await.unwrap_err();
23010        assert_eq!(selected.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
23011        assert!(shared.handle.snapshot().sessions.is_empty());
23012    }
23013
23014    #[test]
23015    fn legacy_projection_omits_managed_snapshot_events_and_path_bearing_diagnostics() {
23016        let shared = terminal_test_shared();
23017        let lease = install_managed_lease(&shared);
23018        let snapshot = snapshot_for_wire(
23019            &shared, true, true, true, false, true, true, false, false, true,
23020        );
23021        assert!(snapshot.managed_worktrees.is_empty());
23022        assert!(snapshot.workspaces.iter().all(|workspace| {
23023            workspace.managed_worktree_profiles.is_none()
23024        }));
23025        let mut reply = ResponseEnvelope {
23026            request_id: 1,
23027            result: Ok(NodeResponse::Resync {
23028                event_sequence: 1,
23029                oldest_available_sequence: 1,
23030                snapshot: shared.snapshot(),
23031                events: vec![NodeEventEnvelope {
23032                    sequence: 1,
23033                    event: NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() },
23034                }],
23035            }),
23036        };
23037        project_response_without_managed_worktrees(&mut reply);
23038        let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
23039            panic!("projection changed the response kind");
23040        };
23041        assert!(snapshot.managed_worktrees.is_empty());
23042        assert!(events.is_empty());
23043        let mut git = git_snapshot_for_parser();
23044        git.branch = Some(lease.branch.clone());
23045        git.managed_worktree = Some(crate::protocol::ManagedWorktreeGitScope {
23046            lease_id: lease.lease_id.clone(),
23047            source_workspace_id: lease.source_workspace_id.clone(),
23048            branch: lease.branch.clone(),
23049            base_commit: GitObjectId::new(lease.base_commit.clone()).unwrap(),
23050            active_session_count: 1,
23051            managed_record_count: 0,
23052        });
23053        let mut inspection_reply = ResponseEnvelope {
23054            request_id: 2,
23055            result: Ok(NodeResponse::WorkspaceInspected {
23056                inspection: WorkspaceInspection {
23057                    workspace_id: lease.workspace_id.clone(),
23058                    entries: Vec::new(),
23059                    tree_truncated: false,
23060                    git,
23061                    truncation: None,
23062                },
23063            }),
23064        };
23065        project_response_without_managed_worktrees(&mut inspection_reply);
23066        let Ok(NodeResponse::WorkspaceInspected { inspection }) = inspection_reply.result else {
23067            panic!("projection changed the inspection response kind");
23068        };
23069        assert!(inspection.git.managed_worktree.is_none());
23070        let failure = managed_git_worktree_failure(GitWorktreeError {
23071            kind: GitWorktreeErrorKind::Failed,
23072            message: r"git failed at C:\private\managed-target".to_owned(),
23073        });
23074        assert!(!failure.message.contains("private"));
23075        assert!(!failure.message.contains("managed-target"));
23076    }
23077
23078    #[tokio::test]
23079    async fn restart_reconciliation_retains_exact_owned_worktree_and_explicit_cleanup_removes_it() {
23080        let root = temporary_workspace_root("managed-restart");
23081        let source = root.join("source");
23082        let allocation = root.join("allocation");
23083        std::fs::create_dir_all(&source).unwrap();
23084        std::fs::create_dir_all(&allocation).unwrap();
23085        run_test_git(&source, &["init"]);
23086        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
23087        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
23088        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
23089        run_test_git(&source, &["add", "seed.txt"]);
23090        run_test_git(&source, &["commit", "-m", "seed"]);
23091        let profile = ManagedWorktreeProfile::new(
23092            WorktreeProfileId::new("default").unwrap(),
23093            crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
23094            &allocation,
23095            "gate4agent",
23096            "HEAD",
23097            ManagedWorktreeRetention::Retain,
23098        ).unwrap();
23099        let workspace = WorkspaceConfig::new(
23100            WorkspaceId::new("primary").unwrap(),
23101            &source,
23102        ).unwrap()
23103            .with_worktree_service_mode(WorktreeServiceMode::Managed)
23104            .with_managed_worktree_profile(profile.clone()).unwrap();
23105        let target = PathBuf::from(profile.allocation_root()).join("mw-restart");
23106        let source_text = workspace.canonical_root().to_owned();
23107        let target_text = target.to_string_lossy().into_owned();
23108        let created = create_git_worktree(
23109            &source_text,
23110            &target_text,
23111            "gate4agent/mw-restart",
23112            Some("HEAD"),
23113        ).await.unwrap();
23114        let lease = ManagedWorktreeLeaseRecord {
23115            lease_id: ManagedWorktreeLeaseId::new("mw-restart").unwrap(),
23116            source_workspace_id: WorkspaceId::new("primary").unwrap(),
23117            workspace_id: WorkspaceId::new("managed-restart").unwrap(),
23118            profile_id: profile.profile_id().clone(),
23119            profile_revision: profile.revision().clone(),
23120            target_root: created.path.clone(),
23121            branch: "gate4agent/mw-restart".to_owned(),
23122            base_commit: created.head.clone(),
23123            expected_head: None,
23124            retention: ManagedWorktreeRetention::Retain,
23125            state: ManagedWorktreeLeaseState::Allocating,
23126            session_holders: Vec::new(),
23127            record_holders: Vec::new(),
23128            cleanup_failure: None,
23129            created_at_unix_ms: 1,
23130            updated_at_unix_ms: 1,
23131        };
23132        let catalog = active_registry().unwrap();
23133        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23134        let shared = NodeShared::new_with_incarnation(
23135            handle,
23136            "fixture-token".to_owned(),
23137            NodeId::new("node-managed-restart").unwrap(),
23138            NodeIncarnationId::from_bytes([7; crate::protocol::NODE_INCARNATION_ID_BYTES]),
23139            vec![workspace],
23140            vec![agent("claude")],
23141            ProviderRuntimeStatuses::default(),
23142            None,
23143            Vec::new(),
23144            Vec::new(),
23145            SpawnProfileRegistry::default(),
23146            None,
23147            None,
23148            None,
23149            None,
23150            Vec::new(),
23151            vec![lease.clone()],
23152            Vec::new(),
23153            Vec::new(),
23154            None,
23155            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
23156        );
23157        shared.reconcile_managed_worktrees().await;
23158        let retained = shared.managed_worktrees.lock()
23159            .unwrap_or_else(|poisoned| poisoned.into_inner())
23160            .get(&lease.lease_id).unwrap().clone();
23161        assert_eq!(retained.state, ManagedWorktreeLeaseState::Retained);
23162        assert!(shared.workspaces.read()
23163            .unwrap_or_else(|poisoned| poisoned.into_inner())
23164            .contains_key(&lease.workspace_id));
23165        let removed = shared.cleanup_managed_worktree(&lease.lease_id, true).await.unwrap();
23166        assert_eq!(removed.state, ManagedWorktreeLeaseState::Removed);
23167        assert!(!Path::new(&created.path).exists());
23168        std::fs::remove_dir_all(root).unwrap();
23169    }
23170
23171    fn run_test_git(root: &Path, arguments: &[&str]) {
23172        let output = std::process::Command::new("git")
23173            .arg("-C").arg(root).args(arguments).output().unwrap();
23174        assert!(
23175            output.status.success(),
23176            "git {:?} failed: {}",
23177            arguments,
23178            String::from_utf8_lossy(&output.stderr),
23179        );
23180    }
23181
23182    #[tokio::test]
23183    async fn spawn_spec_validation_fails_before_session_mutation() {
23184        let shared = terminal_test_shared();
23185        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23186            capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
23187        }));
23188        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23189            capability.as_str() == NODE_SPAWN_PROFILE_REVISION_CAPABILITY
23190        }));
23191        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23192            capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
23193        }));
23194
23195        let mut target_mismatch = spawn_spec_fixture();
23196        target_mismatch.target.node_id = NodeId::new("other-node").unwrap();
23197        let failure = shared.spawn_from_spec(target_mismatch).await.unwrap_err();
23198        assert_eq!(failure.code, NodeFailureCode::SpawnTargetMismatch);
23199
23200        let mut unknown_profile = spawn_spec_fixture();
23201        unknown_profile.idempotency_key = SpawnIdempotencyKey::new("unknown-profile").unwrap();
23202        unknown_profile.profile_id = crate::protocol::SpawnProfileId::new("missing").unwrap();
23203        let failure = shared.spawn_from_spec(unknown_profile).await.unwrap_err();
23204        assert_eq!(failure.code, NodeFailureCode::UnknownSpawnProfile);
23205
23206        let mut revision_mismatch = spawn_spec_fixture();
23207        revision_mismatch.idempotency_key =
23208            SpawnIdempotencyKey::new("profile-revision-mismatch").unwrap();
23209        revision_mismatch.expected_profile_revision =
23210            crate::protocol::SpawnProfileRevision::new("test-r2").unwrap();
23211        let failure = shared.spawn_from_spec(revision_mismatch).await.unwrap_err();
23212        assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
23213
23214        let mut unsupported_materializer = spawn_spec_fixture();
23215        unsupported_materializer.idempotency_key =
23216            SpawnIdempotencyKey::new("unsupported-materializer").unwrap();
23217        unsupported_materializer.overrides.bundle_id = crate::protocol::SpawnOverride::Set {
23218            value: crate::protocol::SpawnBundleId::new("bundle-v1").unwrap(),
23219        };
23220        let failure = shared
23221            .spawn_from_spec(unsupported_materializer)
23222            .await
23223            .unwrap_err();
23224        assert_eq!(failure.code, NodeFailureCode::UnknownBundle);
23225
23226        let failure = shared
23227            .spawn_session_with_deadline(
23228                WorkspaceId::new("primary").unwrap(),
23229                agent("claude"),
23230                SessionMode::Pty,
23231                gate4agent_types::TerminalSize {
23232                    rows: 24,
23233                    columns: 80,
23234                },
23235                None,
23236                None,
23237                None,
23238                None,
23239                None,
23240                None,
23241                SpawnRecordPolicy::ProviderIdentityOnly,
23242                Some(Instant::now()),
23243                &[],
23244                None,
23245                ApprovalLevel::default(),
23246                None,
23247                None,
23248            )
23249            .await
23250            .unwrap_err();
23251        assert_eq!(failure.code, NodeFailureCode::SpawnDeadlineExceeded);
23252        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
23253        assert!(shared.handle.snapshot().sessions.is_empty());
23254    }
23255
23256    #[test]
23257    fn worktree_selection_requires_its_separate_negotiated_capability() {
23258        let mut spec = spawn_spec_fixture();
23259        spec.target.worktree_id = Some(WorkspaceId::new("selected-worktree").unwrap());
23260        let request = NodeRequest::SpawnSpec { spec };
23261        let spawn_spec_capability =
23262            CapabilityId::new(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY).unwrap();
23263        assert!(request_uses_unnegotiated_capability(
23264            &request,
23265            &[spawn_spec_capability.clone()],
23266        ));
23267        let worktree_capability =
23268            CapabilityId::new(NODE_WORKTREE_SELECTION_CAPABILITY).unwrap();
23269        assert!(request_uses_unnegotiated_capability(
23270            &request,
23271            &[spawn_spec_capability.clone(), worktree_capability.clone()],
23272        ));
23273        let profile_revision_capability =
23274            CapabilityId::new(NODE_SPAWN_PROFILE_REVISION_CAPABILITY).unwrap();
23275        assert!(request_uses_unnegotiated_capability(
23276            &request,
23277            &[
23278                spawn_spec_capability.clone(),
23279                worktree_capability.clone(),
23280                profile_revision_capability.clone(),
23281            ],
23282        ));
23283        // Default `SpawnOverrides` leaves `bundle_id`/`context_id` at
23284        // `Inherit`, not `Clear` — the profile's own defaults might still
23285        // materialize a bundle or a context pack, so both capabilities stay
23286        // conservatively required until negotiated (see
23287        // `requires_session_bundle_materialization_capability` /
23288        // `requires_history_context_pack_capability`).
23289        let bundle_materialization_capability =
23290            CapabilityId::new(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY).unwrap();
23291        assert!(request_uses_unnegotiated_capability(
23292            &request,
23293            &[
23294                spawn_spec_capability.clone(),
23295                worktree_capability.clone(),
23296                profile_revision_capability.clone(),
23297                bundle_materialization_capability.clone(),
23298            ],
23299        ));
23300        let history_context_pack_capability =
23301            CapabilityId::new(NODE_HISTORY_CONTEXT_PACK_CAPABILITY).unwrap();
23302        assert!(!request_uses_unnegotiated_capability(
23303            &request,
23304            &[
23305                spawn_spec_capability,
23306                worktree_capability,
23307                profile_revision_capability,
23308                bundle_materialization_capability,
23309                history_context_pack_capability,
23310            ],
23311        ));
23312    }
23313
23314    #[tokio::test]
23315    async fn spawn_spec_idempotency_replays_receipt_and_conflicts_before_session_mutation() {
23316        let mut shared = terminal_test_shared();
23317        let spec = spawn_spec_fixture();
23318        let receipt = shared
23319            .resolve_spawn_spec(&spec)
23320            .unwrap()
23321            .receipt(shared.incarnation_id, terminal_address(1));
23322        shared.remember_spawn_spec(spec.clone(), Ok(receipt.clone()), Instant::now());
23323
23324        let mut revised = shared.spawn_profiles.get(&spec.profile_id).unwrap().clone();
23325        revised.revision = crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
23326        shared.spawn_profiles = SpawnProfileRegistry::new([revised]).unwrap();
23327
23328        assert_eq!(shared.spawn_from_spec(spec.clone()).await.unwrap(), receipt);
23329
23330        let mut revision_conflicting = spec.clone();
23331        revision_conflicting.expected_profile_revision =
23332            crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
23333        let failure = shared.spawn_from_spec(revision_conflicting).await.unwrap_err();
23334        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
23335
23336        let mut stale_cache_miss = spec.clone();
23337        stale_cache_miss.idempotency_key =
23338            SpawnIdempotencyKey::new("stale-profile-cache-miss").unwrap();
23339        let failure = shared.spawn_from_spec(stale_cache_miss).await.unwrap_err();
23340        assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
23341
23342        let mut conflicting = spec;
23343        conflicting.deadline_ms = crate::protocol::SpawnDeadlineMs::new(31_000).unwrap();
23344        let failure = shared.spawn_from_spec(conflicting).await.unwrap_err();
23345        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
23346        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
23347        assert!(shared.handle.snapshot().sessions.is_empty());
23348    }
23349
23350    #[tokio::test]
23351    async fn off_worktree_service_rejects_selection_and_mutation_before_git() {
23352        let shared = terminal_test_shared_with_mode(WorktreeServiceMode::Off);
23353        let mut spec = spawn_spec_fixture();
23354        spec.target.worktree_id = Some(WorkspaceId::new("unregistered-worktree").unwrap());
23355        let failure = shared.spawn_from_spec(spec).await.unwrap_err();
23356        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
23357
23358        let failure = shared
23359            .create_worktree(
23360                WorkspaceId::new("primary").unwrap(),
23361                WorkspaceId::new("new-worktree").unwrap(),
23362                "not-an-absolute-path".to_owned(),
23363                "invalid branch".to_owned(),
23364                None,
23365            )
23366            .await
23367            .unwrap_err();
23368        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
23369
23370        let failure = shared
23371            .remove_worktree(
23372                WorkspaceId::new("primary").unwrap(),
23373                "not-an-absolute-path".to_owned(),
23374            )
23375            .await
23376            .unwrap_err();
23377        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
23378        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
23379        assert!(shared.handle.snapshot().sessions.is_empty());
23380    }
23381
23382    #[tokio::test]
23383    async fn manual_worktree_registration_failure_removes_the_created_worktree() {
23384        let root = temporary_workspace_root("manual-worktree-compensation");
23385        let source = root.join("source");
23386        let target = root.join("target");
23387        let invalid_state_parent = root.join("state-parent-is-a-file");
23388        let invalid_state_path = invalid_state_parent.join("state.json");
23389        std::fs::create_dir_all(&source).unwrap();
23390        std::fs::write(&invalid_state_parent, "not a directory").unwrap();
23391        run_test_git(&source, &["init"]);
23392        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
23393        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
23394        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
23395        run_test_git(&source, &["add", "seed.txt"]);
23396        run_test_git(&source, &["commit", "-m", "seed"]);
23397
23398        let catalog = active_registry().unwrap();
23399        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23400        let workspace = WorkspaceConfig::new(
23401            WorkspaceId::new("primary").unwrap(),
23402            &source,
23403        ).unwrap();
23404        let mut shared = NodeShared::new(
23405            handle,
23406            "fixture-token".to_owned(),
23407            NodeId::new("node-worktree-compensation").unwrap(),
23408            vec![workspace],
23409            vec![agent("claude")],
23410        );
23411        shared.state_path = Some(invalid_state_path);
23412
23413        let error = shared.create_worktree(
23414            WorkspaceId::new("primary").unwrap(),
23415            WorkspaceId::new("compensated").unwrap(),
23416            target.to_string_lossy().into_owned(),
23417            "gate4agent/compensated".to_owned(),
23418            Some("HEAD".to_owned()),
23419        ).await.unwrap_err();
23420
23421        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
23422        assert!(!target.exists());
23423        assert!(list_git_worktrees(source.to_str().unwrap()).await.unwrap()
23424            .iter()
23425            .all(|worktree| !worktree_paths_equal(&worktree.path, &target.to_string_lossy())));
23426        std::fs::remove_dir_all(root).unwrap();
23427    }
23428
23429    #[test]
23430    fn selected_worktree_requires_authoritative_non_main_non_bare_entry() {
23431        fn worktree(path: &str, is_main: bool, is_bare: bool) -> NativeGitWorktreeSnapshot {
23432            NativeGitWorktreeSnapshot {
23433                path: path.to_owned(),
23434                head: "0123456789abcdef".to_owned(),
23435                branch: Some("refs/heads/test".to_owned()),
23436                is_bare,
23437                is_main,
23438                locked: false,
23439                lock_reason: None,
23440                prunable: false,
23441                prunable_reason: None,
23442                workspace_id: None,
23443            }
23444        }
23445
23446        let source = r"C:\repo";
23447        let selected = r"C:\repo-worktree";
23448        let listed = vec![
23449            worktree(source, true, false),
23450            worktree(selected, false, false),
23451        ];
23452        assert!(validate_selected_worktree(source, selected, &listed).is_ok());
23453
23454        let main_failure = validate_selected_worktree(source, source, &listed).unwrap_err();
23455        assert_eq!(main_failure.code, NodeFailureCode::WorktreeProtected);
23456
23457        let bare = vec![
23458            worktree(source, true, false),
23459            worktree(selected, false, true),
23460        ];
23461        let bare_failure = validate_selected_worktree(source, selected, &bare).unwrap_err();
23462        assert_eq!(bare_failure.code, NodeFailureCode::WorktreeProtected);
23463
23464        let foreign_failure =
23465            validate_selected_worktree(source, r"C:\foreign", &listed).unwrap_err();
23466        assert_eq!(foreign_failure.code, NodeFailureCode::WorktreeProtected);
23467    }
23468
23469    fn terminal_frame(sequence: u64) -> TerminalFrame {
23470        TerminalFrame {
23471            sequence,
23472            size: gate4agent_types::TerminalSize {
23473                rows: 24,
23474                columns: 80,
23475            },
23476            cursor_row: 2,
23477            cursor_column: 3,
23478            contents: format!("frame-{sequence}"),
23479            formatted: vec![1, 2, 3],
23480            scrollback_formatted: Vec::new(),
23481            alternate_screen: false,
23482            mouse_protocol_enabled: false,
23483            mouse_protocol_encoding: Default::default(),
23484            produced_at_unix_ms: 0,
23485            screen_state: gate4agent_types::PtyScreenState::default(),
23486            bracketed_paste: None,
23487        }
23488    }
23489
23490    #[test]
23491    fn terminal_frame_watermark_coalesces_and_resets_for_exact_session_address() {
23492        let shared = terminal_test_shared();
23493        let mut receiver = shared.terminal_event_tx.subscribe();
23494        let original = terminal_address(1);
23495
23496        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
23497        let first = receiver.try_recv().unwrap();
23498        assert_eq!(first.len(), 1);
23499        assert!(matches!(
23500            &first[0].event,
23501            NodeEvent::TerminalFrame { address, frame }
23502                if address == &original && frame.sequence == 7
23503        ));
23504
23505        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
23506        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(6))]);
23507        assert!(matches!(
23508            receiver.try_recv(),
23509            Err(broadcast::error::TryRecvError::Empty)
23510        ));
23511
23512        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(8))]);
23513        let advanced = receiver.try_recv().unwrap();
23514        assert_eq!(advanced[0].sequence, 2);
23515
23516        let resumed = terminal_address(2);
23517        shared.publish_terminal_frame_candidates(vec![(resumed.clone(), terminal_frame(1))]);
23518        let reset = receiver.try_recv().unwrap();
23519        assert_eq!(reset[0].sequence, 3);
23520        assert!(matches!(
23521            &reset[0].event,
23522            NodeEvent::TerminalFrame { address, frame }
23523                if address == &resumed && frame.sequence == 1
23524        ));
23525        shared.clear_terminal_frame_watermark(&resumed);
23526        assert!(!shared
23527            .terminal_frame_watermarks
23528            .lock()
23529            .unwrap_or_else(|poisoned| poisoned.into_inner())
23530            .contains_key(&resumed.session.instance_id));
23531    }
23532
23533    #[test]
23534    fn terminal_frame_batches_replace_without_entering_node_event_history() {
23535        let shared = terminal_test_shared();
23536        let mut receiver = shared.terminal_event_tx.subscribe();
23537        let address = terminal_address(1);
23538        let count = NODE_EVENT_HISTORY_MAX as u64 + 17;
23539
23540        for sequence in 1..=count {
23541            shared.publish_terminal_frame_candidates(vec![(
23542                address.clone(),
23543                terminal_frame(sequence),
23544            )]);
23545        }
23546
23547        let history = shared
23548            .history
23549            .lock()
23550            .unwrap_or_else(|poisoned| poisoned.into_inner());
23551        assert!(history.events.is_empty());
23552        assert_eq!(history.last_sequence, count);
23553        assert_eq!(history.replay_floor_sequence, 1);
23554        drop(history);
23555        let NodeResponse::Resync {
23556            event_sequence,
23557            oldest_available_sequence,
23558            events,
23559            ..
23560        } = shared.resync(0) else {
23561            unreachable!("resync helper returned another response")
23562        };
23563        assert_eq!(event_sequence, count);
23564        assert_eq!(oldest_available_sequence, 1);
23565        assert!(events.is_empty());
23566        assert!(matches!(
23567            resync_required_event(&shared).event,
23568            NodeEvent::ResyncRequired {
23569                oldest_available_sequence: 1,
23570            }
23571        ));
23572        assert!(matches!(
23573            receiver.try_recv(),
23574            Err(broadcast::error::TryRecvError::Lagged(skipped)) if skipped == count - 1
23575        ));
23576        let latest = receiver.try_recv().unwrap();
23577        assert_eq!(latest.len(), 1);
23578        assert_eq!(latest[0].sequence, count);
23579    }
23580
23581    #[test]
23582    fn real_durable_eviction_advances_authoritative_replay_floor() {
23583        let shared = terminal_test_shared();
23584        for _ in 0..=NODE_EVENT_HISTORY_MAX {
23585            shared.publish(NodeEvent::ControllerChanged { controller: None });
23586        }
23587
23588        let NodeResponse::Resync {
23589            event_sequence,
23590            oldest_available_sequence,
23591            events,
23592            ..
23593        } = shared.resync(0) else {
23594            unreachable!("resync helper returned another response")
23595        };
23596        assert_eq!(event_sequence, NODE_EVENT_HISTORY_MAX as u64 + 1);
23597        assert_eq!(oldest_available_sequence, 2);
23598        assert_eq!(events.first().map(|event| event.sequence), Some(2));
23599        assert_eq!(events.len(), NODE_EVENT_HISTORY_MAX);
23600        assert!(matches!(
23601            resync_required_event(&shared).event,
23602            NodeEvent::ResyncRequired {
23603                oldest_available_sequence: 2,
23604            }
23605        ));
23606    }
23607
23608    #[test]
23609    fn legacy_terminal_frame_connection_has_no_live_subscription() {
23610        let shared = terminal_test_shared();
23611        let without_capability = terminal_event_subscription(&shared, false);
23612        let with_capability = terminal_event_subscription(&shared, true);
23613        assert!(without_capability.is_none());
23614        assert!(with_capability.is_some());
23615        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23616            capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
23617        }));
23618    }
23619
23620    #[test]
23621    fn continuous_terminal_output_leaves_request_service_after_bounded_event_burst() {
23622        let mut pending = Vec::new();
23623        let mut event_sequence = 0_u64;
23624        for frame_sequence in 1..=4 {
23625            for instance in 1..=(NODE_CONNECTION_EVENT_BURST_MAX as u64 * 2) {
23626                event_sequence += 1;
23627                let mut address = terminal_address(1);
23628                address.session.instance_id = AgentInstanceId(instance);
23629                queue_connection_event(
23630                    &mut pending,
23631                    NodeEventEnvelope {
23632                        sequence: event_sequence,
23633                        event: NodeEvent::TerminalFrame {
23634                            address,
23635                            frame: terminal_frame(frame_sequence),
23636                        },
23637                    },
23638                    0,
23639                );
23640            }
23641        }
23642        assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX * 2);
23643        pending.sort_unstable_by_key(|event| event.sequence);
23644
23645        let mut uninterrupted = pending.clone();
23646        let first_burst = connection_event_burst_len(uninterrupted.len());
23647        uninterrupted.drain(..first_burst);
23648        let second_burst = connection_event_burst_len(uninterrupted.len());
23649        uninterrupted.drain(..second_burst);
23650        assert!(uninterrupted.is_empty());
23651
23652        let (frame_tx, mut frame_rx) =
23653            mpsc::channel::<Result<ClientFrame, FrameError>>(1);
23654        frame_tx
23655            .try_send(Ok(ClientFrame::Request(RequestEnvelope {
23656                request_id: 77,
23657                request: NodeRequest::Snapshot,
23658            })))
23659            .unwrap();
23660        let burst_len = connection_event_burst_len(pending.len());
23661        assert_eq!(burst_len, NODE_CONNECTION_EVENT_BURST_MAX);
23662        pending.drain(..burst_len);
23663        assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX);
23664        assert!(matches!(
23665            frame_rx.try_recv(),
23666            Ok(Ok(ClientFrame::Request(RequestEnvelope {
23667                request_id: 77,
23668                request: NodeRequest::Snapshot,
23669            })))
23670        ));
23671    }
23672
23673    #[test]
23674    fn terminal_lag_watermark_discards_retained_and_replayed_batches() {
23675        let mut pending = Vec::new();
23676        for sequence in [7_u64, 8] {
23677            let mut address = terminal_address(1);
23678            address.session.instance_id = AgentInstanceId(sequence);
23679            queue_connection_event(
23680                &mut pending,
23681                NodeEventEnvelope {
23682                    sequence,
23683                    event: NodeEvent::TerminalFrame {
23684                        address,
23685                        frame: terminal_frame(sequence),
23686                    },
23687                },
23688                0,
23689            );
23690        }
23691        let mut discard_through = 0;
23692        apply_connection_resync_watermark(&mut pending, &mut discard_through, 8);
23693        assert!(pending.is_empty());
23694
23695        let replayed = NodeEventEnvelope {
23696            sequence: 8,
23697            event: NodeEvent::TerminalFrame {
23698                address: terminal_address(1),
23699                frame: terminal_frame(8),
23700            },
23701        };
23702        let advanced = NodeEventEnvelope {
23703            sequence: 9,
23704            event: NodeEvent::TerminalFrame {
23705                address: terminal_address(2),
23706                frame: terminal_frame(1),
23707            },
23708        };
23709        queue_connection_event_batch(
23710            &mut pending,
23711            &[replayed, advanced.clone()],
23712            discard_through,
23713        );
23714        assert_eq!(pending, vec![advanced]);
23715    }
23716
23717    fn record(provider: &str, record_id: &str) -> ManagedSessionRecord {
23718        ManagedSessionRecord {
23719            record_id: SessionRecordId::new(record_id).unwrap(),
23720            display_name: format!("{provider} record"),
23721            provider: agent(provider),
23722            mode: SessionMode::Pty,
23723            state: ManagedSessionState::Unavailable,
23724            workspace_id: WorkspaceId::new("primary").unwrap(),
23725            canonical_root: opaque_windows_path(
23726                std::env::current_dir().unwrap().to_string_lossy().into_owned(),
23727            ),
23728            provider_session: None,
23729            active_session: None,
23730            environment_profile: None,
23731            bundle: None,
23732            context_id: None,
23733            context: None,
23734            exported_context: None,
23735            task_binding: None,
23736            created_at_unix_ms: 1,
23737            updated_at_unix_ms: 1,
23738            last_error: None,
23739        }
23740    }
23741
23742    fn retention_test_record(
23743        workspace_id: &str,
23744        record_id: &str,
23745        state: ManagedSessionState,
23746        updated_at_unix_ms: u64,
23747    ) -> ManagedSessionRecord {
23748        ManagedSessionRecord {
23749            record_id: SessionRecordId::new(record_id).unwrap(),
23750            display_name: format!("{record_id} display"),
23751            provider: agent("claude"),
23752            mode: SessionMode::Pty,
23753            state,
23754            workspace_id: WorkspaceId::new(workspace_id).unwrap(),
23755            canonical_root: opaque_windows_path(
23756                std::env::current_dir().unwrap().to_string_lossy().into_owned(),
23757            ),
23758            provider_session: None,
23759            active_session: None,
23760            environment_profile: None,
23761            bundle: None,
23762            context_id: None,
23763            context: None,
23764            exported_context: None,
23765            task_binding: None,
23766            created_at_unix_ms: updated_at_unix_ms,
23767            updated_at_unix_ms,
23768            last_error: None,
23769        }
23770    }
23771
23772    /// Two workspaces of three `Unavailable` records each, staggered by
23773    /// age, plus one never-touchable decoy per non-`Unavailable` state in
23774    /// `workspace-a` (all as old as that workspace's oldest real record --
23775    /// if the sweep's state gate ever leaked, these would be the first
23776    /// thing it wrongly retired). Ages, oldest to newest:
23777    /// `workspace-a`: a1 (500s), a2 (300s), a3 (100s).
23778    /// `workspace-b`: b1 (400s), b2 (200s), b3 (50s).
23779    fn retention_fixture_records(now: u64) -> Vec<ManagedSessionRecord> {
23780        vec![
23781            retention_test_record("workspace-a", "a1", ManagedSessionState::Unavailable, now - 500_000),
23782            retention_test_record("workspace-a", "a2", ManagedSessionState::Unavailable, now - 300_000),
23783            retention_test_record("workspace-a", "a3", ManagedSessionState::Unavailable, now - 100_000),
23784            retention_test_record("workspace-b", "b1", ManagedSessionState::Unavailable, now - 400_000),
23785            retention_test_record("workspace-b", "b2", ManagedSessionState::Unavailable, now - 200_000),
23786            retention_test_record("workspace-b", "b3", ManagedSessionState::Unavailable, now - 50_000),
23787            retention_test_record("workspace-a", "decoy-live", ManagedSessionState::Live, now - 500_000),
23788            retention_test_record(
23789                "workspace-a",
23790                "decoy-pending",
23791                ManagedSessionState::IdentityPending,
23792                now - 500_000,
23793            ),
23794            retention_test_record(
23795                "workspace-a",
23796                "decoy-dormant",
23797                ManagedSessionState::Dormant,
23798                now - 500_000,
23799            ),
23800        ]
23801    }
23802
23803    fn retention_fixture_shared() -> (NodeShared, NativeRuntime, PathBuf, u64) {
23804        let root = temporary_workspace_root("session-record-retention");
23805        std::fs::create_dir_all(&root).unwrap();
23806        let catalog = active_registry().unwrap();
23807        let (handle, runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23808        let workspace_a =
23809            WorkspaceConfig::new(WorkspaceId::new("workspace-a").unwrap(), &root).unwrap();
23810        let workspace_b =
23811            WorkspaceConfig::new(WorkspaceId::new("workspace-b").unwrap(), &root).unwrap();
23812        let mut shared = NodeShared::new(
23813            handle,
23814            "fixture-token".to_owned(),
23815            NodeId::new("node-retention-test").unwrap(),
23816            vec![workspace_a, workspace_b],
23817            vec![agent("claude")],
23818        );
23819        shared.state_path = Some(root.join("node-state-v10.json"));
23820        let now = unix_time_ms();
23821        for record in retention_fixture_records(now) {
23822            shared.insert_record(record).unwrap();
23823        }
23824        shared
23825            .persist_state_locked()
23826            .expect("fixture-only, empty-history persist against a fresh temp state path");
23827        (shared, runtime, root, now)
23828    }
23829
23830    fn retention_removed_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23831        shared
23832            .history
23833            .lock()
23834            .unwrap_or_else(|poisoned| poisoned.into_inner())
23835            .events
23836            .iter()
23837            .filter_map(|envelope| match &envelope.event {
23838                NodeEvent::SessionRecordRemoved { record_id } => {
23839                    Some(record_id.as_str().to_owned())
23840                }
23841                _ => None,
23842            })
23843            .collect()
23844    }
23845
23846    fn retention_remaining_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23847        shared
23848            .session_records
23849            .lock()
23850            .unwrap_or_else(|poisoned| poisoned.into_inner())
23851            .records
23852            .keys()
23853            .map(|record_id| record_id.as_str().to_owned())
23854            .collect()
23855    }
23856
23857    fn owned(values: &[&str]) -> std::collections::BTreeSet<String> {
23858        values.iter().map(|value| (*value).to_owned()).collect()
23859    }
23860
23861    #[tokio::test]
23862    async fn session_record_retention_keep_n_only_keeps_the_newest_per_workspace() {
23863        let (mut shared, runtime, root, _now) = retention_fixture_shared();
23864        shared.session_record_retention = SessionRecordRetentionConfig {
23865            age_ms: 0,
23866            keep_per_workspace: 1,
23867        };
23868        shared.retire_unavailable_session_records().await;
23869
23870        assert_eq!(
23871            retention_remaining_record_ids(&shared),
23872            owned(&["a3", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23873        );
23874        assert_eq!(
23875            retention_removed_record_ids(&shared),
23876            owned(&["a1", "a2", "b1", "b2"]),
23877        );
23878        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 4);
23879
23880        drop(shared);
23881        drop(runtime);
23882        std::fs::remove_dir_all(root).unwrap();
23883    }
23884
23885    /// Age-only would retire {a1, a2, b1}; keep-N-only would retire
23886    /// {a1, a2, b1, b2}. With both flags set the sweep must retire exactly
23887    /// the intersection {a1, a2, b1} -- `b2` fails the age test (it is only
23888    /// 200s old against a 250s threshold) even though it fails the keep-N
23889    /// test too, proving retirement needs BOTH, not either.
23890    #[tokio::test]
23891    async fn session_record_retention_both_set_retires_only_the_intersection() {
23892        let (mut shared, runtime, root, _now) = retention_fixture_shared();
23893        shared.session_record_retention = SessionRecordRetentionConfig {
23894            age_ms: 250_000,
23895            keep_per_workspace: 1,
23896        };
23897        shared.retire_unavailable_session_records().await;
23898
23899        assert_eq!(
23900            retention_remaining_record_ids(&shared),
23901            owned(&["a3", "b2", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23902        );
23903        assert_eq!(retention_removed_record_ids(&shared), owned(&["a1", "a2", "b1"]));
23904        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 3);
23905
23906        drop(shared);
23907        drop(runtime);
23908        std::fs::remove_dir_all(root).unwrap();
23909    }
23910
23911    #[tokio::test]
23912    async fn session_record_retention_both_disabled_is_a_no_op() {
23913        let (mut shared, runtime, root, _now) = retention_fixture_shared();
23914        shared.session_record_retention = SessionRecordRetentionConfig::default();
23915        shared.retire_unavailable_session_records().await;
23916
23917        assert_eq!(
23918            retention_remaining_record_ids(&shared),
23919            owned(&[
23920                "a1", "a2", "a3", "b1", "b2", "b3",
23921                "decoy-live", "decoy-pending", "decoy-dormant",
23922            ]),
23923        );
23924        assert!(retention_removed_record_ids(&shared).is_empty());
23925        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 0);
23926
23927        drop(shared);
23928        drop(runtime);
23929        std::fs::remove_dir_all(root).unwrap();
23930    }
23931
23932    #[test]
23933    fn session_record_context_export_selects_one_exact_history_identity() {
23934        let candidates = vec![
23935            HistoryCandidateSummary {
23936                id: "candidate-other".to_owned(),
23937                session_id_hint: "provider-other".to_owned(),
23938                modified_at_unix_ms: Some(1),
23939            },
23940            HistoryCandidateSummary {
23941                id: "candidate-exact".to_owned(),
23942                session_id_hint: "provider-exact".to_owned(),
23943                modified_at_unix_ms: Some(2),
23944            },
23945        ];
23946        assert_eq!(
23947            select_history_candidate(&candidates, Some("provider-exact")).unwrap(),
23948            "candidate-exact",
23949        );
23950    }
23951
23952    #[test]
23953    fn session_record_context_export_rejects_missing_and_ambiguous_history_identity() {
23954        let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
23955            id: id.to_owned(),
23956            session_id_hint: hint.to_owned(),
23957            modified_at_unix_ms: None,
23958        };
23959        let missing = select_history_candidate(
23960            &[candidate("candidate-other", "provider-other")],
23961            Some("provider-exact"),
23962        )
23963        .unwrap_err();
23964        assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23965        let ambiguous = select_history_candidate(
23966            &[
23967                candidate("candidate-a", "provider-exact"),
23968                candidate("candidate-b", "provider-exact"),
23969            ],
23970            Some("provider-exact"),
23971        )
23972        .unwrap_err();
23973        assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
23974    }
23975
23976    /// The PTY-sidecar-observation shape (no fleet provider today, but
23977    /// capability-keyed rather than vendor-locked) never carries a verified
23978    /// `provider_session`; its record-bound export instead falls back to
23979    /// "exactly one discovered candidate" (see
23980    /// `export_context_pack_for_session_record_inner`). This is the same
23981    /// ambiguity refusal as the identity-filtered path, just without an
23982    /// identity to filter by.
23983    #[test]
23984    fn session_record_context_export_selects_sole_history_candidate_without_identity() {
23985        let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
23986            id: id.to_owned(),
23987            session_id_hint: hint.to_owned(),
23988            modified_at_unix_ms: None,
23989        };
23990        assert_eq!(
23991            select_history_candidate(&[candidate("candidate-sole", "sidecar-session")], None)
23992                .unwrap(),
23993            "candidate-sole",
23994        );
23995        let missing = select_history_candidate(&[], None).unwrap_err();
23996        assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23997        let ambiguous = select_history_candidate(
23998            &[
23999                candidate("candidate-a", "sidecar-session-a"),
24000                candidate("candidate-b", "sidecar-session-b"),
24001            ],
24002            None,
24003        )
24004        .unwrap_err();
24005        assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
24006    }
24007
24008    #[test]
24009    fn session_record_context_export_revalidation_rejects_record_change() {
24010        let session = terminal_address(1);
24011        let identity = ProviderSessionIdentity {
24012            key: ProviderSessionKey::SessionId,
24013            id: "provider-exact".to_owned(),
24014            transcript_path: Some(r"C:\private\provider.jsonl".to_owned()),
24015        };
24016        let mut expected = record("claude", "record-context-export");
24017        expected.state = ManagedSessionState::Live;
24018        expected.provider_session = Some(identity.clone());
24019        expected.active_session = Some(session.clone());
24020        let binding = SessionBinding {
24021            workspace_id: session.workspace_id.clone(),
24022            generation: session.session.generation,
24023            runtime_policy: ProviderRuntimePolicy::raw_pty(),
24024            pending_resume: None,
24025            record_id: Some(expected.record_id.clone()),
24026            managed_worktree_lease_id: None,
24027            environment_profile: None,
24028            bundle: None,
24029            context: None,
24030            materialization_id: None,
24031        };
24032        let current_root = windows_path_text(&expected.canonical_root).to_owned();
24033        assert!(session_record_context_export_binding_is_exact(
24034            &expected,
24035            &expected,
24036            Some(&identity),
24037            &session,
24038            &binding,
24039            &expected.provider,
24040            &current_root,
24041            false,
24042        ));
24043        let mut changed = expected.clone();
24044        changed.updated_at_unix_ms += 1;
24045        assert!(!session_record_context_export_binding_is_exact(
24046            &expected,
24047            &changed,
24048            Some(&identity),
24049            &session,
24050            &binding,
24051            &expected.provider,
24052            &current_root,
24053            false,
24054        ));
24055        // A same-instant timestamp bump alone (state and active_session
24056        // otherwise unchanged) is not the clean-detachment transition
24057        // either, even with the reactive flag on: only the exact Live ->
24058        // Dormant + active_session cleared shape qualifies.
24059        assert!(!session_record_context_export_binding_is_exact(
24060            &expected,
24061            &changed,
24062            Some(&identity),
24063            &session,
24064            &binding,
24065            &expected.provider,
24066            &current_root,
24067            true,
24068        ));
24069        let mut detached = expected.clone();
24070        detached.state = ManagedSessionState::Dormant;
24071        detached.active_session = None;
24072        detached.updated_at_unix_ms += 1;
24073        assert!(!session_record_context_export_binding_is_exact(
24074            &expected,
24075            &detached,
24076            Some(&identity),
24077            &session,
24078            &binding,
24079            &expected.provider,
24080            &current_root,
24081            false,
24082        ));
24083        assert!(session_record_context_export_binding_is_exact(
24084            &expected,
24085            &detached,
24086            Some(&identity),
24087            &session,
24088            &binding,
24089            &expected.provider,
24090            &current_root,
24091            true,
24092        ));
24093    }
24094
24095    /// Mirrors `session_record_context_export_revalidation_rejects_record_change`
24096    /// for a record whose provider never carries a verified identity (the
24097    /// PTY-sidecar-observation shape -- capability-keyed, no fleet provider
24098    /// declares one today): `identity` is `None` throughout, and the
24099    /// exactness check falls back to comparing `provider_session` itself,
24100    /// which must still be `None` on both sides.
24101    #[test]
24102    fn session_record_context_export_binding_is_exact_without_identity() {
24103        let session = terminal_address(1);
24104        let mut expected = record("pty-sidecar-fixture", "record-context-export-no-identity");
24105        expected.state = ManagedSessionState::Live;
24106        expected.active_session = Some(session.clone());
24107        let binding = SessionBinding {
24108            workspace_id: session.workspace_id.clone(),
24109            generation: session.session.generation,
24110            runtime_policy: ProviderRuntimePolicy::raw_pty(),
24111            pending_resume: None,
24112            record_id: Some(expected.record_id.clone()),
24113            managed_worktree_lease_id: None,
24114            environment_profile: None,
24115            bundle: None,
24116            context: None,
24117            materialization_id: None,
24118        };
24119        let current_root = windows_path_text(&expected.canonical_root).to_owned();
24120        assert!(session_record_context_export_binding_is_exact(
24121            &expected,
24122            &expected,
24123            None,
24124            &session,
24125            &binding,
24126            &expected.provider,
24127            &current_root,
24128            false,
24129        ));
24130        // A record that gained an identity between the initial lookup and
24131        // this recheck no longer matches the `None` this shape expects to
24132        // keep seeing -- treated the same as any other mid-export drift.
24133        let mut drifted = expected.clone();
24134        drifted.provider_session = Some(ProviderSessionIdentity {
24135            key: ProviderSessionKey::SessionId,
24136            id: "unexpected-identity".to_owned(),
24137            transcript_path: None,
24138        });
24139        assert!(!session_record_context_export_binding_is_exact(
24140            &expected,
24141            &drifted,
24142            None,
24143            &session,
24144            &binding,
24145            &expected.provider,
24146            &current_root,
24147            false,
24148        ));
24149    }
24150
24151    #[test]
24152    fn session_record_context_export_preflight_requires_live_usable_source() {
24153        let mut source = record("claude", "record-context-export-preflight");
24154        source.state = ManagedSessionState::Live;
24155        assert!(session_record_context_export_source_is_usable(
24156            &source,
24157            &SessionStatus::Running,
24158            false,
24159        ));
24160        assert!(session_record_context_export_source_is_usable(
24161            &source,
24162            &SessionStatus::Exited { exit_code: Some(0) },
24163            false,
24164        ));
24165
24166        source.state = ManagedSessionState::Dormant;
24167        assert!(!session_record_context_export_source_is_usable(
24168            &source,
24169            &SessionStatus::Running,
24170            false,
24171        ));
24172        // The reactive auto-export-at-exit path (allow_clean_detachment) is
24173        // the only caller that may treat a Dormant, cleanly-exited record as
24174        // still usable — the on-demand path above never does.
24175        assert!(session_record_context_export_source_is_usable(
24176            &source,
24177            &SessionStatus::Exited { exit_code: Some(0) },
24178            true,
24179        ));
24180        assert!(!session_record_context_export_source_is_usable(
24181            &source,
24182            &SessionStatus::Exited { exit_code: Some(0) },
24183            false,
24184        ));
24185        source.state = ManagedSessionState::Live;
24186        assert!(!session_record_context_export_source_is_usable(
24187            &source,
24188            &SessionStatus::Starting,
24189            false,
24190        ));
24191    }
24192
24193    #[test]
24194    fn session_record_context_export_final_conflict_leaves_catalog_empty() {
24195        let catalog = active_registry().unwrap();
24196        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24197        let workspace = WorkspaceConfig::new(
24198            WorkspaceId::new("primary").unwrap(),
24199            std::env::current_dir().unwrap(),
24200        )
24201        .unwrap();
24202        let shared = NodeShared::new(
24203            handle,
24204            "fixture-token".to_owned(),
24205            NodeId::new("node-context-export-conflict").unwrap(),
24206            vec![workspace],
24207            vec![agent("claude")],
24208        );
24209        let history = HistorySessionRecord {
24210            session_id: "provider-exact".to_owned(),
24211            title: None,
24212            cwd: None,
24213            model: None,
24214            message_count: 1,
24215            completed_turn_count: None,
24216            total_tokens: 0,
24217            messages: vec![gate4agent_types::HistoryMessageRecord {
24218                role: gate4agent_types::HistoryMessageRole::User,
24219                text: "bounded context".to_owned(),
24220            }],
24221        };
24222        let pack = NodeContextPack::export(
24223            ContextPackLineageReceipt {
24224                source_node_id: shared.node_id.clone(),
24225                source_session: terminal_address(1),
24226                source_provider: agent("claude"),
24227            },
24228            &history,
24229        )
24230        .unwrap();
24231        let context_id = pack.receipt().id.clone();
24232        let session = terminal_address(1);
24233        let identity = ProviderSessionIdentity {
24234            key: ProviderSessionKey::SessionId,
24235            id: history.session_id.clone(),
24236            transcript_path: None,
24237        };
24238        let mut expected = record("claude", "record-context-export-final-conflict");
24239        expected.state = ManagedSessionState::Live;
24240        expected.provider_session = Some(identity.clone());
24241        expected.active_session = Some(session.clone());
24242        let mut changed = expected.clone();
24243        changed.updated_at_unix_ms += 1;
24244        shared.insert_record(changed).unwrap();
24245
24246        let error = shared
24247            .commit_context_pack_for_session_record(
24248                &expected,
24249                Some(&identity),
24250                &session,
24251                "candidate-exact",
24252                &history,
24253                pack,
24254                false,
24255            )
24256            .unwrap_err();
24257
24258        assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
24259        assert!(shared
24260            .context_catalog
24261            .read()
24262            .unwrap_or_else(|poisoned| poisoned.into_inner())
24263            .get(&context_id)
24264            .is_none());
24265    }
24266
24267    #[tokio::test]
24268    async fn session_task_binding_mints_assigns_clears_with_cas_and_persists_v9() {
24269        let root = temporary_workspace_root("session-task-binding");
24270        std::fs::create_dir_all(&root).unwrap();
24271        let state_path = root.join("node-state.json");
24272        let catalog = active_registry().unwrap();
24273        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24274        let workspace = WorkspaceConfig::new(WorkspaceId::new("primary").unwrap(), &root).unwrap();
24275        let node_id = NodeId::new("node-1").unwrap();
24276        let mut shared = NodeShared::new(
24277            handle,
24278            "fixture-token".to_owned(),
24279            node_id.clone(),
24280            vec![workspace],
24281            vec![agent("claude")],
24282        );
24283        shared.state_path = Some(state_path.clone());
24284        let record = record("claude", "sr-task");
24285        let record_id = record.record_id.clone();
24286        shared.insert_record(record).unwrap();
24287        shared.persist_state().unwrap();
24288        shared
24289            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
24290            .unwrap();
24291
24292        let minted = process_request_inner(
24293            &shared,
24294            77,
24295            ClientRole::Operator,
24296            NodeRequest::SetSessionTask {
24297                record_id: record_id.clone(),
24298                expected_revision: 0,
24299                target: SessionTaskTargetV1::New,
24300            },
24301        )
24302        .await
24303        .unwrap();
24304        let NodeResponse::SessionRecordUpdated { record: minted } = minted else {
24305            panic!("session task mutation returned another response");
24306        };
24307        let minted_binding = minted.task_binding.clone().unwrap();
24308        let minted_task_id = minted_binding.task_id.clone().unwrap();
24309        assert_eq!(minted_binding.revision, 1);
24310        assert!(minted_task_id.as_str().starts_with("task-"));
24311        assert_eq!(minted_task_id.as_str().len(), 29);
24312
24313        let event_count = shared.current_sequence();
24314        let repeated = process_request_inner(
24315            &shared,
24316            77,
24317            ClientRole::Operator,
24318            NodeRequest::SetSessionTask {
24319                record_id: record_id.clone(),
24320                expected_revision: 1,
24321                target: SessionTaskTargetV1::Existing {
24322                    task_id: minted_task_id.clone(),
24323                },
24324            },
24325        )
24326        .await
24327        .unwrap();
24328        assert_eq!(repeated, NodeResponse::SessionRecordUpdated { record: minted.clone() });
24329        assert_eq!(shared.current_sequence(), event_count);
24330
24331        let stale = process_request_inner(
24332            &shared,
24333            77,
24334            ClientRole::Operator,
24335            NodeRequest::SetSessionTask {
24336                record_id: record_id.clone(),
24337                expected_revision: 0,
24338                target: SessionTaskTargetV1::Existing {
24339                    task_id: minted_task_id,
24340                },
24341            },
24342        )
24343        .await
24344        .unwrap_err();
24345        assert_eq!(stale.code, NodeFailureCode::SessionRecordConflict);
24346
24347        let assigned_id: TaskId = "task-ffeeddccbbaa998877665544".parse().unwrap();
24348        let assigned = process_request_inner(
24349            &shared,
24350            77,
24351            ClientRole::Operator,
24352            NodeRequest::SetSessionTask {
24353                record_id: record_id.clone(),
24354                expected_revision: 1,
24355                target: SessionTaskTargetV1::Existing {
24356                    task_id: assigned_id.clone(),
24357                },
24358            },
24359        )
24360        .await
24361        .unwrap();
24362        let NodeResponse::SessionRecordUpdated { record: assigned } = assigned else {
24363            panic!("session task assignment returned another response");
24364        };
24365        assert_eq!(assigned.task_binding.as_ref().unwrap().revision, 2);
24366        assert_eq!(assigned.task_binding.as_ref().unwrap().task_id.as_ref(), Some(&assigned_id));
24367
24368        let cleared = process_request_inner(
24369            &shared,
24370            77,
24371            ClientRole::Operator,
24372            NodeRequest::SetSessionTask {
24373                record_id: record_id.clone(),
24374                expected_revision: 2,
24375                target: SessionTaskTargetV1::Clear,
24376            },
24377        )
24378        .await
24379        .unwrap();
24380        let NodeResponse::SessionRecordUpdated { record: cleared } = cleared else {
24381            panic!("session task clear returned another response");
24382        };
24383        assert_eq!(cleared.task_binding.as_ref().unwrap().revision, 3);
24384        assert!(cleared.task_binding.as_ref().unwrap().task_id.is_none());
24385        assert!(cleared.task_binding.as_ref().unwrap().changed_at_unix_ms > 0);
24386        assert_eq!(
24387            cleared.task_binding.as_ref().unwrap().changed_at_unix_ms,
24388            cleared.updated_at_unix_ms,
24389        );
24390
24391        let persisted = std::fs::read(&state_path).unwrap();
24392        assert_eq!(serde_json::from_slice::<serde_json::Value>(&persisted).unwrap()["version"], NODE_STATE_SCHEMA_V10);
24393        let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
24394        assert_eq!(loaded.records[0].task_binding, cleared.task_binding);
24395        std::fs::remove_dir_all(root).unwrap();
24396    }
24397
24398    #[test]
24399    fn session_task_capability_strips_snapshots_events_and_replies() {
24400        let mut bound = record("claude", "sr-task-projection");
24401        bound.updated_at_unix_ms = 2;
24402        bound.task_binding = Some(SessionTaskBindingV1 {
24403            revision: 1,
24404            task_id: Some("task-00112233445566778899aaff".parse().unwrap()),
24405            changed_at_unix_ms: 2,
24406        });
24407        let request = NodeRequest::SetSessionTask {
24408            record_id: bound.record_id.clone(),
24409            expected_revision: 1,
24410            target: SessionTaskTargetV1::Clear,
24411        };
24412        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
24413            capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
24414        }));
24415        assert!(!request_uses_unnegotiated_capability(
24416            &request,
24417            &baseline_capabilities().unwrap(),
24418        ));
24419        assert!(request_uses_unnegotiated_capability(&request, &[]));
24420
24421        let mut snapshot = terminal_test_shared().snapshot();
24422        snapshot.session_records.push(bound.clone());
24423        let mut reply = ResponseEnvelope {
24424            request_id: 1,
24425            result: Ok(NodeResponse::Resync {
24426                event_sequence: 1,
24427                oldest_available_sequence: 1,
24428                snapshot,
24429                events: vec![NodeEventEnvelope {
24430                    sequence: 1,
24431                    event: NodeEvent::SessionRecordUpserted {
24432                        record: bound.clone(),
24433                    },
24434                }],
24435            }),
24436        };
24437        project_response_without_session_task_binding(&mut reply);
24438        let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
24439            panic!("task projection changed the response kind");
24440        };
24441        assert!(snapshot.session_records[0].task_binding.is_none());
24442        let NodeEvent::SessionRecordUpserted { record } = &events[0].event else {
24443            panic!("task projection changed the event kind");
24444        };
24445        assert!(record.task_binding.is_none());
24446
24447        let projected = project_event_without_session_task_binding(NodeEventEnvelope {
24448            sequence: 2,
24449            event: NodeEvent::SessionRecordUpserted { record: bound.clone() },
24450        });
24451        let NodeEvent::SessionRecordUpserted { record } = projected.event else {
24452            panic!("task event projection changed the event kind");
24453        };
24454        assert!(record.task_binding.is_none());
24455
24456        let mut direct = ResponseEnvelope {
24457            request_id: 2,
24458            result: Ok(NodeResponse::SessionRecordUpdated { record: bound }),
24459        };
24460        project_response_without_session_task_binding(&mut direct);
24461        let Ok(NodeResponse::SessionRecordUpdated { record }) = direct.result else {
24462            panic!("task reply projection changed the response kind");
24463        };
24464        assert!(record.task_binding.is_none());
24465    }
24466
24467    #[test]
24468    fn windows_runtime_default_node_endpoint_is_exact_and_valid() {
24469        assert_eq!(DEFAULT_NODE_ENDPOINT, r"\\.\pipe\gate4agent-node");
24470        let workspace = WorkspaceConfig::new(
24471            WorkspaceId::new("primary").unwrap(),
24472            std::env::current_dir().unwrap(),
24473        )
24474        .unwrap();
24475        let config = NodeServerConfig::new(
24476            DEFAULT_NODE_ENDPOINT,
24477            "fixture-token",
24478            NodeId::new("node-1").unwrap(),
24479            [workspace],
24480        )
24481        .unwrap();
24482        assert_eq!(config.endpoint, DEFAULT_NODE_ENDPOINT);
24483    }
24484
24485    #[cfg(all(feature = "fixture", windows))]
24486    #[test]
24487    fn provider_bundle_argv_hold_fixture_is_parent_bounded_and_waits_after_proof() {
24488        let root = temporary_workspace_root("provider-bundle-argv-hold");
24489        let outside = temporary_workspace_root("provider-bundle-argv-hold-outside");
24490        std::fs::create_dir_all(&root).unwrap();
24491        std::fs::create_dir_all(&outside).unwrap();
24492        let proof_path = root.join("provider.proof");
24493        let release_signal = root.join("release.signal");
24494        let config = NodeServerConfig::new(
24495            format!(
24496                r"\\.\pipe\gate4agent-provider-bundle-hold-{}",
24497                std::process::id(),
24498            ),
24499            "fixture-token",
24500            NodeId::new("provider-bundle-hold-node").unwrap(),
24501            [WorkspaceConfig::new(
24502                WorkspaceId::new("primary").unwrap(),
24503                std::env::current_dir().unwrap(),
24504            )
24505            .unwrap()],
24506        )
24507        .unwrap()
24508        .with_spawn_profiles(SpawnProfileRegistry::new([
24509            crate::protocol::SpawnProfileDefaults {
24510                profile_id: crate::protocol::SpawnProfileId::new("kimi-bundle-hold").unwrap(),
24511                revision: crate::protocol::SpawnProfileRevision::new("kimi-bundle-hold-r1")
24512                    .unwrap(),
24513                provider: agent("kimi"),
24514                mode: SessionMode::Pty,
24515                terminal_size: gate4agent_types::TerminalSize {
24516                    rows: 24,
24517                    columns: 80,
24518                },
24519                prompt: None,
24520                bundle_id: None,
24521                context_id: None,
24522                environment_profile_id: None,
24523            },
24524        ])
24525        .unwrap());
24526        let server = NodeServer::new_provider_bundle_argv_hold_fixture(
24527            config,
24528            agent("kimi"),
24529            proof_path.clone(),
24530            release_signal.clone(),
24531        )
24532        .unwrap();
24533        assert_eq!(server.shared.enabled_providers, [agent("kimi")]);
24534
24535        let spec = NodeServer::provider_bundle_argv_fixture_spec(
24536            agent("kimi"),
24537            proof_path.clone(),
24538            Some(release_signal.clone()),
24539        )
24540        .unwrap();
24541        let wrapper = &spec.launch.fixed_args[spec.launch.fixed_args.len() - 3];
24542        let proof_write = wrapper.find("WriteAllLines($proofPath").unwrap();
24543        let release_wait = wrapper
24544            .find("while (-not (Test-Path -LiteralPath $releaseSignal -PathType Leaf))")
24545            .unwrap();
24546        let fixture_ready = wrapper.find("fixture-ready>").unwrap();
24547        assert!(proof_write < release_wait);
24548        assert!(release_wait < fixture_ready);
24549        assert!(wrapper.contains("[DateTime]::UtcNow.AddSeconds(45)"));
24550        assert!(wrapper.contains("Start-Sleep -Milliseconds 20"));
24551        assert_eq!(
24552            &spec.launch.fixed_args[spec.launch.fixed_args.len() - 2..],
24553            &[
24554                proof_path.to_string_lossy().into_owned(),
24555                release_signal.to_string_lossy().into_owned(),
24556            ],
24557        );
24558
24559        let error = NodeServer::provider_bundle_argv_fixture_spec(
24560            agent("kimi"),
24561            proof_path,
24562            Some(outside.join("release.signal")),
24563        )
24564        .unwrap_err();
24565        assert!(matches!(
24566            error,
24567            NodeServerError::Registry(message)
24568                if message == "provider bundle argv release path must share the proof parent"
24569        ));
24570        drop(server);
24571        std::fs::remove_dir_all(root).unwrap();
24572        std::fs::remove_dir_all(outside).unwrap();
24573    }
24574
24575    #[cfg(feature = "fixture")]
24576    #[test]
24577    fn four_provider_context_fixture_registry_is_unambiguous_and_history_bound() {
24578        let root = temporary_workspace_root("context-fixture-registry");
24579        std::fs::create_dir_all(&root).unwrap();
24580        let proof_path = root.join("context.proof");
24581        let config = NodeServerConfig::new(
24582            format!(
24583                r"\\.\pipe\gate4agent-context-fixture-registry-{}",
24584                std::process::id(),
24585            ),
24586            "fixture-token",
24587            NodeId::new("context-fixture-node").unwrap(),
24588            [WorkspaceConfig::new(
24589                WorkspaceId::new("primary").unwrap(),
24590                std::env::current_dir().unwrap(),
24591            )
24592            .unwrap()],
24593        )
24594        .unwrap();
24595        let server = NodeServer::new_context_pack_fixture(config, proof_path).unwrap();
24596        let mut enabled = server
24597            .shared
24598            .enabled_providers
24599            .iter()
24600            .map(AgentId::as_str)
24601            .collect::<Vec<_>>();
24602        enabled.sort_unstable();
24603        assert_eq!(enabled, ["claude", "codex", "grok", "kimi"]);
24604        for provider in &server.shared.enabled_providers {
24605            assert!(server
24606                .shared
24607                .provider_adapter_contracts
24608                .iter()
24609                .any(|contract| {
24610                    &contract.provider == provider
24611                        && contract.family == AdapterFamily::History
24612                }));
24613        }
24614        drop(server);
24615        std::fs::remove_dir_all(root).unwrap();
24616    }
24617
24618    #[cfg(feature = "fixture")]
24619    #[test]
24620    fn monitoring_context_fixture_combines_claude_and_codex_hooks_with_history() {
24621        let root = temporary_workspace_root("monitoring-context-fixture");
24622        std::fs::create_dir_all(&root).unwrap();
24623        let proof_path = root.join("context.proof");
24624        let standard = NodeServer::context_pack_fixture_catalog(
24625            proof_path.clone(),
24626            None,
24627            false,
24628        ).unwrap();
24629        let monitoring = NodeServer::context_pack_fixture_catalog(
24630            proof_path.clone(),
24631            None,
24632            true,
24633        ).unwrap();
24634        let claude = AgentId::new("claude").unwrap();
24635        let standard_claude = standard.get(&claude).unwrap();
24636        let monitoring_claude = monitoring.get(&claude).unwrap();
24637        // Lifecycle hooks are retired (owner ruling 2026-09-25): neither the
24638        // standard nor the monitoring fixture declares a hook adapter
24639        // anymore.
24640        assert!(monitoring_claude.capabilities.adapters.hook.is_none());
24641        assert_eq!(
24642            monitoring_claude.capabilities.adapters.history,
24643            standard_claude.capabilities.adapters.history,
24644        );
24645        assert_ne!(monitoring_claude.launch, standard_claude.launch);
24646        let codex = AgentId::new("codex").unwrap();
24647        let standard_codex = standard.get(&codex).unwrap();
24648        let monitoring_codex = monitoring.get(&codex).unwrap();
24649        assert!(monitoring_codex.capabilities.adapters.hook.is_none());
24650        assert_eq!(
24651            monitoring_codex.capabilities.adapters.history,
24652            standard_codex.capabilities.adapters.history,
24653        );
24654        assert_ne!(monitoring_codex.launch, standard_codex.launch);
24655        let codex_validation = monitoring_codex.launch.fixed_args.iter()
24656            .find(|argument| argument.contains("F7_CODEX_BUNDLE_CONTEXT_VALIDATED"))
24657            .unwrap();
24658        for required in [
24659            "$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')",
24660            "$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')",
24661            "@('role', 'text')",
24662            "g4a-private-provider-session-canary",
24663            "private-provider-login-identity",
24664            "$rawProof",
24665            "$env:GATE4AGENT_HOOK_URL",
24666            "g4a-private-provider-session-canary",
24667        ] {
24668            assert!(codex_validation.contains(required), "missing privacy validation: {required}");
24669        }
24670        for provider in ["grok", "kimi"] {
24671            let provider = AgentId::new(provider).unwrap();
24672            assert_eq!(monitoring.get(&provider), standard.get(&provider));
24673        }
24674
24675        let config = NodeServerConfig::new(
24676            format!(
24677                r"\\.\pipe\gate4agent-monitoring-context-fixture-{}",
24678                std::process::id(),
24679            ),
24680            "fixture-token",
24681            NodeId::new("monitoring-context-fixture-node").unwrap(),
24682            [WorkspaceConfig::new(
24683                WorkspaceId::new("primary").unwrap(),
24684                std::env::current_dir().unwrap(),
24685            )
24686            .unwrap()],
24687        )
24688        .unwrap();
24689        let server = NodeServer::new_monitoring_context_pack_fixture(config, proof_path).unwrap();
24690        assert!(server.shared.fixture_semantic_hook_policy);
24691        drop(server);
24692        std::fs::remove_dir_all(root).unwrap();
24693    }
24694
24695    #[cfg(feature = "fixture")]
24696    #[test]
24697    fn kimi_context_only_fixture_is_bounded_and_preserves_codex_validation() {
24698        let root = temporary_workspace_root("kimi-context-only-fixture");
24699        std::fs::create_dir_all(&root).unwrap();
24700        let proof_path = root.join("context.proof");
24701        let standard = NodeServer::context_pack_fixture_catalog(
24702            proof_path.clone(),
24703            None,
24704            false,
24705        ).unwrap();
24706        let kimi = AgentId::new("kimi").unwrap();
24707        let context_only = NodeServer::context_pack_fixture_catalog(
24708            proof_path,
24709            Some(&kimi),
24710            false,
24711        )
24712        .unwrap();
24713
24714        let codex = AgentId::new("codex").unwrap();
24715        assert_eq!(
24716            standard.get(&codex).unwrap().launch.fixed_args,
24717            context_only.get(&codex).unwrap().launch.fixed_args,
24718        );
24719        let script = context_only
24720            .get(&kimi)
24721            .unwrap()
24722            .launch
24723            .fixed_args
24724            .iter()
24725            .find(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED"))
24726            .expect("Kimi context-only validation script is missing");
24727        for required in [
24728            "$bundleArgs.Count -ne 0",
24729            "GATE4AGENT_CONTEXT_ROOT",
24730            "context-pack.json",
24731            "$entries.Count -ne 1",
24732            "g4a-context-pack-v1",
24733            "$document.PSObject.Properties['cwd']",
24734            "'claude', 'codex', 'grok', 'kimi'",
24735            "$hasUser",
24736            "$hasAssistant",
24737            "SHA256",
24738            "context-only",
24739        ] {
24740            assert!(script.contains(required), "missing validation: {required}");
24741        }
24742        for forbidden in ["CODEX_HOME", "USERPROFILE", "auth", "token", "global config"] {
24743            assert!(!script.contains(forbidden), "forbidden home/auth access: {forbidden}");
24744        }
24745        assert!(!standard
24746            .get(&kimi)
24747            .unwrap()
24748            .launch
24749            .fixed_args
24750            .iter()
24751            .any(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED")));
24752
24753        std::fs::remove_dir_all(root).unwrap();
24754    }
24755
24756    #[cfg(all(feature = "fixture", windows))]
24757    #[tokio::test]
24758    async fn kimi_context_only_fixture_spawns_catalogued_context_pack_through_spawn_spec() {
24759        let root = temporary_workspace_root("kimi-context-only-spawn");
24760        let workspace_root = root.join("workspace");
24761        std::fs::create_dir_all(&workspace_root).unwrap();
24762        let materialization_root = root.join("private-materializations");
24763        let proof_path = root.join("context.proof");
24764        let node_id = NodeId::new("node-kimi-context-only-spawn").unwrap();
24765        let workspace_id = WorkspaceId::new("primary").unwrap();
24766        let profile_id = crate::protocol::SpawnProfileId::new("target-kimi").unwrap();
24767        let endpoint = format!(
24768            r"\\.\pipe\gate4agent-kimi-context-only-spawn-{}-{}",
24769            std::process::id(),
24770            unix_time_ms(),
24771        );
24772        let spawn_profiles = SpawnProfileRegistry::new([
24773            crate::protocol::SpawnProfileDefaults {
24774                profile_id: profile_id.clone(),
24775                revision: crate::protocol::SpawnProfileRevision::new("target-kimi-r1").unwrap(),
24776                provider: agent("kimi"),
24777                mode: SessionMode::Pty,
24778                terminal_size: gate4agent_types::TerminalSize {
24779                    rows: 30,
24780                    columns: 120,
24781                },
24782                prompt: None,
24783                bundle_id: None,
24784                context_id: None,
24785                environment_profile_id: None,
24786            },
24787        ])
24788        .unwrap();
24789        let mut config = NodeServerConfig::new(
24790            &endpoint,
24791            "fixture-token",
24792            node_id.clone(),
24793            [WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap()],
24794        )
24795        .unwrap()
24796        .with_spawn_profiles(spawn_profiles)
24797        .with_session_environment_materialization(
24798            materialization_root,
24799            Arc::new(FixtureSecretResolver {
24800                deny: Arc::new(AtomicBool::new(false)),
24801            }),
24802        )
24803        .unwrap();
24804        config.fixture_raw_pty_runtime = true;
24805        let server = NodeServer::new_context_only_proof_fixture(
24806            config,
24807            agent("kimi"),
24808            proof_path.clone(),
24809        )
24810        .unwrap();
24811        let shared = Arc::clone(&server.shared);
24812        let shutdown = server.shutdown_handle();
24813
24814        let history = HistorySessionRecord {
24815            session_id: "context-only-source-history".to_owned(),
24816            title: None,
24817            cwd: None,
24818            model: None,
24819            message_count: 2,
24820            completed_turn_count: None,
24821            total_tokens: 0,
24822            messages: vec![
24823                gate4agent_types::HistoryMessageRecord {
24824                    role: gate4agent_types::HistoryMessageRole::User,
24825                    text: "continue the bounded parent run through native C2".to_owned(),
24826                },
24827                gate4agent_types::HistoryMessageRecord {
24828                    role: gate4agent_types::HistoryMessageRole::Assistant,
24829                    text: "the exact parent context is ready for the child run".to_owned(),
24830                },
24831            ],
24832        };
24833        let pack = NodeContextPack::export(
24834            ContextPackLineageReceipt {
24835                source_node_id: node_id.clone(),
24836                source_session: SessionAddress {
24837                    workspace_id: workspace_id.clone(),
24838                    session: SessionKey {
24839                        instance_id: AgentInstanceId(71),
24840                        generation: SessionGeneration(1),
24841                    },
24842                },
24843                source_provider: agent("codex"),
24844            },
24845            &history,
24846        )
24847        .unwrap();
24848        let expected_context_hash = digest(&SHA256, pack.bytes())
24849            .as_ref()
24850            .iter()
24851            .map(|byte| format!("{byte:02x}"))
24852            .collect::<String>();
24853        let context = shared
24854            .context_catalog
24855            .write()
24856            .unwrap_or_else(|poisoned| poisoned.into_inner())
24857            .insert(pack)
24858            .unwrap();
24859        let spec = SpawnSpec {
24860            target: crate::protocol::SpawnTarget {
24861                node_id,
24862                workspace_id: workspace_id.clone(),
24863                worktree_id: None,
24864            },
24865            profile_id,
24866            expected_profile_revision: crate::protocol::SpawnProfileRevision::new(
24867                "target-kimi-r1",
24868            ).unwrap(),
24869            overrides: crate::protocol::SpawnOverrides {
24870                context_id: crate::protocol::SpawnOverride::Set {
24871                    value: context.id.clone(),
24872                },
24873                ..crate::protocol::SpawnOverrides::default()
24874            },
24875            deadline_ms: crate::protocol::SpawnDeadlineMs::new(10_000).unwrap(),
24876            idempotency_key: SpawnIdempotencyKey::new("kimi-context-only-spawn").unwrap(),
24877            required_capabilities: SpawnRequiredCapabilities::default(),
24878        };
24879        let resolved = match shared.resolve_spawn_spec(&spec) {
24880            Ok(resolved) => resolved,
24881            Err(error) => panic!(
24882                "Kimi context-only resolve rejected: exact_code={:?} message={}",
24883                error.code,
24884                error.message,
24885            ),
24886        };
24887        let resolved_context = match shared.resolve_context(&resolved) {
24888            Ok(Some(resolved_context)) => resolved_context,
24889            Ok(None) => panic!("Kimi context-only resolve omitted the exact ContextPack"),
24890            Err(error) => panic!(
24891                "Kimi context catalog resolve rejected: exact_code={:?} message={}",
24892                error.code,
24893                error.message,
24894            ),
24895        };
24896        assert_eq!(resolved_context, context);
24897        let runtime_policy = match shared
24898            .admit_provider_runtime(&agent("kimi"), ProviderRuntimeRequirement::RawPty)
24899            .await
24900        {
24901            Ok(runtime_policy) => runtime_policy,
24902            Err(error) => panic!(
24903                "Kimi RawPty admission rejected: exact_code={:?} message={}",
24904                error.code,
24905                error.message,
24906            ),
24907        };
24908        assert!(runtime_policy.raw_pty_lifecycle);
24909
24910        let launch_control = shared.native_launch_profile_control.as_ref().unwrap();
24911        let generic_overlay = NativeLaunchEnvironmentOverlay::new(
24912            agent("kimi"),
24913            TransportKind::Pty,
24914            vec![EnvMutation {
24915                key: OsString::from("GATE4AGENT_UNPROFILED_GENERIC"),
24916                value: Some(OsString::from("must-remain-rejected")),
24917            }],
24918        )
24919        .unwrap();
24920        assert_eq!(
24921            launch_control
24922                .install_native_launch_environment_overlay(
24923                    AgentInstanceId(72),
24924                    generic_overlay,
24925                )
24926                .unwrap_err(),
24927            gate4agent_runtime_native::NativeLaunchProfileError::EnvironmentOverlaySelectionMissing,
24928        );
24929
24930        let diagnostic_address = SessionAddress {
24931            workspace_id: workspace_id.clone(),
24932            session: SessionKey {
24933                instance_id: AgentInstanceId(73),
24934                generation: SessionGeneration(1),
24935            },
24936        };
24937        let (diagnostic_overlay, diagnostic_materialization) = match shared
24938            .prepare_session_materialization(
24939                &diagnostic_address,
24940                &agent("kimi"),
24941                SessionMode::Pty,
24942                None,
24943                None,
24944                Some(&resolved_context),
24945                None,
24946            )
24947        {
24948            Ok(Some(prepared)) => prepared,
24949            Ok(None) => panic!("Kimi context-only materialization produced no launch overlay"),
24950            Err(error) => panic!(
24951                "Kimi context-only materialization rejected: exact_code={:?} message={}",
24952                error.code,
24953                error.message,
24954            ),
24955        };
24956        let diagnostic_overlay = diagnostic_overlay
24957            .expect("Kimi context-only materialization omitted its environment overlay");
24958        assert!(matches!(
24959            &diagnostic_overlay,
24960            PreparedNativeLaunchOverlay::Environment(_),
24961        ));
24962        match shared.install_prepared_launch_overlay(
24963            diagnostic_address.session.instance_id,
24964            diagnostic_overlay,
24965        ) {
24966            Ok(None) => {}
24967            Ok(Some(_)) => panic!("Kimi context-only overlay unexpectedly retained argv authority"),
24968            Err(error) => panic!(
24969                "Kimi context-only overlay installation rejected: exact_code={:?} message={}",
24970                error.code,
24971                error.message,
24972            ),
24973        }
24974        assert!(launch_control.clear_native_instance_launch_overlay(
24975            diagnostic_address.session.instance_id,
24976        ));
24977        drop(diagnostic_materialization);
24978        assert!(shared
24979            .materializations
24980            .lock()
24981            .unwrap_or_else(|poisoned| poisoned.into_inner())
24982            .is_empty());
24983
24984        let node_task = tokio::spawn(server.run());
24985        let ready_client = timeout(Duration::from_secs(5), async {
24986            loop {
24987                match gate4agent_node_wire::LocalNodeClient::connect(
24988                    &endpoint,
24989                    &shared.node_id,
24990                    ClientRole::Observer,
24991                    "fixture-token",
24992                )
24993                .await
24994                {
24995                    Ok(client) => break client,
24996                    Err(_) if !node_task.is_finished() => sleep(Duration::from_millis(10)).await,
24997                    Err(error) => panic!("Kimi context-only Node stopped before readiness: {error}"),
24998                }
24999            }
25000        })
25001        .await
25002        .expect("Kimi context-only Node did not become ready");
25003        assert_eq!(
25004            ready_client
25005                .hello()
25006                .snapshot
25007                .provider_runtime_statuses
25008                .iter()
25009                .find(|status| status.provider().as_str() == "kimi")
25010                .expect("Kimi runtime status is missing")
25011                .mode(),
25012            crate::protocol::ProviderRuntimeMode::RawPassthrough,
25013        );
25014        drop(ready_client);
25015
25016        let receipt = match shared.spawn_from_spec(spec).await {
25017            Ok(receipt) => receipt,
25018            Err(error) => panic!(
25019                "Kimi context-only SpawnSpec rejected: exact_code={:?} message={}",
25020                error.code,
25021                error.message,
25022            ),
25023        };
25024        assert_eq!(receipt.provider, agent("kimi"));
25025        assert_eq!(receipt.context.as_ref(), Some(&context));
25026        assert!(receipt.context_binding_is_valid());
25027        timeout(Duration::from_secs(5), async {
25028            while !proof_path.is_file() {
25029                sleep(Duration::from_millis(10)).await;
25030            }
25031        })
25032        .await
25033        .expect("Kimi context-only fixture did not write its proof");
25034
25035        let proof = std::fs::read_to_string(&proof_path).unwrap();
25036        let lines = proof.lines().collect::<Vec<_>>();
25037        assert_eq!(lines.len(), 7);
25038        assert_eq!(lines[0], "context-only");
25039        assert!(Path::new(lines[1]).is_absolute());
25040        assert_ne!(
25041            std::fs::canonicalize(Path::new(lines[1])).unwrap(),
25042            std::fs::canonicalize(&workspace_root).unwrap(),
25043        );
25044        assert_eq!(
25045            std::fs::canonicalize(Path::new(lines[2])).unwrap(),
25046            std::fs::canonicalize(&workspace_root).unwrap(),
25047        );
25048        assert_eq!(lines[3], expected_context_hash);
25049        assert_eq!(lines[4], "g4a-context-pack-v1");
25050        assert_eq!(lines[5], "codex");
25051        assert_eq!(lines[6], "2");
25052        assert!(shared.handle.snapshot().sessions.iter().any(|session| {
25053            session.instance_id == receipt.session.session.instance_id
25054                && session.agent_id.as_str() == "kimi"
25055        }));
25056
25057        shutdown.request_shutdown().await.unwrap();
25058        timeout(Duration::from_secs(5), node_task)
25059            .await
25060            .unwrap()
25061            .unwrap()
25062            .unwrap();
25063        drop(shutdown);
25064        drop(shared);
25065        std::fs::remove_dir_all(root).unwrap();
25066    }
25067
25068    #[cfg(feature = "fixture")]
25069    #[test]
25070    fn clean_exit_fixture_constructor_is_fixture_scoped_and_path_bounded() {
25071        let root = temporary_workspace_root("clean-exit-constructor");
25072        std::fs::create_dir_all(&root).unwrap();
25073        let config = construction_test_config("clean-exit-constructor")
25074            .with_state_path(root.join("node-state.json"))
25075            .unwrap();
25076        let server = NodeServer::new_clean_exit_fixture(
25077            config,
25078            root.clone(),
25079            root.join("started.marker"),
25080            root.join("release.signal"),
25081        )
25082        .unwrap();
25083        assert_eq!(
25084            server
25085                .shared
25086                .enabled_providers
25087                .iter()
25088                .map(AgentId::as_str)
25089                .collect::<Vec<_>>(),
25090            ["claude"],
25091        );
25092        drop(server);
25093        std::fs::remove_dir_all(root).unwrap();
25094    }
25095
25096    #[test]
25097    fn production_registry_provider_contract_manifest_is_exact() {
25098        let registry = active_registry().unwrap();
25099        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
25100        assert_eq!(
25101            providers
25102                .iter()
25103                .map(|contract| (contract.provider.as_str(), contract.revision.as_str()))
25104                .collect::<Vec<_>>(),
25105            vec![
25106                ("claude", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25107                ("codex", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25108                ("grok", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25109                ("kimi", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25110            ]
25111        );
25112        assert_eq!(
25113            adapters
25114                .iter()
25115                .map(|contract| (
25116                    contract.provider.as_str(),
25117                    contract.family,
25118                    contract.adapter_id.as_str(),
25119                    contract.revision.as_str(),
25120                ))
25121                .collect::<Vec<_>>(),
25122            vec![
25123                ("claude", AdapterFamily::PtySemantic, "claude-code", "gate4agent-adapter/v1"),
25124                ("claude", AdapterFamily::Pipe, "claude-code", "gate4agent-adapter/v1"),
25125                ("claude", AdapterFamily::Acp, "claude-code", "gate4agent-adapter/v1"),
25126                ("claude", AdapterFamily::OneShot, "claude", "gate4agent-inline/claude-code-2.1/v1"),
25127                ("claude", AdapterFamily::History, "claude-code", "gate4agent-adapter/v1"),
25128                ("claude", AdapterFamily::Resume, "claude-code", "gate4agent-adapter/v1"),
25129                ("claude", AdapterFamily::SessionOptions, "claude-code", "gate4agent-session-options/orca-d8629c4/v1"),
25130                ("codex", AdapterFamily::PtySemantic, "codex", "gate4agent-adapter/v1"),
25131                ("codex", AdapterFamily::Pipe, "codex", "gate4agent-adapter/v1"),
25132                ("codex", AdapterFamily::Acp, "codex", "gate4agent-adapter/v1"),
25133                ("codex", AdapterFamily::OneShot, "codex", "gate4agent-inline/codex-cli-0.144/v1"),
25134                ("codex", AdapterFamily::History, "codex", "gate4agent-adapter/v1"),
25135                ("codex", AdapterFamily::Resume, "codex", "gate4agent-adapter/v1"),
25136                ("codex", AdapterFamily::SessionOptions, "codex", "gate4agent-session-options/orca-d8629c4/v1"),
25137                ("grok", AdapterFamily::Acp, "grok", "gate4agent-adapter/v1"),
25138                ("grok", AdapterFamily::History, "grok", "gate4agent-adapter/v1"),
25139                ("grok", AdapterFamily::Resume, "grok", "gate4agent-adapter/v1"),
25140                ("kimi", AdapterFamily::PtySemantic, "kimi", "gate4agent-adapter/v1"),
25141                ("kimi", AdapterFamily::Pipe, "kimi", "gate4agent-adapter/v1"),
25142                ("kimi", AdapterFamily::Acp, "kimi", "gate4agent-adapter/v1"),
25143                ("kimi", AdapterFamily::OneShot, "kimi", "gate4agent-inline/kimi-code-0.31/v1"),
25144                ("kimi", AdapterFamily::History, "kimi", "gate4agent-adapter/v1"),
25145                ("kimi", AdapterFamily::Resume, "kimi", "gate4agent-adapter/v1"),
25146            ]
25147        );
25148    }
25149
25150    fn construction_test_config(name: &str) -> NodeServerConfig {
25151        let workspace = WorkspaceConfig::new(
25152            WorkspaceId::new("primary").unwrap(),
25153            std::env::current_dir().unwrap(),
25154        )
25155        .unwrap();
25156        NodeServerConfig::new(
25157            format!(r"\\.\pipe\gate4agent-{name}"),
25158            "fixture-token",
25159            NodeId::new(name).unwrap(),
25160            [workspace],
25161        )
25162        .unwrap()
25163    }
25164
25165    fn maximum_size_provider_manifest_registry() -> AgentRegistry {
25166        let adapter = AdapterBinding::new(
25167            gate4agent_types::AdapterId::new("a".repeat(96)).unwrap(),
25168            "r".repeat(crate::protocol::MAX_ADAPTER_CONTRACT_REVISION_BYTES),
25169            gate4agent_types::AdapterVerification::SyntheticFixture,
25170        )
25171        .unwrap();
25172        let agent_ids = ["claude", "codex", "kimi"]
25173            .into_iter()
25174            .map(|agent_id| AgentId::new(agent_id).unwrap())
25175            .collect::<Vec<_>>();
25176        let adapters = gate4agent_catalog::AdapterRegistry::new(
25177            [
25178                AdapterFamily::PtySemantic,
25179                AdapterFamily::Pipe,
25180                AdapterFamily::OneShot,
25181                AdapterFamily::Acp,
25182                AdapterFamily::Hook,
25183                AdapterFamily::ManagedHook,
25184                AdapterFamily::History,
25185                AdapterFamily::Resume,
25186                AdapterFamily::SessionOptions,
25187                AdapterFamily::CapabilityProbe,
25188            ]
25189            .into_iter()
25190            .map(|family| gate4agent_catalog::AdapterDescriptor {
25191                family,
25192                binding: adapter.clone(),
25193                agents: agent_ids.clone(),
25194            }),
25195        )
25196        .unwrap();
25197        let specs = agent_ids
25198            .iter()
25199            .map(|agent_id| {
25200                let agent_id = agent_id.as_str();
25201                let mut spec = builtin_registry().get_by_id(agent_id).unwrap().clone();
25202                spec.revision =
25203                    "r".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES);
25204                spec.capabilities.transports.pty_adapter = Some(adapter.clone());
25205                spec.capabilities.transports.pipe = Some(gate4agent_types::PipeTransportSpec {
25206                    adapter: adapter.clone(),
25207                    protocol: gate4agent_types::PipeProtocol::SemanticNdjson,
25208                    launch_override: None,
25209                    prompt_delivery: gate4agent_types::PipePromptDelivery::None,
25210                });
25211                spec.capabilities.transports.acp = Some(gate4agent_types::AcpTransportSpec {
25212                    adapter: adapter.clone(),
25213                    launch_override: None,
25214                });
25215                spec.capabilities.adapters.hook = Some(adapter.clone());
25216                spec.capabilities.adapters.managed_hook = Some(adapter.clone());
25217                spec.capabilities.adapters.one_shot = Some(adapter.clone());
25218                spec.capabilities.adapters.history = Some(adapter.clone());
25219                spec.capabilities.adapters.resume = Some(adapter.clone());
25220                spec.capabilities.adapters.session_options = Some(adapter.clone());
25221                spec.capabilities.adapters.capability_probe = Some(adapter.clone());
25222                spec
25223            })
25224            .collect::<Vec<_>>();
25225        AgentRegistry::new_with_adapters(specs, &adapters).unwrap()
25226    }
25227
25228    /// The real fleet's manifest is accepted by a real server construction.
25229    ///
25230    /// The count is asserted against `active_registry` itself rather than a
25231    /// literal, so this stays a test that construction succeeds for whatever
25232    /// the fleet is -- which is the property worth guarding -- instead of a
25233    /// second place the fleet size is written down and has to be kept in
25234    /// step with the first.
25235    #[test]
25236    fn node_server_construction_accepts_the_production_manifest() {
25237        let registry = active_registry().unwrap();
25238        let (providers, _) = provider_contract_manifest(&registry).unwrap();
25239        assert_eq!(providers.len(), registry.iter().count());
25240        assert!(!providers.is_empty());
25241        let server = NodeServer::new_with_registry(
25242            construction_test_config("manifest-production-bound"),
25243            registry,
25244        )
25245        .unwrap();
25246        drop(server);
25247    }
25248
25249    #[test]
25250    fn node_server_construction_rejects_count_valid_manifest_over_handshake_capacity() {
25251        let registry = maximum_size_provider_manifest_registry();
25252        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
25253        assert_eq!(providers.len(), 3);
25254        assert_eq!(adapters.len(), 30);
25255        assert!(adapters.len() <= crate::protocol::MAX_PROVIDER_ADAPTER_CONTRACTS);
25256        let error = match NodeServer::new_with_registry(
25257            construction_test_config("manifest-handshake-overflow"),
25258            registry,
25259        ) {
25260            Ok(_) => panic!("oversized negotiated manifest was accepted"),
25261            Err(error) => error,
25262        };
25263        assert!(matches!(
25264            error,
25265            NodeServerError::ProviderContractManifest(message)
25266                if message.contains("authentication binding length")
25267                    && message.contains("8192-byte limit")
25268        ));
25269    }
25270
25271    #[test]
25272    fn provider_contract_manifest_rejects_wire_revision_overflow_before_startup() {
25273        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
25274        spec.revision = "x".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES + 1);
25275        let registry = AgentRegistry::new([spec]).unwrap();
25276        assert!(matches!(
25277            provider_contract_manifest(&registry),
25278            Err(NodeServerError::ProviderContractManifest(message))
25279                if message.contains("exceeds the 128-byte limit")
25280        ));
25281    }
25282
25283    #[test]
25284    fn provider_contract_manifest_rejects_invalid_wire_revision_before_startup() {
25285        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
25286        spec.revision = "invalid revision".to_owned();
25287        let registry = AgentRegistry::new([spec]).unwrap();
25288        assert!(matches!(
25289            provider_contract_manifest(&registry),
25290            Err(NodeServerError::ProviderContractManifest(message))
25291                if message.contains("bounded lowercase ASCII")
25292        ));
25293    }
25294
25295    #[test]
25296    fn provider_contract_manifest_rejects_conflicting_duplicate_family_before_startup() {
25297        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
25298        let transport_binding = spec.capabilities.adapters.one_shot.clone().unwrap();
25299        let conflicting_binding = builtin_registry()
25300            .get_by_id("codex")
25301            .unwrap()
25302            .capabilities
25303            .adapters
25304            .one_shot
25305            .clone()
25306            .unwrap();
25307        let pipe = spec.capabilities.transports.pipe.as_mut().unwrap();
25308        pipe.protocol = gate4agent_types::PipeProtocol::OneShotText;
25309        pipe.adapter = transport_binding;
25310        spec.capabilities.adapters.one_shot = Some(conflicting_binding);
25311        let registry = AgentRegistry::new([spec]).unwrap();
25312        assert!(matches!(
25313            provider_contract_manifest(&registry),
25314            Err(NodeServerError::ProviderContractManifest(message))
25315                if message.contains("conflicting OneShot adapter bindings")
25316        ));
25317    }
25318
25319    #[test]
25320    fn public_node_failures_expose_only_fixed_categories() {
25321        let provider = failure(
25322            NodeFailureCode::BackendOperationFailed,
25323            r"provider bearer-secret failed at C:\private\session.jsonl",
25324        );
25325        assert_eq!(provider.message, "backend-operation-failed");
25326        assert!(!provider.message.contains("bearer-secret"));
25327        assert!(!provider.message.contains(r"C:\private"));
25328        let persistence = persistence_failure(io::Error::new(
25329            io::ErrorKind::Other,
25330            r"state write failed at C:\private\state-v1.json",
25331        ));
25332        assert_eq!(persistence.message, DURABLE_STATE_COMMIT_FAILED_ERROR);
25333        assert!(!persistence.message.contains(r"C:\private"));
25334
25335        for kind in [io::ErrorKind::PermissionDenied, io::ErrorKind::Unsupported] {
25336            let generic = durable_state_load_error(io::Error::new(kind, "host I/O failure"));
25337            assert_eq!(
25338                generic.to_string(),
25339                "node durable state failed: durable-state-load-failed",
25340            );
25341        }
25342    }
25343
25344    #[test]
25345    fn terminal_byte_sequences_are_bounded_before_dispatch() {
25346        let sequence = b"\x1b[1;5D".to_vec();
25347        assert_eq!(
25348            terminal_bytes_action(sequence.clone()).unwrap(),
25349            InputAction::TerminalBytes(sequence),
25350        );
25351        assert_eq!(
25352            terminal_bytes_action(Vec::new()).unwrap_err().code,
25353            NodeFailureCode::InvalidRequest,
25354        );
25355        assert_eq!(
25356            terminal_bytes_action(vec![0; MAX_NODE_TERMINAL_BYTES + 1])
25357                .unwrap_err()
25358                .code,
25359            NodeFailureCode::InvalidRequest,
25360        );
25361    }
25362
25363    #[test]
25364    fn node_server_rejects_a_second_owner_of_the_same_durable_state_path() {
25365        let root = temporary_workspace_root("exclusive-state-owner");
25366        std::fs::create_dir_all(&root).unwrap();
25367        let workspace = WorkspaceConfig::new(
25368            WorkspaceId::new("primary").unwrap(),
25369            &root,
25370        )
25371        .unwrap();
25372        let state_path = root.join("state-v1.json");
25373        let endpoint = format!(
25374            r"\\.\pipe\gate4agent-node-state-lock-{}-{}",
25375            std::process::id(),
25376            std::time::SystemTime::now()
25377                .duration_since(std::time::UNIX_EPOCH)
25378                .unwrap()
25379                .as_nanos(),
25380        );
25381        let config = NodeServerConfig::new(
25382            endpoint,
25383            "fixture-token",
25384            NodeId::new("node-1").unwrap(),
25385            [workspace],
25386        )
25387        .unwrap()
25388        .with_state_path(&state_path)
25389        .unwrap();
25390
25391        let first = NodeServer::new(config.clone()).unwrap();
25392        let error = match NodeServer::new(config.clone()) {
25393            Ok(_) => panic!("a second node server acquired the same durable state path"),
25394            Err(error) => error,
25395        };
25396        assert!(matches!(
25397            &error,
25398            NodeServerError::DurableState(error)
25399                if error.kind() == io::ErrorKind::WouldBlock
25400        ));
25401        assert_eq!(
25402            error.to_string(),
25403            "node durable state failed: durable-state-lock-failed",
25404        );
25405        assert!(!error.to_string().contains(&state_path.to_string_lossy().into_owned()));
25406        drop(first);
25407        let second = NodeServer::new(config).unwrap();
25408        drop(second);
25409        std::fs::remove_dir_all(root).unwrap();
25410    }
25411
25412    #[test]
25413    fn session_binding_is_workspace_scoped_and_removed_fail_closed() {
25414        let catalog = active_registry().unwrap();
25415        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25416        let workspace_id = WorkspaceId::new("primary").unwrap();
25417        let workspace = WorkspaceConfig::new(
25418            workspace_id.clone(),
25419            std::env::current_dir().unwrap(),
25420        )
25421        .unwrap();
25422        let shared = NodeShared::new(
25423            handle,
25424            "fixture-token".to_owned(),
25425            NodeId::new("node-1").unwrap(),
25426            vec![workspace.clone()],
25427            vec![agent("claude"), agent("codex"), agent("kimi")],
25428        );
25429        let instance_id = AgentInstanceId(17);
25430        let generation = SessionGeneration(3);
25431        assert!(shared.address_for(instance_id, generation).is_none());
25432        shared.bind_session(instance_id, workspace_id.clone(), generation);
25433        let address = SessionAddress {
25434            workspace_id,
25435            session: SessionKey { instance_id, generation },
25436        };
25437        assert_eq!(
25438            shared.workspace_root(&address.workspace_id).unwrap(),
25439            workspace.canonical_root,
25440        );
25441        assert_eq!(shared.address_for(instance_id, generation), Some(address.clone()));
25442        shared.remove_binding(&address);
25443        assert!(shared.address_for(instance_id, generation).is_none());
25444    }
25445
25446    #[test]
25447    fn open_provider_request_gate_is_state_aware_and_fail_closed() {
25448        let address = SessionAddress {
25449            workspace_id: WorkspaceId::new("primary").unwrap(),
25450            session: SessionKey {
25451                instance_id: AgentInstanceId(7),
25452                generation: SessionGeneration(2),
25453            },
25454        };
25455        let record_id = SessionRecordId::new("record-a").unwrap();
25456        let size = gate4agent_types::TerminalSize {
25457            rows: 24,
25458            columns: 80,
25459        };
25460        let address_requests = [
25461            NodeRequest::Resume {
25462                session: address.clone(),
25463                terminal_size: size,
25464                initial_prompt: None,
25465            },
25466            NodeRequest::Prompt { session: address.clone(), text: "p".to_owned() },
25467            NodeRequest::Paste { session: address.clone(), text: "p".to_owned() },
25468            NodeRequest::Input { session: address.clone(), text: "p".to_owned() },
25469            NodeRequest::TerminalBytes { session: address.clone(), bytes: vec![1] },
25470            NodeRequest::TerminalControl {
25471                session: address.clone(),
25472                control: TerminalControl::Enter,
25473            },
25474            NodeRequest::Resize { session: address.clone(), size },
25475            NodeRequest::Interrupt { session: address.clone() },
25476            NodeRequest::Stop { session: address.clone(), force: false },
25477            NodeRequest::Remove { session: address },
25478        ];
25479        for request in &address_requests {
25480            assert!(request_requires_open_provider_ids_with(
25481                request,
25482                |_| Some(agent("third-party-agent")),
25483                |_| Some(agent("claude")),
25484            ));
25485            assert!(!request_requires_open_provider_ids_with(
25486                request,
25487                |_| Some(agent("claude")),
25488                |_| Some(agent("claude")),
25489            ));
25490            assert!(request_requires_open_provider_ids_with(
25491                request,
25492                |_| None,
25493                |_| Some(agent("claude")),
25494            ));
25495        }
25496
25497        let record_requests = [
25498            NodeRequest::RenameSessionRecord {
25499                record_id: record_id.clone(),
25500                display_name: "renamed".to_owned(),
25501            },
25502            NodeRequest::ResumeSessionRecord {
25503                record_id: record_id.clone(),
25504                terminal_size: size,
25505                initial_prompt: None,
25506            },
25507            NodeRequest::ForgetSessionRecord { record_id },
25508        ];
25509        for request in &record_requests {
25510            assert!(request_requires_open_provider_ids_with(
25511                request,
25512                |_| Some(agent("claude")),
25513                |_| Some(agent("grok")),
25514            ));
25515            assert!(!request_requires_open_provider_ids_with(
25516                request,
25517                |_| Some(agent("claude")),
25518                |_| Some(agent("kimi")),
25519            ));
25520            assert!(request_requires_open_provider_ids_with(
25521                request,
25522                |_| Some(agent("claude")),
25523                |_| None,
25524            ));
25525        }
25526
25527        assert!(request_requires_open_provider_ids_with(
25528            &NodeRequest::Spawn {
25529                workspace_id: WorkspaceId::new("primary").unwrap(),
25530                provider: agent("third-party-agent"),
25531                mode: SessionMode::Pty,
25532                terminal_size: size,
25533                initial_prompt: None,
25534            },
25535            |_| None,
25536            |_| None,
25537        ));
25538        assert!(!request_requires_open_provider_ids_with(
25539            &NodeRequest::Snapshot,
25540            |_| None,
25541            |_| None,
25542        ));
25543        assert!(request_requires_open_provider_ids_with(
25544            &NodeRequest::ForgetContextPack {
25545                context_id: SpawnContextId::new("ctx-forget").unwrap(),
25546            },
25547            |_| None,
25548            |_| None,
25549        ));
25550    }
25551
25552    #[test]
25553    fn legacy_projection_filters_open_record_replies_and_event_history() {
25554        let catalog = active_registry().unwrap();
25555        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25556        let workspace = WorkspaceConfig::new(
25557            WorkspaceId::new("primary").unwrap(),
25558            std::env::current_dir().unwrap(),
25559        )
25560        .unwrap();
25561        let shared = NodeShared::new(
25562            handle,
25563            "fixture-token".to_owned(),
25564            NodeId::new("node-1").unwrap(),
25565            vec![workspace],
25566            vec![agent("claude"), agent("grok")],
25567        );
25568        let legacy = record("claude", "legacy-record");
25569        let open = record("grok", "open-record");
25570        let legacy_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
25571            record: legacy.clone(),
25572        });
25573        let open_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
25574            record: open.clone(),
25575        });
25576        let legacy_removed = shared.publish(NodeEvent::SessionRecordRemoved {
25577            record_id: legacy.record_id.clone(),
25578        });
25579        let open_removed = shared.publish(NodeEvent::SessionRecordRemoved {
25580            record_id: open.record_id.clone(),
25581        });
25582        assert!(project_event_legacy_provider_ids(&shared, legacy_upsert.clone()).is_some());
25583        assert!(project_event_legacy_provider_ids(&shared, open_upsert.clone()).is_none());
25584        assert!(project_event_legacy_provider_ids(&shared, legacy_removed.clone()).is_some());
25585        assert!(project_event_legacy_provider_ids(&shared, open_removed.clone()).is_none());
25586
25587        let mut record_reply = ResponseEnvelope {
25588            request_id: 1,
25589            result: Ok(NodeResponse::SessionRecordUpdated { record: open }),
25590        };
25591        project_response_legacy_provider_ids(&shared, &mut record_reply);
25592        assert!(matches!(
25593            record_reply.result,
25594            Err(NodeFailure { code: NodeFailureCode::UnsupportedCapability, .. })
25595        ));
25596
25597        let mut resync = ResponseEnvelope {
25598            request_id: 2,
25599            result: Ok(NodeResponse::Resync {
25600                event_sequence: open_removed.sequence,
25601                oldest_available_sequence: 1,
25602                snapshot: shared.snapshot(),
25603                events: vec![legacy_upsert, open_upsert, legacy_removed, open_removed],
25604            }),
25605        };
25606        project_response_legacy_provider_ids(&shared, &mut resync);
25607        let Ok(NodeResponse::Resync { snapshot, events, .. }) = resync.result else {
25608            panic!("legacy projection changed the resync response kind");
25609        };
25610        assert!(snapshot.session_records.iter().all(|item| {
25611            provider_id_is_legacy(&item.provider)
25612        }));
25613        assert_eq!(events.len(), 2);
25614        assert!(events.iter().all(|event| matches!(
25615            &event.event,
25616            NodeEvent::SessionRecordUpserted { record }
25617                if provider_id_is_legacy(&record.provider)
25618        ) || matches!(&event.event, NodeEvent::SessionRecordRemoved { .. })));
25619    }
25620
25621    #[test]
25622    fn session_binding_capacity_is_bounded_by_the_control_session_limit() {
25623        let catalog = active_registry().unwrap();
25624        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25625        let workspace_id = WorkspaceId::new("primary").unwrap();
25626        let workspace = WorkspaceConfig::new(
25627            workspace_id.clone(),
25628            std::env::current_dir().unwrap(),
25629        )
25630        .unwrap();
25631        let shared = NodeShared::new(
25632            handle,
25633            "fixture-token".to_owned(),
25634            NodeId::new("node-1").unwrap(),
25635            vec![workspace],
25636            vec![agent("claude")],
25637        );
25638        for index in 0..CONTROL_SESSIONS_MAX {
25639            shared.bind_session(
25640                AgentInstanceId((index + 1) as u64),
25641                workspace_id.clone(),
25642                SessionGeneration(1),
25643            );
25644        }
25645        let error = shared.ensure_binding_capacity().unwrap_err();
25646        assert_eq!(error.code, NodeFailureCode::BackendBusy);
25647    }
25648
25649    #[tokio::test]
25650    async fn provider_spawn_admission_rejects_before_session_mutation() {
25651        let catalog = active_registry().unwrap();
25652        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25653        let workspace = WorkspaceConfig::new(
25654            WorkspaceId::new("primary").unwrap(),
25655            std::env::current_dir().unwrap(),
25656        )
25657        .unwrap();
25658        let mut shared = NodeShared::new(
25659            handle,
25660            "fixture-token".to_owned(),
25661            NodeId::new("node-provider-admission").unwrap(),
25662            vec![workspace],
25663            vec![agent("claude")],
25664        );
25665        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25666            crate::protocol::ProviderRuntimeStatus::unavailable(agent("claude")),
25667        ])
25668        .unwrap();
25669        let terminal_size = gate4agent_types::TerminalSize {
25670            rows: 24,
25671            columns: 80,
25672        };
25673
25674        let unavailable = shared
25675            .spawn_session(
25676                WorkspaceId::new("primary").unwrap(),
25677                agent("claude"),
25678                SessionMode::Pty,
25679                terminal_size,
25680                None,
25681            )
25682            .await
25683            .unwrap_err();
25684        assert_eq!(unavailable.code, NodeFailureCode::BackendOperationFailed);
25685        assert_eq!(unavailable.message, "backend-operation-failed");
25686
25687        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25688            crate::protocol::ProviderRuntimeStatus::raw_passthrough(
25689                agent("claude"),
25690                Some(crate::protocol::ProviderRuntimeVersion::new("999.0.0").unwrap()),
25691            ),
25692        ])
25693        .unwrap();
25694        let semantic = shared
25695            .spawn_session(
25696                WorkspaceId::new("primary").unwrap(),
25697                agent("claude"),
25698                SessionMode::Inline,
25699                terminal_size,
25700                None,
25701            )
25702            .await
25703            .unwrap_err();
25704        assert_eq!(semantic.code, NodeFailureCode::UnsupportedCapability);
25705        assert_eq!(semantic.message, "unsupported-capability");
25706
25707        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
25708        assert!(shared
25709            .session_bindings
25710            .lock()
25711            .unwrap_or_else(|poisoned| poisoned.into_inner())
25712            .is_empty());
25713        assert!(shared
25714            .session_records
25715            .lock()
25716            .unwrap_or_else(|poisoned| poisoned.into_inner())
25717            .records
25718            .is_empty());
25719        assert!(shared.handle.snapshot().sessions.is_empty());
25720    }
25721
25722    #[tokio::test]
25723    async fn raw_spawn_binding_is_visible_with_a_durable_live_record() {
25724        let catalog = active_registry().unwrap();
25725        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25726        let workspace_id = WorkspaceId::new("primary").unwrap();
25727        let workspace = WorkspaceConfig::new(
25728            workspace_id.clone(),
25729            std::env::current_dir().unwrap(),
25730        )
25731        .unwrap();
25732        let shared = NodeShared::new(
25733            handle,
25734            "fixture-token".to_owned(),
25735            NodeId::new("node-raw-spawn-record").unwrap(),
25736            vec![workspace],
25737            vec![agent("claude")],
25738        );
25739        let address = SessionAddress {
25740            workspace_id,
25741            session: SessionKey {
25742                instance_id: AgentInstanceId(71),
25743                generation: SessionGeneration::default(),
25744            },
25745        };
25746
25747        let record_id = shared
25748            .bind_spawn_session(
25749                &address,
25750                agent("claude"),
25751                SessionMode::Pty,
25752                ProviderRuntimePolicy::raw_pty(),
25753                SpawnRecordPolicy::Always,
25754                None,
25755            )
25756            .unwrap()
25757            .unwrap();
25758        shared
25759            .handle
25760            .dispatch(shared.prepare_command(ControlCommand::Register {
25761                instance_id: address.session.instance_id,
25762                agent_id: agent("claude"),
25763                transport: TransportKind::Pty,
25764            }))
25765            .unwrap();
25766        runtime.tick().await;
25767
25768        assert!(shared.handle.snapshot().sessions.iter().any(|session| {
25769            session.instance_id == address.session.instance_id
25770                && session.generation == address.session.generation
25771        }));
25772        let record = shared.record(&record_id).unwrap();
25773        assert_eq!(record.display_name, "claude #1");
25774        assert_eq!(record.state, ManagedSessionState::Live);
25775        assert_eq!(record.active_session.as_ref(), Some(&address));
25776        assert!(record.provider_session.is_none());
25777        let binding_record_id = shared
25778            .session_bindings
25779            .lock()
25780            .unwrap_or_else(|poisoned| poisoned.into_inner())
25781            .get(&address.session.instance_id)
25782            .and_then(|binding| binding.record_id.clone());
25783        assert_eq!(binding_record_id.as_ref(), Some(&record_id));
25784
25785        let renamed = shared
25786            .rename_session_record(&record_id, "raw review".to_owned())
25787            .unwrap();
25788        assert_eq!(renamed.record_id, record_id);
25789        assert_eq!(renamed.display_name, "raw review");
25790        shared.reconcile_managed_record(
25791            &record_id,
25792            &address,
25793            &ControlEvent {
25794                sequence: 1,
25795                command_id: None,
25796                instance_id: address.session.instance_id,
25797                generation: address.session.generation,
25798                event: ControlEventKind::Running { process_id: None },
25799            },
25800            false,
25801            false,
25802        );
25803        let running = shared.record(&record_id).unwrap();
25804        assert_eq!(running.state, ManagedSessionState::Live);
25805        assert_eq!(running.active_session.as_ref(), Some(&address));
25806        assert_eq!(running.display_name, "raw review");
25807
25808        shared.reconcile_managed_record(
25809            &record_id,
25810            &address,
25811            &ControlEvent {
25812                sequence: 2,
25813                command_id: None,
25814                instance_id: address.session.instance_id,
25815                generation: address.session.generation,
25816                event: ControlEventKind::Exited {
25817                    exit_code: Some(0),
25818                    forced: false,
25819                },
25820            },
25821            false,
25822            false,
25823        );
25824        let detached = shared.record(&record_id).unwrap();
25825        assert_eq!(detached.state, ManagedSessionState::Unavailable);
25826        assert!(detached.active_session.is_none());
25827        assert_eq!(detached.display_name, "raw review");
25828        let renamed_detached = shared
25829            .rename_session_record(&record_id, "raw review detached".to_owned())
25830            .unwrap();
25831        assert_eq!(renamed_detached.record_id, record_id);
25832        assert_eq!(renamed_detached.state, ManagedSessionState::Unavailable);
25833        assert!(renamed_detached.active_session.is_none());
25834        assert_eq!(renamed_detached.display_name, "raw review detached");
25835        assert_eq!(shared.snapshot().session_records, vec![renamed_detached]);
25836    }
25837
25838    #[test]
25839    fn legacy_raw_spawn_binding_remains_untracked() {
25840        let catalog = active_registry().unwrap();
25841        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25842        let workspace_id = WorkspaceId::new("primary").unwrap();
25843        let workspace = WorkspaceConfig::new(
25844            workspace_id.clone(),
25845            std::env::current_dir().unwrap(),
25846        )
25847        .unwrap();
25848        let shared = NodeShared::new(
25849            handle,
25850            "fixture-token".to_owned(),
25851            NodeId::new("node-legacy-raw-spawn").unwrap(),
25852            vec![workspace],
25853            vec![agent("claude")],
25854        );
25855        let address = SessionAddress {
25856            workspace_id,
25857            session: SessionKey {
25858                instance_id: AgentInstanceId(72),
25859                generation: SessionGeneration::default(),
25860            },
25861        };
25862
25863        let record_id = shared
25864            .bind_spawn_session(
25865                &address,
25866                agent("claude"),
25867                SessionMode::Pty,
25868                ProviderRuntimePolicy::raw_pty(),
25869                SpawnRecordPolicy::ProviderIdentityOnly,
25870                None,
25871            )
25872            .unwrap();
25873
25874        assert!(record_id.is_none());
25875        let binding_record_id = shared
25876            .session_bindings
25877            .lock()
25878            .unwrap_or_else(|poisoned| poisoned.into_inner())
25879            .get(&address.session.instance_id)
25880            .and_then(|binding| binding.record_id.clone());
25881        assert!(binding_record_id.is_none());
25882        assert!(shared.snapshot().session_records.is_empty());
25883    }
25884
25885    #[tokio::test]
25886    async fn raw_runtime_policy_rejects_prompt_paste_and_resume_before_mutation() {
25887        let catalog = active_registry().unwrap();
25888        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25889        let workspace_id = WorkspaceId::new("primary").unwrap();
25890        let workspace = WorkspaceConfig::new(
25891            workspace_id.clone(),
25892            std::env::current_dir().unwrap(),
25893        )
25894        .unwrap();
25895        let mut shared = NodeShared::new(
25896            handle,
25897            "fixture-token".to_owned(),
25898            NodeId::new("node-raw-semantic-gate").unwrap(),
25899            vec![workspace],
25900            vec![agent("claude")],
25901        );
25902        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25903            crate::protocol::ProviderRuntimeStatus::raw_passthrough(agent("claude"), None),
25904        ])
25905        .unwrap();
25906        shared
25907            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25908            .unwrap();
25909        let address = SessionAddress {
25910            workspace_id,
25911            session: SessionKey {
25912                instance_id: AgentInstanceId(72),
25913                generation: SessionGeneration::default(),
25914            },
25915        };
25916        shared.bind_session_with_policy(
25917            &address,
25918            ProviderRuntimePolicy::raw_pty(),
25919            None,
25920        );
25921        shared
25922            .handle
25923            .dispatch(shared.prepare_command(ControlCommand::Register {
25924                instance_id: address.session.instance_id,
25925                agent_id: agent("claude"),
25926                transport: TransportKind::Pty,
25927            }))
25928            .unwrap();
25929        runtime.tick().await;
25930        let command_id_before = shared.next_command_id.load(Ordering::Acquire);
25931        let revision_before = shared.handle.snapshot().revision;
25932        let history_sequence_before = shared.current_sequence();
25933        let terminal_size = gate4agent_types::TerminalSize {
25934            rows: 24,
25935            columns: 80,
25936        };
25937
25938        for request in [
25939            NodeRequest::Prompt {
25940                session: address.clone(),
25941                text: "prompt".to_owned(),
25942            },
25943            NodeRequest::Paste {
25944                session: address.clone(),
25945                text: "paste".to_owned(),
25946            },
25947            // `initial_prompt: Some(_)` selects `ResumeWithPrompt`, which needs
25948            // the same semantic capabilities as `Prompt`/`Paste`. A bare
25949            // (promptless) resume is a raw PTY relaunch and IS admitted under
25950            // `raw_pty()` — see `raw_pty_admits_provider_native_resume_without_prompt_only`
25951            // in `provider_runtime.rs`; it is deliberately not exercised here.
25952            NodeRequest::Resume {
25953                session: address.clone(),
25954                terminal_size,
25955                initial_prompt: Some("resume".to_owned()),
25956            },
25957        ] {
25958            let error = process_request_inner(
25959                &shared,
25960                77,
25961                ClientRole::Operator,
25962                request,
25963            )
25964            .await
25965            .unwrap_err();
25966            assert_eq!(error.code, NodeFailureCode::UnsupportedCapability);
25967        }
25968
25969        assert_eq!(shared.next_command_id.load(Ordering::Acquire), command_id_before);
25970        assert_eq!(shared.handle.snapshot().revision, revision_before);
25971        assert_eq!(shared.current_sequence(), history_sequence_before);
25972        let bindings = shared
25973            .session_bindings
25974            .lock()
25975            .unwrap_or_else(|poisoned| poisoned.into_inner());
25976        let binding = bindings.get(&address.session.instance_id).unwrap();
25977        assert_eq!(binding.generation, address.session.generation);
25978        assert!(binding.pending_resume.is_none());
25979        assert!(binding.record_id.is_none());
25980    }
25981
25982    #[tokio::test]
25983    async fn windows_rejects_unix_bytes_workspace_path_before_filesystem_access() {
25984        let catalog = active_registry().unwrap();
25985        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25986        let primary = WorkspaceConfig::new(
25987            WorkspaceId::new("primary").unwrap(),
25988            std::env::current_dir().unwrap(),
25989        )
25990        .unwrap();
25991        let shared = NodeShared::new(
25992            handle,
25993            "fixture-token".to_owned(),
25994            NodeId::new("node-1").unwrap(),
25995            vec![primary],
25996            vec![agent("claude")],
25997        );
25998        shared
25999            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
26000            .unwrap();
26001        let before = shared.snapshot();
26002        let error = process_request_inner(
26003            &shared,
26004            77,
26005            ClientRole::Operator,
26006            NodeRequest::RegisterWorkspace {
26007                workspace_id: WorkspaceId::new("foreign").unwrap(),
26008                root: OpaqueHostPath::unix_bytes(b"/srv/repo".to_vec()).unwrap(),
26009            },
26010        )
26011        .await
26012        .unwrap_err();
26013        assert_eq!(error.code, NodeFailureCode::InvalidRequest);
26014        assert_eq!(error.message, "invalid-request");
26015        assert_eq!(shared.snapshot().workspaces, before.workspaces);
26016    }
26017
26018    #[tokio::test]
26019    async fn dynamic_workspaces_reject_duplicates_busy_and_last_then_publish_ordered_events() {
26020        let catalog = active_registry().unwrap();
26021        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26022        let primary_id = WorkspaceId::new("primary").unwrap();
26023        let primary = WorkspaceConfig::new(
26024            primary_id.clone(),
26025            std::env::current_dir().unwrap(),
26026        )
26027        .unwrap();
26028        let shared = NodeShared::new(
26029            handle,
26030            "fixture-token".to_owned(),
26031            NodeId::new("node-1").unwrap(),
26032            vec![primary.clone()],
26033            vec![agent("claude")],
26034        );
26035        let secondary_id = WorkspaceId::new("secondary").unwrap();
26036        let secondary = shared
26037            .register_workspace(
26038                secondary_id.clone(),
26039                std::env::temp_dir().to_string_lossy().into_owned(),
26040            )
26041            .await
26042            .unwrap();
26043        assert_eq!(secondary.workspace_id, secondary_id);
26044        assert!(secondary.sessions.is_empty());
26045        assert_eq!(shared.snapshot().workspaces.len(), 2);
26046
26047        let duplicate_id = shared
26048            .register_workspace(
26049                secondary_id.clone(),
26050                primary.canonical_root().to_owned(),
26051            )
26052            .await
26053            .unwrap_err();
26054        assert_eq!(duplicate_id.code, NodeFailureCode::DuplicateWorkspaceId);
26055        let duplicate_root = shared
26056            .register_workspace(
26057                WorkspaceId::new("duplicate-root").unwrap(),
26058                windows_path_text(&secondary.canonical_root).to_owned(),
26059            )
26060            .await
26061            .unwrap_err();
26062        assert_eq!(duplicate_root.code, NodeFailureCode::DuplicateWorkspaceRoot);
26063
26064        let address = SessionAddress {
26065            workspace_id: secondary_id.clone(),
26066            session: SessionKey {
26067                instance_id: AgentInstanceId(41),
26068                generation: SessionGeneration(1),
26069            },
26070        };
26071        shared.bind_session(
26072            address.session.instance_id,
26073            secondary_id.clone(),
26074            address.session.generation,
26075        );
26076        let busy = shared.unregister_workspace(&secondary_id).unwrap_err();
26077        assert_eq!(busy.code, NodeFailureCode::WorkspaceBusy);
26078        shared.remove_binding(&address);
26079        shared.unregister_workspace(&secondary_id).unwrap();
26080        assert_eq!(shared.snapshot().workspaces.len(), 1);
26081
26082        let last = shared.unregister_workspace(&primary_id).unwrap_err();
26083        assert_eq!(last.code, NodeFailureCode::LastWorkspace);
26084        let history = shared
26085            .history
26086            .lock()
26087            .unwrap_or_else(|poisoned| poisoned.into_inner());
26088        assert!(matches!(
26089            &history.events[0],
26090            NodeEventEnvelope {
26091                sequence: 1,
26092                event: NodeEvent::WorkspaceAdded { workspace },
26093            } if workspace.workspace_id == secondary_id
26094        ));
26095        assert!(matches!(
26096            &history.events[1],
26097            NodeEventEnvelope {
26098                sequence: 2,
26099                event: NodeEvent::WorkspaceRemoved { workspace_id },
26100            } if workspace_id == &secondary_id
26101        ));
26102    }
26103
26104    #[tokio::test]
26105    async fn provider_session_reference_index_is_controller_gated_and_idempotent() {
26106        let secondary_root = temporary_workspace_root("provider-session-reference-index");
26107        std::fs::create_dir_all(&secondary_root).unwrap();
26108        let catalog = active_registry().unwrap();
26109        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26110        let primary_id = WorkspaceId::new("primary").unwrap();
26111        let secondary_id = WorkspaceId::new("secondary").unwrap();
26112        let shared = NodeShared::new(
26113            handle,
26114            "fixture-token".to_owned(),
26115            NodeId::new("node-session-reference-index").unwrap(),
26116            vec![
26117                WorkspaceConfig::new(primary_id.clone(), std::env::current_dir().unwrap())
26118                    .unwrap(),
26119                WorkspaceConfig::new(secondary_id.clone(), &secondary_root).unwrap(),
26120            ],
26121            vec![agent("claude"), agent("codex")],
26122        );
26123        let identity = ProviderSessionIdentity {
26124            key: gate4agent_types::ProviderSessionKey::SessionId,
26125            id: "provider-session-42".to_owned(),
26126            transcript_path: None,
26127        };
26128        let index_request = |workspace_id: WorkspaceId,
26129                             provider: AgentId,
26130                             identity: ProviderSessionIdentity,
26131                             display_name: &str| {
26132            NodeRequest::IndexProviderSession {
26133                workspace_id,
26134                provider,
26135                identity,
26136                display_name: display_name.to_owned(),
26137            }
26138        };
26139        let request = index_request(
26140            primary_id.clone(),
26141            agent("claude"),
26142            identity.clone(),
26143            "release shepherd",
26144        );
26145        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
26146            capability.as_str() == NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY
26147        }));
26148        let observer = process_request_inner(
26149            &shared,
26150            77,
26151            ClientRole::Observer,
26152            request.clone(),
26153        )
26154        .await
26155        .unwrap_err();
26156        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26157        let uncontrolled = process_request_inner(
26158            &shared,
26159            77,
26160            ClientRole::Operator,
26161            request.clone(),
26162        )
26163        .await
26164        .unwrap_err();
26165        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
26166        shared
26167            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
26168            .unwrap();
26169
26170        let indexed = process_request_inner(
26171            &shared,
26172            77,
26173            ClientRole::Operator,
26174            request.clone(),
26175        )
26176        .await
26177        .unwrap();
26178        let NodeResponse::ProviderSessionIndexed { record } = indexed else {
26179            panic!("provider session reference index returned another response");
26180        };
26181        assert_eq!(record.state, ManagedSessionState::Dormant);
26182        assert_eq!(record.mode, SessionMode::Pty);
26183        assert_eq!(record.workspace_id, primary_id);
26184        assert_eq!(record.provider_session.as_ref(), Some(&identity));
26185        assert!(record.active_session.is_none());
26186        assert!(record.environment_profile.is_none());
26187        assert!(record.bundle.is_none());
26188        assert!(record.context.is_none());
26189
26190        let repeated = process_request_inner(
26191            &shared,
26192            77,
26193            ClientRole::Operator,
26194            index_request(
26195                primary_id.clone(),
26196                agent("claude"),
26197                identity.clone(),
26198                "ignored replacement",
26199            ),
26200        )
26201        .await
26202        .unwrap();
26203        assert!(matches!(
26204            repeated,
26205            NodeResponse::ProviderSessionIndexed { record: ref existing }
26206                if existing.record_id == record.record_id
26207                    && existing.display_name == "release shepherd"
26208        ));
26209        let conflicting_workspace = process_request_inner(
26210            &shared,
26211            77,
26212            ClientRole::Operator,
26213            index_request(
26214                secondary_id,
26215                agent("claude"),
26216                identity.clone(),
26217                "release shepherd",
26218            ),
26219        )
26220        .await
26221        .unwrap_err();
26222        assert_eq!(conflicting_workspace.code, NodeFailureCode::SessionRecordConflict);
26223        let conflicting_provider = process_request_inner(
26224            &shared,
26225            77,
26226            ClientRole::Operator,
26227            index_request(
26228                primary_id.clone(),
26229                agent("codex"),
26230                identity.clone(),
26231                "release shepherd",
26232            ),
26233        )
26234        .await
26235        .unwrap_err();
26236        assert_eq!(conflicting_provider.code, NodeFailureCode::SessionRecordConflict);
26237        let transcript_rejected = process_request_inner(
26238            &shared,
26239            77,
26240            ClientRole::Operator,
26241            index_request(
26242                primary_id,
26243                agent("claude"),
26244                ProviderSessionIdentity {
26245                    transcript_path: Some("C:/provider/transcript.jsonl".to_owned()),
26246                    ..identity
26247                },
26248                "release shepherd",
26249            ),
26250        )
26251        .await
26252        .unwrap_err();
26253        assert_eq!(transcript_rejected.code, NodeFailureCode::InvalidRequest);
26254        let upserts = shared
26255            .history
26256            .lock()
26257            .unwrap_or_else(|poisoned| poisoned.into_inner())
26258            .events
26259            .iter()
26260            .filter(|event| matches!(
26261                &event.event,
26262                NodeEvent::SessionRecordUpserted { record: published }
26263                    if published.record_id == record.record_id
26264            ))
26265            .count();
26266        assert_eq!(upserts, 1);
26267        std::fs::remove_dir_all(secondary_root).unwrap();
26268    }
26269
26270    #[tokio::test]
26271    async fn standalone_workspace_is_independent_registered_and_controller_gated() {
26272        let fixture_root = temporary_workspace_root("standalone-independent");
26273        let target = fixture_root.join("independent");
26274        std::fs::create_dir_all(&fixture_root).unwrap();
26275        let catalog = active_registry().unwrap();
26276        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26277        let selected = WorkspaceConfig::new(
26278            WorkspaceId::new("primary").unwrap(),
26279            std::env::current_dir().unwrap(),
26280        )
26281        .unwrap();
26282        let selected_root = selected.canonical_root().to_owned();
26283        let shared = NodeShared::new(
26284            handle,
26285            "fixture-token".to_owned(),
26286            NodeId::new("node-standalone").unwrap(),
26287            vec![selected],
26288            vec![agent("claude")],
26289        );
26290        let workspace_id = WorkspaceId::new("independent").unwrap();
26291        let request = NodeRequest::CreateStandaloneWorkspace {
26292            workspace_id: workspace_id.clone(),
26293            root: opaque_windows_path(target.to_string_lossy().into_owned()),
26294            initial_branch: Some("main".to_owned()),
26295        };
26296        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
26297            capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
26298        }));
26299        assert!(!request_uses_unnegotiated_capability(
26300            &request,
26301            &baseline_capabilities().unwrap(),
26302        ));
26303        assert!(request_uses_unnegotiated_capability(&request, &[]));
26304
26305        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
26306            .await
26307            .unwrap_err();
26308        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26309        assert!(!target.exists());
26310        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
26311            .await
26312            .unwrap_err();
26313        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
26314        assert!(!target.exists());
26315        shared
26316            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
26317            .unwrap();
26318
26319        let response = process_request_inner(&shared, 77, ClientRole::Operator, request)
26320            .await
26321            .unwrap();
26322        let NodeResponse::StandaloneWorkspaceCreated { workspace } = response else {
26323            panic!("standalone workspace creation returned another response");
26324        };
26325        assert_eq!(workspace.workspace_id, workspace_id);
26326        assert!(target.join(".git").is_dir());
26327        let branch = run_git_bounded(
26328            target.to_str().unwrap(),
26329            &["branch", "--show-current"],
26330            GIT_OUTPUT_MAX_BYTES,
26331        )
26332        .await
26333        .unwrap();
26334        assert!(branch.success);
26335        assert_eq!(String::from_utf8(branch.stdout).unwrap().trim(), "main");
26336        let worktrees = list_git_worktrees(target.to_str().unwrap()).await.unwrap();
26337        assert_eq!(worktrees.len(), 1);
26338        let listed_root = WorkspaceConfig::new(
26339            WorkspaceId::new("listed-independent").unwrap(),
26340            &worktrees[0].path,
26341        )
26342        .unwrap();
26343        assert!(platform::roots_equal(
26344            listed_root.canonical_root(),
26345            windows_path_text(&workspace.canonical_root),
26346        ));
26347        assert!(!platform::roots_equal(listed_root.canonical_root(), &selected_root));
26348        assert!(shared.snapshot().workspaces.iter().any(|snapshot| {
26349            snapshot.workspace_id == workspace_id
26350                && snapshot.canonical_root == workspace.canonical_root
26351        }));
26352        assert!(shared
26353            .history
26354            .lock()
26355            .unwrap_or_else(|poisoned| poisoned.into_inner())
26356            .events
26357            .iter()
26358            .any(|event| matches!(
26359                &event.event,
26360                NodeEvent::WorkspaceAdded { workspace }
26361                    if workspace.workspace_id == workspace_id
26362            )));
26363        std::fs::remove_dir_all(fixture_root).unwrap();
26364    }
26365
26366    #[tokio::test]
26367    async fn standalone_workspace_registration_failure_preserves_guarded_repository_for_recovery() {
26368        let fixture_root = temporary_workspace_root("standalone-compensation");
26369        let created_target = fixture_root.join("created-target");
26370        let existing_target = fixture_root.join("existing-target");
26371        let invalid_state_parent = fixture_root.join("state-parent-is-a-file");
26372        let invalid_state_path = invalid_state_parent.join("state.json");
26373        std::fs::create_dir_all(&existing_target).unwrap();
26374        std::fs::write(&invalid_state_parent, "not a directory").unwrap();
26375        let catalog = active_registry().unwrap();
26376        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26377        let primary = WorkspaceConfig::new(
26378            WorkspaceId::new("primary").unwrap(),
26379            std::env::current_dir().unwrap(),
26380        )
26381        .unwrap();
26382        let mut shared = NodeShared::new(
26383            handle,
26384            "fixture-token".to_owned(),
26385            NodeId::new("node-standalone-compensation").unwrap(),
26386            vec![primary],
26387            vec![agent("claude")],
26388        );
26389        shared.state_path = Some(invalid_state_path);
26390
26391        let created_recovery = shared
26392            .create_standalone_workspace(
26393                WorkspaceId::new("compensated").unwrap(),
26394                created_target.to_string_lossy().into_owned(),
26395                Some("main".to_owned()),
26396            )
26397            .await
26398            .unwrap_err();
26399        assert_eq!(
26400            created_recovery.code,
26401            NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
26402        );
26403        assert!(created_target.join(".git").is_dir());
26404        assert!(shared.snapshot().workspaces.iter().all(|workspace| {
26405            workspace.workspace_id.as_str() != "compensated"
26406        }));
26407
26408        let existing_recovery = shared
26409            .create_standalone_workspace(
26410                WorkspaceId::new("restored").unwrap(),
26411                existing_target.to_string_lossy().into_owned(),
26412                None,
26413            )
26414            .await
26415            .unwrap_err();
26416        assert_eq!(
26417            existing_recovery.code,
26418            NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
26419        );
26420        assert!(existing_target.is_dir());
26421        assert!(existing_target.join(".git").is_dir());
26422        assert!(shared.snapshot().workspaces.iter().all(|workspace| {
26423            workspace.workspace_id.as_str() != "restored"
26424        }));
26425
26426        let registered_empty = fixture_root.join("registered-empty");
26427        std::fs::create_dir(&registered_empty).unwrap();
26428        let catalog = active_registry().unwrap();
26429        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26430        let registered = WorkspaceConfig::new(
26431            WorkspaceId::new("registered-empty").unwrap(),
26432            &registered_empty,
26433        )
26434        .unwrap();
26435        let duplicate_shared = NodeShared::new(
26436            handle,
26437            "fixture-token".to_owned(),
26438            NodeId::new("node-standalone-duplicate-root").unwrap(),
26439            vec![registered],
26440            vec![agent("claude")],
26441        );
26442        let duplicate = duplicate_shared
26443            .create_standalone_workspace(
26444                WorkspaceId::new("duplicate-root").unwrap(),
26445                registered_empty.to_string_lossy().into_owned(),
26446                None,
26447            )
26448            .await
26449            .unwrap_err();
26450        assert_eq!(duplicate.code, NodeFailureCode::DuplicateWorkspaceRoot);
26451        assert!(std::fs::read_dir(&registered_empty).unwrap().next().is_none());
26452        std::fs::remove_dir_all(fixture_root).unwrap();
26453    }
26454
26455    #[test]
26456    fn managed_record_id_collision_preserves_the_existing_record() {
26457        let catalog = active_registry().unwrap();
26458        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26459        let workspace = WorkspaceConfig::new(
26460            WorkspaceId::new("primary").unwrap(),
26461            std::env::current_dir().unwrap(),
26462        )
26463        .unwrap();
26464        let shared = NodeShared::new(
26465            handle,
26466            "fixture-token".to_owned(),
26467            NodeId::new("node-1").unwrap(),
26468            vec![workspace.clone()],
26469            vec![agent("claude")],
26470        );
26471        let record_id = SessionRecordId::new("sr-collision").unwrap();
26472        let original = ManagedSessionRecord {
26473            record_id: record_id.clone(),
26474            display_name: "original".to_owned(),
26475            provider: agent("claude"),
26476            mode: SessionMode::Pty,
26477            state: ManagedSessionState::Dormant,
26478            workspace_id: workspace.workspace_id,
26479            canonical_root: opaque_windows_path(workspace.canonical_root),
26480            provider_session: None,
26481            active_session: None,
26482            environment_profile: None,
26483            bundle: None,
26484            context_id: None,
26485            context: None,
26486            exported_context: None,
26487            task_binding: None,
26488            created_at_unix_ms: 1,
26489            updated_at_unix_ms: 1,
26490            last_error: None,
26491        };
26492        shared.insert_record(original.clone()).unwrap();
26493        let mut collision = original.clone();
26494        collision.display_name = "replacement".to_owned();
26495        let error = shared.insert_record(collision).unwrap_err();
26496        assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
26497        assert_eq!(shared.record(&record_id).unwrap(), original);
26498    }
26499
26500    #[test]
26501    fn managed_record_runtime_errors_are_exposed_only_as_allowlisted_categories() {
26502        let catalog = active_registry().unwrap();
26503        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26504        let workspace = WorkspaceConfig::new(
26505            WorkspaceId::new("primary").unwrap(),
26506            std::env::current_dir().unwrap(),
26507        )
26508        .unwrap();
26509        let shared = NodeShared::new(
26510            handle,
26511            "fixture-token".to_owned(),
26512            NodeId::new("node-1").unwrap(),
26513            vec![workspace.clone()],
26514            vec![agent("claude")],
26515        );
26516        let record_id = SessionRecordId::new("sr-runtime-error").unwrap();
26517        shared
26518            .insert_record(ManagedSessionRecord {
26519                record_id: record_id.clone(),
26520                display_name: "runtime error".to_owned(),
26521                provider: agent("claude"),
26522                mode: SessionMode::Pty,
26523                state: ManagedSessionState::IdentityPending,
26524                workspace_id: workspace.workspace_id,
26525                canonical_root: opaque_windows_path(workspace.canonical_root),
26526                provider_session: None,
26527                active_session: None,
26528                environment_profile: None,
26529                bundle: None,
26530                context_id: None,
26531                context: None,
26532                exported_context: None,
26533                task_binding: None,
26534                created_at_unix_ms: 1,
26535                updated_at_unix_ms: 1,
26536                last_error: None,
26537            })
26538            .unwrap();
26539
26540        shared
26541            .mark_record_error(
26542                &record_id,
26543                r"provider failed with bearer-secret-123 at C:\private\session.jsonl",
26544            )
26545            .unwrap();
26546        let record = shared.record(&record_id).unwrap();
26547        assert_eq!(record.state, ManagedSessionState::Unavailable);
26548        assert_eq!(
26549            record.last_error.as_deref(),
26550            Some("provider-runtime-failed"),
26551        );
26552        let snapshot = shared.snapshot();
26553        assert_eq!(snapshot.session_records, vec![record]);
26554    }
26555
26556    /// Regression coverage for the "operator sees `deadline` instead of the
26557    /// kernel's own named rejection" defect: a spawn-dispatch waiter must be
26558    /// able to learn a command's asynchronous kernel rejection (here,
26559    /// specifically classified as `UnsupportedTransport`) exactly once, and
26560    /// must not still find it after that -- proving the fast path this
26561    /// mechanism exists for (`spawn_session_with_deadline`'s commit-wait
26562    /// loop) drains the reason instead of leaving it to be rediscovered (or
26563    /// missed) on a later poll.
26564    #[test]
26565    fn rejected_command_reason_is_classified_and_consumed_exactly_once() {
26566        let catalog = active_registry().unwrap();
26567        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26568        let workspace = WorkspaceConfig::new(
26569            WorkspaceId::new("primary").unwrap(),
26570            std::env::current_dir().unwrap(),
26571        )
26572        .unwrap();
26573        let shared = NodeShared::new(
26574            handle,
26575            "fixture-token".to_owned(),
26576            NodeId::new("node-1").unwrap(),
26577            vec![workspace],
26578            vec![agent("claude")],
26579        );
26580
26581        assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
26582
26583        shared.record_rejected_command(
26584            CommandId(1),
26585            "agent 'claude' does not support transport Acp".to_owned(),
26586            true,
26587        );
26588        // An unrelated command_id must not see another command's reason.
26589        assert!(shared.take_rejected_command_reason(CommandId(2)).is_none());
26590
26591        let reason = shared.take_rejected_command_reason(CommandId(1)).unwrap();
26592        assert_eq!(reason.message, "agent 'claude' does not support transport Acp");
26593        assert!(reason.unsupported_transport);
26594
26595        // Consumed: a second read for the same command_id finds nothing.
26596        assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
26597
26598        // A rejection unrelated to transport support keeps its own
26599        // classification distinct, so the reader can choose the generic
26600        // `BackendOperationFailed` code instead of misreporting
26601        // `UnsupportedTransport`.
26602        shared.record_rejected_command(
26603            CommandId(3),
26604            "backend busy".to_owned(),
26605            false,
26606        );
26607        let unclassified = shared.take_rejected_command_reason(CommandId(3)).unwrap();
26608        assert!(!unclassified.unsupported_transport);
26609    }
26610
26611    #[test]
26612    fn provider_identity_update_is_ignored_without_exact_session_policy() {
26613        let catalog = active_registry().unwrap();
26614        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26615        let workspace = WorkspaceConfig::new(
26616            WorkspaceId::new("primary").unwrap(),
26617            std::env::current_dir().unwrap(),
26618        )
26619        .unwrap();
26620        let shared = NodeShared::new(
26621            handle,
26622            "fixture-token".to_owned(),
26623            NodeId::new("node-identity-policy").unwrap(),
26624            vec![workspace.clone()],
26625            vec![agent("claude")],
26626        );
26627        let address = SessionAddress {
26628            workspace_id: workspace.workspace_id.clone(),
26629            session: SessionKey {
26630                instance_id: AgentInstanceId(90),
26631                generation: SessionGeneration(1),
26632            },
26633        };
26634        let record_id = SessionRecordId::new("sr-policy-denied").unwrap();
26635        shared
26636            .insert_record(ManagedSessionRecord {
26637                record_id: record_id.clone(),
26638                display_name: "policy denied".to_owned(),
26639                provider: agent("claude"),
26640                mode: SessionMode::Pty,
26641                state: ManagedSessionState::IdentityPending,
26642                workspace_id: workspace.workspace_id,
26643                canonical_root: opaque_windows_path(workspace.canonical_root),
26644                provider_session: None,
26645                active_session: Some(address.clone()),
26646                environment_profile: None,
26647                bundle: None,
26648                context_id: None,
26649                context: None,
26650                exported_context: None,
26651                task_binding: None,
26652                created_at_unix_ms: 1,
26653                updated_at_unix_ms: 1,
26654                last_error: None,
26655            })
26656            .unwrap();
26657        let event = ControlEvent {
26658            sequence: 1,
26659            command_id: None,
26660            instance_id: address.session.instance_id,
26661            generation: address.session.generation,
26662            event: ControlEventKind::ProviderEvent {
26663                sequence: 1,
26664                source: gate4agent_types::ProviderSource {
26665                    family: AdapterFamily::PtySemantic,
26666                    binding: AdapterBinding::new(
26667                        gate4agent_types::AdapterId::new("claude").unwrap(),
26668                        "fixture/v1",
26669                        gate4agent_types::AdapterVerification::SyntheticFixture,
26670                    )
26671                    .unwrap(),
26672                },
26673                source_sequence: 1,
26674                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26675                    identity: gate4agent_types::ProviderSessionIdentity {
26676                        key: gate4agent_types::ProviderSessionKey::SessionId,
26677                        id: "provider-session".to_owned(),
26678                        transcript_path: None,
26679                    },
26680                },
26681            },
26682        };
26683
26684        shared.reconcile_managed_record(&record_id, &address, &event, false, false);
26685
26686        let record = shared.record(&record_id).unwrap();
26687        assert!(record.provider_session.is_none());
26688        assert_eq!(record.state, ManagedSessionState::IdentityPending);
26689        assert_eq!(record.active_session.as_ref(), Some(&address));
26690    }
26691
26692    #[test]
26693    fn provider_identity_cannot_rebind_a_record_across_workspaces() {
26694        let secondary_root = temporary_workspace_root("identity-scope");
26695        std::fs::create_dir_all(&secondary_root).unwrap();
26696        let catalog = active_registry().unwrap();
26697        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26698        let primary = WorkspaceConfig::new(
26699            WorkspaceId::new("primary").unwrap(),
26700            std::env::current_dir().unwrap(),
26701        )
26702        .unwrap();
26703        let secondary = WorkspaceConfig::new(
26704            WorkspaceId::new("secondary").unwrap(),
26705            &secondary_root,
26706        )
26707        .unwrap();
26708        let shared = NodeShared::new(
26709            handle,
26710            "fixture-token".to_owned(),
26711            NodeId::new("node-1").unwrap(),
26712            vec![primary.clone(), secondary.clone()],
26713            vec![agent("claude")],
26714        );
26715        let identity = gate4agent_types::ProviderSessionIdentity {
26716            key: gate4agent_types::ProviderSessionKey::SessionId,
26717            id: "provider-session".to_owned(),
26718            transcript_path: None,
26719        };
26720        let dormant_id = SessionRecordId::new("sr-dormant").unwrap();
26721        shared
26722            .insert_record(ManagedSessionRecord {
26723                record_id: dormant_id.clone(),
26724                display_name: "dormant".to_owned(),
26725                provider: agent("claude"),
26726                mode: SessionMode::Pty,
26727                state: ManagedSessionState::Dormant,
26728                workspace_id: primary.workspace_id,
26729                canonical_root: opaque_windows_path(primary.canonical_root),
26730                provider_session: Some(identity.clone()),
26731                active_session: None,
26732                environment_profile: None,
26733                bundle: None,
26734                context_id: None,
26735                context: None,
26736                exported_context: None,
26737                task_binding: None,
26738                created_at_unix_ms: 1,
26739                updated_at_unix_ms: 1,
26740                last_error: None,
26741            })
26742            .unwrap();
26743        let address = SessionAddress {
26744            workspace_id: secondary.workspace_id.clone(),
26745            session: SessionKey {
26746                instance_id: AgentInstanceId(91),
26747                generation: SessionGeneration(1),
26748            },
26749        };
26750        let current_id = SessionRecordId::new("sr-current").unwrap();
26751        shared
26752            .insert_record(ManagedSessionRecord {
26753                record_id: current_id.clone(),
26754                display_name: "current".to_owned(),
26755                provider: agent("claude"),
26756                mode: SessionMode::Pty,
26757                state: ManagedSessionState::IdentityPending,
26758                workspace_id: secondary.workspace_id,
26759                canonical_root: opaque_windows_path(secondary.canonical_root),
26760                provider_session: None,
26761                active_session: Some(address.clone()),
26762                environment_profile: None,
26763                bundle: None,
26764                context_id: None,
26765                context: None,
26766                exported_context: None,
26767                task_binding: None,
26768                created_at_unix_ms: 2,
26769                updated_at_unix_ms: 2,
26770                last_error: None,
26771            })
26772            .unwrap();
26773        shared.bind_managed_session(
26774            &address,
26775            current_id.clone(),
26776            ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26777            None,
26778        );
26779        let event = ControlEvent {
26780            sequence: 1,
26781            command_id: None,
26782            instance_id: address.session.instance_id,
26783            generation: address.session.generation,
26784            event: ControlEventKind::ProviderEvent {
26785                sequence: 1,
26786                source: gate4agent_types::ProviderSource {
26787                    family: gate4agent_types::AdapterFamily::PtySemantic,
26788                    binding: gate4agent_types::AdapterBinding::new(
26789                        gate4agent_types::AdapterId::new("claude").unwrap(),
26790                        "fixture/v1",
26791                        gate4agent_types::AdapterVerification::SyntheticFixture,
26792                    )
26793                    .unwrap(),
26794                },
26795                source_sequence: 1,
26796                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26797                    identity,
26798                },
26799            },
26800        };
26801        shared.reconcile_managed_record(&current_id, &address, &event, true, false);
26802
26803        let binding = shared
26804            .session_bindings
26805            .lock()
26806            .unwrap_or_else(|poisoned| poisoned.into_inner())
26807            .get(&address.session.instance_id)
26808            .cloned()
26809            .unwrap();
26810        assert_eq!(binding.record_id.as_ref(), Some(&current_id));
26811        assert_eq!(shared.record(&dormant_id).unwrap().state, ManagedSessionState::Dormant);
26812        let current = shared.record(&current_id).unwrap();
26813        assert_eq!(current.state, ManagedSessionState::Unavailable);
26814        assert_eq!(current.active_session.as_ref(), Some(&address));
26815        assert_eq!(
26816            current.last_error.as_deref(),
26817            Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR),
26818        );
26819        std::fs::remove_dir_all(secondary_root).unwrap();
26820    }
26821
26822    #[tokio::test]
26823    async fn semantic_identity_with_transcript_path_reuses_record_and_survives_reload() {
26824        let root = temporary_workspace_root("semantic-provider-session");
26825        std::fs::create_dir_all(&root).unwrap();
26826        let state_path = root.join("state-v1.json");
26827        let catalog = active_registry().unwrap();
26828        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26829        let workspace = WorkspaceConfig::new(
26830            WorkspaceId::new("primary").unwrap(),
26831            &root,
26832        )
26833        .unwrap();
26834        let node_id = NodeId::new("node-semantic-identity").unwrap();
26835        let shared = NodeShared::new_with_incarnation(
26836            handle,
26837            "fixture-token".to_owned(),
26838            node_id.clone(),
26839            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
26840            vec![workspace.clone()],
26841            vec![agent("claude")],
26842            ProviderRuntimeStatuses::default(),
26843            None,
26844            Vec::new(),
26845            Vec::new(),
26846            SpawnProfileRegistry::default(),
26847            None,
26848            None,
26849            None,
26850            Some(state_path.clone()),
26851            Vec::new(),
26852            Vec::new(),
26853            Vec::new(),
26854            Vec::new(),
26855            None,
26856            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
26857        );
26858        let persisted_identity = gate4agent_types::ProviderSessionIdentity {
26859            key: gate4agent_types::ProviderSessionKey::SessionId,
26860            id: "provider-session".to_owned(),
26861            transcript_path: None,
26862        };
26863        let durable_id = SessionRecordId::new("sr-durable").unwrap();
26864        shared
26865            .insert_record(ManagedSessionRecord {
26866                record_id: durable_id.clone(),
26867                display_name: "durable".to_owned(),
26868                provider: agent("claude"),
26869                mode: SessionMode::Pty,
26870                state: ManagedSessionState::Dormant,
26871                workspace_id: workspace.workspace_id.clone(),
26872                canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26873                provider_session: Some(persisted_identity.clone()),
26874                active_session: None,
26875                environment_profile: None,
26876                bundle: None,
26877                context_id: None,
26878                context: None,
26879                exported_context: None,
26880                task_binding: None,
26881                created_at_unix_ms: 1,
26882                updated_at_unix_ms: 1,
26883                last_error: None,
26884            })
26885            .unwrap();
26886        let address = SessionAddress {
26887            workspace_id: workspace.workspace_id.clone(),
26888            session: SessionKey {
26889                instance_id: AgentInstanceId(92),
26890                generation: SessionGeneration(2),
26891            },
26892        };
26893        let pending_id = SessionRecordId::new("sr-pending").unwrap();
26894        shared
26895            .insert_record(ManagedSessionRecord {
26896                record_id: pending_id.clone(),
26897                display_name: "pending".to_owned(),
26898                provider: agent("claude"),
26899                mode: SessionMode::Pty,
26900                state: ManagedSessionState::IdentityPending,
26901                workspace_id: workspace.workspace_id.clone(),
26902                canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26903                provider_session: None,
26904                active_session: Some(address.clone()),
26905                environment_profile: None,
26906                bundle: None,
26907                context_id: None,
26908                context: None,
26909                exported_context: None,
26910                task_binding: None,
26911                created_at_unix_ms: 2,
26912                updated_at_unix_ms: 2,
26913                last_error: None,
26914            })
26915            .unwrap();
26916        shared.bind_managed_session(
26917            &address,
26918            pending_id.clone(),
26919            ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26920            None,
26921        );
26922        let observed_identity = gate4agent_types::ProviderSessionIdentity {
26923            transcript_path: Some(r"C:\private\provider-transcript.jsonl".to_owned()),
26924            ..persisted_identity
26925        };
26926        let event = ControlEvent {
26927            sequence: 1,
26928            command_id: None,
26929            instance_id: address.session.instance_id,
26930            generation: address.session.generation,
26931            event: ControlEventKind::ProviderEvent {
26932                sequence: 1,
26933                source: gate4agent_types::ProviderSource {
26934                    family: gate4agent_types::AdapterFamily::PtySemantic,
26935                    binding: gate4agent_types::AdapterBinding::new(
26936                        gate4agent_types::AdapterId::new("claude").unwrap(),
26937                        "fixture/v1",
26938                        gate4agent_types::AdapterVerification::SyntheticFixture,
26939                    )
26940                    .unwrap(),
26941                },
26942                source_sequence: 1,
26943                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26944                    identity: observed_identity.clone(),
26945                },
26946            },
26947        };
26948        shared.reconcile_managed_record(&pending_id, &address, &event, true, false);
26949
26950        let snapshot = shared.snapshot();
26951        assert_eq!(snapshot.session_records.len(), 1);
26952        let live = &snapshot.session_records[0];
26953        assert_eq!(live.record_id, durable_id);
26954        assert_eq!(live.state, ManagedSessionState::Live);
26955        assert_eq!(live.active_session.as_ref(), Some(&address));
26956        assert_eq!(live.provider_session.as_ref(), Some(&observed_identity));
26957        let binding = shared
26958            .session_bindings
26959            .lock()
26960            .unwrap_or_else(|poisoned| poisoned.into_inner())
26961            .get(&address.session.instance_id)
26962            .cloned()
26963            .unwrap();
26964        assert_eq!(binding.record_id.as_ref(), Some(&durable_id));
26965
26966        let mut duplicate = live.clone();
26967        duplicate.record_id = SessionRecordId::new("sr-duplicate").unwrap();
26968        duplicate.display_name = "duplicate".to_owned();
26969        duplicate.state = ManagedSessionState::Dormant;
26970        duplicate.active_session = None;
26971        duplicate.provider_session.as_mut().unwrap().transcript_path = None;
26972        let duplicate_error = shared.insert_record(duplicate).unwrap_err();
26973        assert_eq!(duplicate_error.code, NodeFailureCode::SessionRecordConflict);
26974        assert_eq!(shared.snapshot().session_records.len(), 1);
26975
26976        let terminal_size = gate4agent_types::TerminalSize {
26977            rows: 24,
26978            columns: 80,
26979        };
26980        let (first_busy, second_busy) = tokio::join!(
26981            shared.resume_session_record(&durable_id, terminal_size, None),
26982            shared.resume_session_record(&durable_id, terminal_size, None),
26983        );
26984        for busy in [first_busy.unwrap_err(), second_busy.unwrap_err()] {
26985            assert_eq!(busy.code, NodeFailureCode::SessionRecordBusy);
26986            assert_eq!(busy.message, "session-record-busy");
26987        }
26988
26989        let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
26990        assert_eq!(loaded.records.len(), 1);
26991        assert_eq!(loaded.records[0].record_id, durable_id);
26992        assert_eq!(loaded.records[0].state, ManagedSessionState::Dormant);
26993        assert_eq!(
26994            loaded.records[0]
26995                .provider_session
26996                .as_ref()
26997                .unwrap()
26998                .transcript_path,
26999            None,
27000        );
27001        let catalog = active_registry().unwrap();
27002        let (reloaded_handle, _reloaded_runtime) =
27003            NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27004        let reloaded = NodeShared::new_with_incarnation(
27005            reloaded_handle,
27006            "fixture-token".to_owned(),
27007            node_id,
27008            NodeIncarnationId::from_bytes([1; crate::protocol::NODE_INCARNATION_ID_BYTES]),
27009            vec![workspace],
27010            vec![agent("claude")],
27011            ProviderRuntimeStatuses::default(),
27012            None,
27013            Vec::new(),
27014            Vec::new(),
27015            SpawnProfileRegistry::default(),
27016            None,
27017            None,
27018            None,
27019            None,
27020            loaded.records,
27021            loaded.managed_worktrees,
27022            loaded.managed_worktree_tombstones,
27023            loaded.materializations,
27024            loaded.warning,
27025            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
27026        );
27027        assert_eq!(reloaded.snapshot().session_records.len(), 1);
27028        assert_eq!(reloaded.snapshot().session_records[0].record_id, durable_id);
27029        std::fs::remove_dir_all(root).unwrap();
27030    }
27031
27032    #[tokio::test]
27033    async fn workspace_reregistration_requires_the_recorded_root_and_restores_records() {
27034        let secondary_root = temporary_workspace_root("workspace-restore");
27035        let other_root = temporary_workspace_root("workspace-conflict");
27036        std::fs::create_dir_all(&secondary_root).unwrap();
27037        std::fs::create_dir_all(&other_root).unwrap();
27038        let catalog = active_registry().unwrap();
27039        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27040        let primary = WorkspaceConfig::new(
27041            WorkspaceId::new("primary").unwrap(),
27042            std::env::current_dir().unwrap(),
27043        )
27044        .unwrap();
27045        let secondary = WorkspaceConfig::new(
27046            WorkspaceId::new("secondary").unwrap(),
27047            &secondary_root,
27048        )
27049        .unwrap();
27050        let shared = NodeShared::new(
27051            handle,
27052            "fixture-token".to_owned(),
27053            NodeId::new("node-1").unwrap(),
27054            vec![primary, secondary.clone()],
27055            vec![agent("claude")],
27056        );
27057        let record_id = SessionRecordId::new("sr-workspace").unwrap();
27058        shared
27059            .insert_record(ManagedSessionRecord {
27060                record_id: record_id.clone(),
27061                display_name: "workspace record".to_owned(),
27062                provider: agent("claude"),
27063                mode: SessionMode::Pty,
27064                state: ManagedSessionState::Dormant,
27065                workspace_id: secondary.workspace_id.clone(),
27066                canonical_root: opaque_windows_path(secondary.canonical_root.clone()),
27067                provider_session: Some(gate4agent_types::ProviderSessionIdentity {
27068                    key: gate4agent_types::ProviderSessionKey::SessionId,
27069                    id: "provider-session".to_owned(),
27070                    transcript_path: None,
27071                }),
27072                active_session: None,
27073                environment_profile: None,
27074                bundle: None,
27075                context_id: None,
27076                context: None,
27077                exported_context: None,
27078                task_binding: None,
27079                created_at_unix_ms: 1,
27080                updated_at_unix_ms: 1,
27081                last_error: None,
27082            })
27083            .unwrap();
27084        shared.unregister_workspace(&secondary.workspace_id).unwrap();
27085        let unavailable = shared.record(&record_id).unwrap();
27086        assert_eq!(unavailable.state, ManagedSessionState::Unavailable);
27087        assert_eq!(windows_path_text(&unavailable.canonical_root), secondary.canonical_root);
27088
27089        let conflict = shared
27090            .register_workspace(
27091                secondary.workspace_id.clone(),
27092                other_root.to_string_lossy().into_owned(),
27093            )
27094            .await
27095            .unwrap_err();
27096        assert_eq!(conflict.code, NodeFailureCode::SessionRecordConflict);
27097        shared
27098            .register_workspace(
27099                secondary.workspace_id,
27100                secondary_root.to_string_lossy().into_owned(),
27101            )
27102            .await
27103            .unwrap();
27104        let restored = shared.record(&record_id).unwrap();
27105        assert_eq!(restored.state, ManagedSessionState::Dormant);
27106        assert!(restored.last_error.is_none());
27107        std::fs::remove_dir_all(secondary_root).unwrap();
27108        std::fs::remove_dir_all(other_root).unwrap();
27109    }
27110
27111    #[test]
27112    fn dynamic_workspace_root_is_rejected_before_filesystem_canonicalization() {
27113        let error = validate_workspace_request_root(
27114            &"x".repeat(MAX_WORKSPACE_ROOT_BYTES + 1),
27115        )
27116        .unwrap_err();
27117        assert_eq!(error.code, NodeFailureCode::InvalidWorkspaceRoot);
27118        let control = validate_workspace_request_root("C:\\repo\nchild").unwrap_err();
27119        assert_eq!(control.code, NodeFailureCode::InvalidWorkspaceRoot);
27120    }
27121
27122    #[test]
27123    fn concurrent_publish_and_resync_never_acknowledge_an_unappended_event() {
27124        let catalog = active_registry().unwrap();
27125        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27126        let workspace = WorkspaceConfig::new(
27127            WorkspaceId::new("primary").unwrap(),
27128            std::env::current_dir().unwrap(),
27129        )
27130        .unwrap();
27131        let shared = NodeShared::new(
27132            handle,
27133            "fixture-token".to_owned(),
27134            NodeId::new("node-1").unwrap(),
27135            vec![workspace],
27136            vec![agent("claude")],
27137        );
27138        let publishers = 4_u64;
27139        let events_per_publisher = 256_u64;
27140        let start = std::sync::Barrier::new(publishers as usize + 1);
27141        let finished = AtomicU64::new(0);
27142
27143        std::thread::scope(|scope| {
27144            for _ in 0..publishers {
27145                scope.spawn(|| {
27146                    start.wait();
27147                    for _ in 0..events_per_publisher {
27148                        shared.publish(NodeEvent::ControllerChanged { controller: None });
27149                    }
27150                    finished.fetch_add(1, Ordering::Release);
27151                });
27152            }
27153            start.wait();
27154            while finished.load(Ordering::Acquire) != publishers {
27155                assert_resync_history_is_linearized(shared.resync(0));
27156                std::thread::yield_now();
27157            }
27158        });
27159        let response = shared.resync(0);
27160        assert_resync_history_is_linearized(response.clone());
27161        let NodeResponse::Resync { event_sequence, events, .. } = response else {
27162            unreachable!("resync helper returned another response");
27163        };
27164        assert_eq!(event_sequence, publishers * events_per_publisher);
27165        assert_eq!(events.len() as u64, event_sequence);
27166    }
27167
27168    fn assert_resync_history_is_linearized(response: NodeResponse) {
27169        let NodeResponse::Resync { event_sequence, events, .. } = response else {
27170            unreachable!("resync helper returned another response");
27171        };
27172        assert!(events.windows(2).all(|pair| {
27173            pair[0].sequence.checked_add(1) == Some(pair[1].sequence)
27174        }));
27175        assert_eq!(events.last().map_or(0, |event| event.sequence), event_sequence);
27176    }
27177
27178    #[test]
27179    fn canonical_workspace_roots_are_safe_for_cmd_exe() {
27180        assert_eq!(
27181            platform::normalize_canonical_root(r"\\?\C:\repo\worktree".to_owned()),
27182            r"C:\repo\worktree",
27183        );
27184        assert_eq!(
27185            platform::normalize_canonical_root(r"\\?\UNC\server\share\repo".to_owned()),
27186            r"\\server\share\repo",
27187        );
27188        assert!(validate_workspace_root(
27189            &WorkspaceId::new("network").unwrap(),
27190            r"\\server\share\repo",
27191        )
27192        .is_err());
27193        let workspace = WorkspaceConfig::new(
27194            WorkspaceId::new("primary").unwrap(),
27195            std::env::current_dir().unwrap(),
27196        )
27197        .unwrap();
27198        assert!(!workspace.canonical_root.starts_with(r"\\?\"));
27199    }
27200
27201    #[test]
27202    fn resume_rebinds_only_on_the_expected_authorized_generation() {
27203        let catalog = active_registry().unwrap();
27204        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27205        let workspace_id = WorkspaceId::new("primary").unwrap();
27206        let workspace = WorkspaceConfig::new(
27207            workspace_id.clone(),
27208            std::env::current_dir().unwrap(),
27209        )
27210        .unwrap();
27211        let shared = NodeShared::new(
27212            handle,
27213            "fixture-token".to_owned(),
27214            NodeId::new("node-1").unwrap(),
27215            vec![workspace],
27216            vec![agent("claude")],
27217        );
27218        let instance_id = AgentInstanceId(17);
27219        let original = SessionAddress {
27220            workspace_id,
27221            session: SessionKey {
27222                instance_id,
27223                generation: SessionGeneration(3),
27224            },
27225        };
27226        shared.bind_session(
27227            instance_id,
27228            original.workspace_id.clone(),
27229            original.session.generation,
27230        );
27231        shared
27232            .arm_resume(
27233                &original,
27234                CommandId(41),
27235                ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
27236            )
27237            .unwrap();
27238
27239        shared.publish_control(ControlEvent {
27240            sequence: 1,
27241            command_id: None,
27242            instance_id,
27243            generation: SessionGeneration(4),
27244            event: ControlEventKind::Running { process_id: None },
27245        });
27246        assert_eq!(
27247            shared.address_for(instance_id, SessionGeneration(3)),
27248            Some(original.clone()),
27249        );
27250        assert!(shared.address_for(instance_id, SessionGeneration(4)).is_none());
27251
27252        shared.publish_control(ControlEvent {
27253            sequence: 2,
27254            command_id: Some(CommandId(42)),
27255            instance_id,
27256            generation: SessionGeneration(3),
27257            event: ControlEventKind::CommandRejected {
27258                message: "unrelated command".to_owned(),
27259            },
27260        });
27261
27262        shared.publish_control(ControlEvent {
27263            sequence: 3,
27264            command_id: None,
27265            instance_id,
27266            generation: SessionGeneration(4),
27267            event: ControlEventKind::ResumeAuthorized {
27268                session: gate4agent_types::ResumeSessionSummary {
27269                    key: gate4agent_types::ProviderSessionKey::SessionId,
27270                    id: "fixture-session".to_owned(),
27271                },
27272            },
27273        });
27274        assert!(shared.address_for(instance_id, SessionGeneration(3)).is_none());
27275        let rebound = shared.address_for(instance_id, SessionGeneration(4)).unwrap();
27276        assert_eq!(rebound.workspace_id, original.workspace_id);
27277        let history = shared
27278            .history
27279            .lock()
27280            .unwrap_or_else(|poisoned| poisoned.into_inner());
27281        assert!(history.events.iter().any(|envelope| matches!(
27282            &envelope.event,
27283            NodeEvent::Control { address, event }
27284                if address == &rebound
27285                    && matches!(&event.event, ControlEventKind::ResumeAuthorized { .. })
27286        )));
27287    }
27288
27289    #[test]
27290    fn all_node_pty_prompts_use_bracketed_paste_framing() {
27291        for agent in ["claude", "codex", "kimi"] {
27292            assert_eq!(
27293                prompt_framing(&AgentId::new(agent).unwrap()),
27294                PromptFraming::BracketedPaste
27295            );
27296        }
27297    }
27298
27299    fn generous_walk_deadline() -> Instant {
27300        Instant::now() + Duration::from_secs(30)
27301    }
27302
27303    #[test]
27304    fn workspace_tree_is_relative_and_skips_heavy_directories() {
27305        let root = temporary_workspace_root("tree");
27306        std::fs::create_dir_all(root.join("src")).unwrap();
27307        std::fs::create_dir_all(root.join(".git/objects")).unwrap();
27308        std::fs::create_dir_all(root.join("target/debug")).unwrap();
27309        std::fs::create_dir_all(root.join("node_modules/package")).unwrap();
27310        std::fs::write(root.join("src/lib.rs"), b"pub fn fixture() {}\n").unwrap();
27311        std::fs::write(root.join("README.md"), b"fixture\n").unwrap();
27312        // A renamed/agent-scoped build directory (`target-a2`-style, per the
27313        // real repository's own build layout) that carries the Cache
27314        // Directory Tagging Specification marker cargo writes into every
27315        // `--target-dir` it creates: must be skipped even though its name
27316        // does not exactly match `target`.
27317        std::fs::create_dir_all(root.join("target-a2/debug")).unwrap();
27318        std::fs::write(root.join("target-a2/CACHEDIR.TAG"), b"Signature: 8a477f597d28d172789f06886806bc55\n").unwrap();
27319        // A directory that merely *starts with* `target` but carries no
27320        // CACHEDIR.TAG: the new prefix-adjacent check must not false-match
27321        // on the name alone, so this stays in the response.
27322        std::fs::create_dir_all(root.join("target-notes")).unwrap();
27323        std::fs::write(root.join("target-notes/plan.md"), b"not a build directory\n").unwrap();
27324
27325        let (entries, truncated, _budget) =
27326            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
27327        assert!(!truncated);
27328        assert!(entries.iter().any(|entry| {
27329            entry.relative_path.as_utf8() == Some("src")
27330                && entry.kind == WorkspaceEntryKind::Directory
27331        }));
27332        assert!(entries.iter().any(|entry| {
27333            entry.relative_path.as_utf8() == Some("src/lib.rs")
27334                && entry.kind == WorkspaceEntryKind::File
27335        }));
27336        assert!(entries.iter().any(|entry| {
27337            entry.relative_path.as_utf8() == Some("target-notes")
27338                && entry.kind == WorkspaceEntryKind::Directory
27339        }));
27340        assert!(entries.iter().any(|entry| {
27341            entry.relative_path.as_utf8() == Some("target-notes/plan.md")
27342                && entry.kind == WorkspaceEntryKind::File
27343        }));
27344        assert!(entries.iter().all(|entry| {
27345            let path = entry.relative_path.as_utf8().unwrap();
27346            !path.starts_with(".git")
27347                && !(path == "target" || path.starts_with("target/"))
27348                && !(path == "target-a2" || path.starts_with("target-a2/"))
27349                && !path.starts_with("node_modules")
27350        }));
27351
27352        std::fs::remove_dir_all(&root).unwrap();
27353    }
27354
27355    #[test]
27356    fn workspace_walk_stops_at_its_entry_cap_and_marks_a_partial_result() {
27357        let root = temporary_workspace_root("tree-entry-cap");
27358        std::fs::create_dir_all(&root).unwrap();
27359        for index in 0..20 {
27360            std::fs::write(root.join(format!("file-{index:02}.txt")), b"fixture\n").unwrap();
27361        }
27362
27363        let (entries, truncated, budget) =
27364            collect_workspace_entries(&root, generous_walk_deadline(), 5);
27365        assert!(truncated);
27366        assert!(budget.entry_cap_exceeded);
27367        assert!(!budget.time_budget_exceeded);
27368        assert!(entries.len() <= 5);
27369
27370        let inspection_truncation = workspace_inspection_truncation(&budget, false, Duration::from_millis(1))
27371            .expect("an entry-cap-truncated walk must report a truncation marker");
27372        assert!(inspection_truncation.walk_entry_cap_exceeded);
27373        assert!(!inspection_truncation.walk_time_budget_exceeded);
27374        assert!(!inspection_truncation.git_time_budget_exceeded);
27375        assert!(inspection_truncation.entries_visited >= 5);
27376
27377        std::fs::remove_dir_all(&root).unwrap();
27378    }
27379
27380    #[test]
27381    fn workspace_walk_within_budget_reports_no_truncation() {
27382        let root = temporary_workspace_root("tree-no-truncation");
27383        std::fs::create_dir_all(&root).unwrap();
27384        std::fs::write(root.join("only-file.txt"), b"fixture\n").unwrap();
27385
27386        let (_entries, truncated, budget) =
27387            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
27388        assert!(!truncated);
27389        assert!(workspace_inspection_truncation(&budget, false, Duration::from_millis(1)).is_none());
27390
27391        std::fs::remove_dir_all(&root).unwrap();
27392    }
27393
27394    #[test]
27395    fn workspace_walk_emits_strict_global_byte_order_for_prefix_named_siblings() {
27396        let root = temporary_workspace_root("tree-prefix-siblings");
27397        std::fs::create_dir_all(root.join("gate4agent-c2/src")).unwrap();
27398        std::fs::write(root.join("gate4agent-c2/src/lib.rs"), b"fixture\n").unwrap();
27399        std::fs::create_dir_all(root.join("gate4agent-c2-client")).unwrap();
27400        std::fs::write(root.join("gate4agent-c2-client/lib.rs"), b"fixture\n").unwrap();
27401
27402        let (entries, truncated, _budget) =
27403            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
27404        assert!(!truncated);
27405        let paths = entries
27406            .iter()
27407            .map(|entry| entry.relative_path.as_utf8().unwrap().to_owned())
27408            .collect::<Vec<_>>();
27409        assert!(paths.windows(2).all(|pair| pair[0] < pair[1]));
27410        assert_eq!(
27411            paths,
27412            vec![
27413                "gate4agent-c2".to_owned(),
27414                "gate4agent-c2-client".to_owned(),
27415                "gate4agent-c2-client/lib.rs".to_owned(),
27416                "gate4agent-c2/src".to_owned(),
27417                "gate4agent-c2/src/lib.rs".to_owned(),
27418            ],
27419        );
27420
27421        std::fs::remove_dir_all(&root).unwrap();
27422    }
27423
27424    #[test]
27425    fn git_parsers_cap_status_and_commit_results() {
27426        let mut status_output = Vec::new();
27427        for index in 0..=GIT_STATUS_MAX_ENTRIES {
27428            status_output.extend_from_slice(format!(" M src/file-{index}.rs\0").as_bytes());
27429        }
27430        let mut snapshot = git_snapshot_for_parser();
27431        parse_git_status(&status_output, &mut snapshot);
27432        assert_eq!(snapshot.status.len(), GIT_STATUS_MAX_ENTRIES);
27433        assert!(snapshot.truncated);
27434        assert_eq!(snapshot.status[0].index_status, " ");
27435        assert_eq!(snapshot.status[0].worktree_status, "M");
27436
27437        snapshot.truncated = false;
27438        let commit_output = (0..=GIT_COMMIT_MAX_ENTRIES)
27439            .map(|index| format!("abc{index}\u{1f}commit {index}\n"))
27440            .collect::<String>();
27441        parse_git_commits(commit_output.as_bytes(), &mut snapshot);
27442        assert_eq!(snapshot.recent_commits.len(), GIT_COMMIT_MAX_ENTRIES);
27443        assert!(snapshot.truncated);
27444    }
27445
27446    #[test]
27447    fn porcelain_v1_z_parser_preserves_current_then_previous_rename_and_copy_order() {
27448        let mut snapshot = git_snapshot_for_parser();
27449        parse_git_status(
27450            b"R  src/current name.rs\0src/previous name.rs\0 C src/copy.rs\0src/original.rs\0?? src/line\nname.rs\0",
27451            &mut snapshot,
27452        );
27453
27454        assert!(!snapshot.truncated);
27455        assert_eq!(snapshot.status.len(), 3);
27456        assert_eq!(snapshot.status[0].index_status, "R");
27457        assert_eq!(snapshot.status[0].worktree_status, " ");
27458        assert_eq!(snapshot.status[0].path.as_utf8(), Some("src/current name.rs"));
27459        assert_eq!(
27460            snapshot.status[0]
27461                .previous_path
27462                .as_ref()
27463                .and_then(RepositoryPath::as_utf8),
27464            Some("src/previous name.rs"),
27465        );
27466        assert_eq!(snapshot.status[1].index_status, " ");
27467        assert_eq!(snapshot.status[1].worktree_status, "C");
27468        assert_eq!(snapshot.status[1].path.as_utf8(), Some("src/copy.rs"));
27469        assert_eq!(
27470            snapshot.status[1]
27471                .previous_path
27472                .as_ref()
27473                .and_then(RepositoryPath::as_utf8),
27474            Some("src/original.rs"),
27475        );
27476        assert_eq!(snapshot.status[2].path.as_utf8(), Some("src/line\nname.rs"));
27477        assert!(snapshot.status[2].previous_path.is_none());
27478    }
27479
27480    #[test]
27481    fn porcelain_v1_z_parser_never_invents_invalid_or_incomplete_path_identity() {
27482        let mut snapshot = git_snapshot_for_parser();
27483        parse_git_status(
27484            b"?? valid.rs\0?? invalid-\xff.rs\0?? invalid\\windows.rs\0R  current.rs\0",
27485            &mut snapshot,
27486        );
27487
27488        assert!(snapshot.truncated);
27489        assert_eq!(snapshot.status.len(), 1);
27490        assert_eq!(snapshot.status[0].path.as_utf8(), Some("valid.rs"));
27491        assert!(snapshot.status[0].previous_path.is_none());
27492    }
27493
27494    #[tokio::test]
27495    async fn observer_can_browse_host_directories_without_controller() {
27496        let root = temporary_workspace_root("host-directory-observer");
27497        std::fs::create_dir_all(root.join("directory-b")).unwrap();
27498        std::fs::create_dir_all(root.join("directory-a")).unwrap();
27499        std::fs::write(root.join("ordinary-file"), b"not a directory").unwrap();
27500        let workspace_id = WorkspaceId::new("primary").unwrap();
27501        let workspace = WorkspaceConfig::new(workspace_id, &root).unwrap();
27502        let catalog = active_registry().unwrap();
27503        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27504        let shared = NodeShared::new(
27505            handle,
27506            "fixture-token".to_owned(),
27507            NodeId::new("node-1").unwrap(),
27508            vec![workspace],
27509            vec![agent("claude")],
27510        );
27511        let request = NodeRequest::BrowseHostDirectories {
27512            directory: Some(OpaqueHostPath::utf8(root.to_string_lossy().into_owned()).unwrap()),
27513            after: None,
27514        };
27515        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
27516            capability.as_str() == CAPABILITY_HOST_DIRECTORY_BROWSE_V1
27517        }));
27518        assert!(!request_uses_unnegotiated_capability(
27519            &request,
27520            &baseline_capabilities().unwrap(),
27521        ));
27522        assert!(request_uses_unnegotiated_capability(&request, &[]));
27523
27524        let response = process_request_inner(&shared, 77, ClientRole::Observer, request)
27525            .await
27526            .unwrap();
27527        let NodeResponse::HostDirectoriesBrowsed { listing } = response else {
27528            panic!("host directory browse returned another response");
27529        };
27530        assert_eq!(
27531            listing
27532                .entries
27533                .iter()
27534                .map(|entry| entry.display_name.as_str())
27535                .collect::<Vec<_>>(),
27536            vec!["directory-a", "directory-b"],
27537        );
27538        assert!(listing.entries.iter().all(|entry| !entry.is_link));
27539        assert!(!listing.incomplete);
27540        std::fs::remove_dir_all(root).unwrap();
27541    }
27542
27543    #[tokio::test]
27544    async fn observer_can_inspect_only_a_registered_workspace() {
27545        let root = temporary_workspace_root("observer");
27546        std::fs::create_dir_all(root.join("src")).unwrap();
27547        std::fs::write(root.join("src/main.rs"), b"fn main() {}\n").unwrap();
27548        let workspace_id = WorkspaceId::new("primary").unwrap();
27549        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27550        let catalog = active_registry().unwrap();
27551        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27552        let shared = NodeShared::new(
27553            handle,
27554            "fixture-token".to_owned(),
27555            NodeId::new("node-1").unwrap(),
27556            vec![workspace],
27557            vec![agent("claude")],
27558        );
27559
27560        let response = process_request_inner(
27561            &shared,
27562            77,
27563            ClientRole::Observer,
27564            NodeRequest::InspectWorkspace {
27565                workspace_id: workspace_id.clone(),
27566            },
27567        )
27568        .await
27569        .unwrap();
27570        let NodeResponse::WorkspaceInspected { inspection } = response else {
27571            panic!("workspace inspection returned another response");
27572        };
27573        assert_eq!(inspection.workspace_id, workspace_id);
27574        assert!(inspection.entries.iter().any(|entry| {
27575            entry.relative_path.as_utf8() == Some("src/main.rs")
27576        }));
27577
27578        let unknown = process_request_inner(
27579            &shared,
27580            77,
27581            ClientRole::Observer,
27582            NodeRequest::InspectWorkspace {
27583                workspace_id: WorkspaceId::new("unknown").unwrap(),
27584            },
27585        )
27586        .await
27587        .unwrap_err();
27588        assert_eq!(unknown.code, NodeFailureCode::UnknownWorkspace);
27589        std::fs::remove_dir_all(&root).unwrap();
27590    }
27591
27592    #[test]
27593    fn context_pack_source_accepts_only_running_or_successful_exit() {
27594        assert!(context_pack_source_status_is_usable(&SessionStatus::Running));
27595        assert!(context_pack_source_status_is_usable(&SessionStatus::Exited {
27596            exit_code: Some(0),
27597        }));
27598        assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
27599            exit_code: Some(1),
27600        }));
27601        assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
27602            exit_code: None,
27603        }));
27604        assert!(!context_pack_source_status_is_usable(&SessionStatus::Failed {
27605            message: "provider failed".to_owned(),
27606        }));
27607        assert!(!context_pack_source_status_is_usable(&SessionStatus::Registered));
27608        assert!(!context_pack_source_status_is_usable(&SessionStatus::Starting));
27609        assert!(!context_pack_source_status_is_usable(&SessionStatus::Stopping));
27610    }
27611
27612    #[test]
27613    fn context_pack_repository_rejects_changed_head_with_same_git_snapshot() {
27614        let git = git_snapshot_for_parser();
27615        let first = ContextPackRepositoryObservation {
27616            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27617            git: git.clone(),
27618            files: Vec::new(),
27619        };
27620        let second = ContextPackRepositoryObservation {
27621            head: ContextPackRepositoryHead::Commit("b".repeat(40)),
27622            git,
27623            files: Vec::new(),
27624        };
27625
27626        let error = stable_context_pack_repository(first, second).unwrap_err();
27627
27628        assert_eq!(
27629            error.code,
27630            NodeFailureCode::ContextPackMaterializationFailed,
27631        );
27632    }
27633
27634    #[test]
27635    fn context_pack_repository_rejects_changed_file_with_same_git_snapshot() {
27636        let git = git_snapshot_for_parser();
27637        let first = ContextPackRepositoryObservation {
27638            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27639            git: git.clone(),
27640            files: vec![ContextPackRepositoryFileSource::utf8(
27641                "README.md",
27642                "first\n".to_owned(),
27643                6,
27644            )],
27645        };
27646        let second = ContextPackRepositoryObservation {
27647            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27648            git,
27649            files: vec![ContextPackRepositoryFileSource::utf8(
27650                "README.md",
27651                "other\n".to_owned(),
27652                6,
27653            )],
27654        };
27655
27656        let error = stable_context_pack_repository(first, second).unwrap_err();
27657
27658        assert_eq!(
27659            error.code,
27660            NodeFailureCode::ContextPackMaterializationFailed,
27661        );
27662    }
27663
27664    #[tokio::test]
27665    async fn native_session_catalog_zero_active_session_fixture() {
27666        let workspace_root = temporary_workspace_root("native-catalog-workspace");
27667        let history_root = temporary_workspace_root("native-catalog-history");
27668        let outside_root = temporary_workspace_root("native-catalog-outside");
27669        std::fs::create_dir_all(&workspace_root).unwrap();
27670        std::fs::create_dir_all(&history_root).unwrap();
27671        std::fs::create_dir_all(&outside_root).unwrap();
27672        let transcript = |session_id: &str, cwd: &Path, title: &str| {
27673            [
27674                serde_json::json!({
27675                    "type": "user",
27676                    "sessionId": session_id,
27677                    "cwd": cwd.to_str().unwrap(),
27678                    "message": { "content": "raw user text must not cross the catalog wire" }
27679                }),
27680                serde_json::json!({
27681                    "type": "assistant",
27682                    "sessionId": session_id,
27683                    "cwd": cwd.to_str().unwrap(),
27684                    "message": { "model": "claude-sonnet", "content": "raw answer" }
27685                }),
27686                serde_json::json!({
27687                    "type": "ai-title",
27688                    "sessionId": session_id,
27689                    "aiTitle": title
27690                }),
27691            ]
27692            .into_iter()
27693            .map(|record| record.to_string())
27694            .collect::<Vec<_>>()
27695            .join("\n")
27696        };
27697        std::fs::write(
27698            history_root.join("stable-session.jsonl"),
27699            transcript("stable-session", &workspace_root, "Workspace review"),
27700        )
27701        .unwrap();
27702        std::fs::write(
27703            history_root.join("outside-session.jsonl"),
27704            transcript("outside-session", &outside_root, "Outside review"),
27705        )
27706        .unwrap();
27707        let history = NativeHistoryConfig::new(vec![
27708            NativeHistoryRoot::new(
27709                gate4agent_types::AdapterId::new("claude-code").unwrap(),
27710                HistorySourceLayout::SingleNdjson,
27711                history_root.clone(),
27712            )
27713            .unwrap(),
27714        ])
27715        .unwrap();
27716        let catalog = active_registry().unwrap();
27717        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27718        assert!(handle.snapshot().sessions.is_empty());
27719        let workspace_id = WorkspaceId::new("primary").unwrap();
27720        let workspace = WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap();
27721        let mut shared = NodeShared::new(
27722            handle,
27723            "fixture-token".to_owned(),
27724            NodeId::new("node-native-catalog").unwrap(),
27725            vec![workspace],
27726            vec![agent("claude")],
27727        );
27728        shared.native_session_catalog = Some(Arc::new(Mutex::new(
27729            NativeSessionCatalogAuthority::new(history),
27730        )));
27731        let route = NativeSessionCatalogRoute::workspace(
27732            workspace_id.clone(),
27733            agent("claude"),
27734        );
27735        let request = NodeRequest::CatalogNativeSessions {
27736            route: route.clone(),
27737            limit: 2,
27738        };
27739        let observer = process_request_inner(&shared, 7, ClientRole::Observer, request.clone())
27740            .await
27741            .unwrap_err();
27742        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27743        let response = process_request_inner(&shared, 7, ClientRole::Operator, request)
27744            .await
27745            .unwrap();
27746        let NodeResponse::NativeSessionsCataloged { entries, summary, .. } = response else {
27747            panic!("native catalog returned a different response");
27748        };
27749        let summary = summary.expect("node must emit native session catalog summary metadata");
27750        assert_eq!(entries.len(), 1);
27751        assert!(gate4agent_types::validate_candidate_id(&entries[0].selection_id).is_ok());
27752        assert_eq!(entries[0].external_group, None);
27753        assert_eq!(entries[0].record_id, None);
27754        assert_eq!(entries[0].title, None);
27755        assert_eq!(entries[0].model, None);
27756        assert_eq!(entries[0].message_count, 0);
27757        let encoded_entries = serde_json::to_string(&entries).unwrap();
27758        assert!(!encoded_entries.contains("stable-session"));
27759        assert!(!encoded_entries.contains(history_root.to_string_lossy().as_ref()));
27760        let page_request = NodeRequest::PageNativeSessions {
27761            route: route.clone(),
27762            window: crate::protocol::NativeSessionCatalogWindow::Recent,
27763            catalog_revision: summary.catalog_revision,
27764            recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27765            after_selection_id: None,
27766            limit: 2,
27767        };
27768        let observer = process_request_inner(
27769            &shared,
27770            7,
27771            ClientRole::Observer,
27772            page_request.clone(),
27773        )
27774        .await
27775        .unwrap_err();
27776        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27777        let page_response = process_request_inner(
27778            &shared,
27779            7,
27780            ClientRole::Operator,
27781            page_request.clone(),
27782        )
27783        .await
27784        .unwrap();
27785        assert!(matches!(
27786            page_response,
27787            NodeResponse::NativeSessionsPaged { page, .. }
27788                if page.entries.len() == 1 && !page.has_more && page.remaining_count == 0
27789        ));
27790        let stale = process_request_inner(
27791            &shared,
27792            7,
27793            ClientRole::Operator,
27794            NodeRequest::PageNativeSessions {
27795                route: route.clone(),
27796                window: crate::protocol::NativeSessionCatalogWindow::Recent,
27797                catalog_revision: summary.catalog_revision.wrapping_add(1),
27798                recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27799                after_selection_id: None,
27800                limit: 2,
27801            },
27802        )
27803        .await
27804        .unwrap_err();
27805        assert_eq!(stale.code, NodeFailureCode::StaleNativeSessionCatalog);
27806        let selection = NativeSessionSelection {
27807            route: route.clone(),
27808            catalog_revision: summary.catalog_revision,
27809            recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27810            selection_id: entries[0].selection_id.clone(),
27811        };
27812        let preview_response = process_request_inner(
27813            &shared,
27814            7,
27815            ClientRole::Operator,
27816            NodeRequest::PreviewNativeSession {
27817                selection: selection.clone(),
27818                message_limit: 1,
27819            },
27820        )
27821        .await
27822        .unwrap();
27823        let NodeResponse::NativeSessionPreviewed { preview, .. } = preview_response else {
27824            panic!("native preview returned a different response");
27825        };
27826        assert_eq!(preview.messages.len(), 1);
27827        assert!(preview.message_count_exact);
27828        assert_eq!(preview.messages[0].text, "raw answer");
27829        assert!(preview.truncated);
27830
27831        let index_request = NodeRequest::IndexNativeSession {
27832            selection: selection.clone(),
27833            display_name: "Saved Claude".to_owned(),
27834        };
27835        let observer = process_request_inner(
27836            &shared,
27837            7,
27838            ClientRole::Observer,
27839            index_request.clone(),
27840        )
27841        .await
27842        .unwrap_err();
27843        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27844        let without_controller = process_request_inner(
27845            &shared,
27846            7,
27847            ClientRole::Operator,
27848            index_request.clone(),
27849        )
27850        .await
27851        .unwrap_err();
27852        assert_eq!(without_controller.code, NodeFailureCode::ControllerRequired);
27853        let acquired = process_request_inner(
27854            &shared,
27855            7,
27856            ClientRole::Operator,
27857            NodeRequest::AcquireController { lease_ms: 5_000 },
27858        )
27859        .await
27860        .unwrap();
27861        assert!(matches!(acquired, NodeResponse::Controller { controller: Some(_) }));
27862        let indexed = process_request_inner(
27863            &shared,
27864            7,
27865            ClientRole::Operator,
27866            index_request,
27867        )
27868        .await
27869        .unwrap();
27870        assert!(indexed.native_session_index_contract_is_valid());
27871        let NodeResponse::NativeSessionIndexed {
27872            selection: echoed_selection,
27873            record,
27874        } = indexed else {
27875            panic!("native session index returned a different response");
27876        };
27877        assert_eq!(echoed_selection, selection);
27878        let recataloged = process_request_inner(
27879            &shared,
27880            7,
27881            ClientRole::Operator,
27882            NodeRequest::CatalogNativeSessions {
27883                route,
27884                limit: 2,
27885            },
27886        )
27887        .await
27888        .unwrap();
27889        let NodeResponse::NativeSessionsCataloged { entries, .. } = recataloged else {
27890            panic!("native catalog returned a different response after indexing");
27891        };
27892        assert_eq!(entries[0].record_id.as_ref(), Some(&record.record_id));
27893
27894        let unregistered_route = NativeSessionCatalogRoute::unregistered(agent("claude"));
27895        let external_catalog = process_request_inner(
27896            &shared,
27897            7,
27898            ClientRole::Operator,
27899            NodeRequest::CatalogNativeSessions {
27900                route: unregistered_route.clone(),
27901                limit: 2,
27902            },
27903        )
27904        .await
27905        .unwrap();
27906        let external_json = serde_json::to_string(&external_catalog).unwrap();
27907        assert!(!external_json.contains("outside-session"));
27908        assert!(!external_json.contains(outside_root.to_string_lossy().as_ref()));
27909        assert!(!external_json.contains(history_root.to_string_lossy().as_ref()));
27910        assert!(!external_json.contains("\"session_id\""));
27911        assert!(!external_json.contains("\"cwd\""));
27912        assert!(!external_json.contains("\"path\""));
27913        let NodeResponse::NativeSessionsCataloged {
27914            route: echoed_route,
27915            entries: external_entries,
27916            summary: Some(external_summary),
27917        } = external_catalog
27918        else {
27919            panic!("unregistered native catalog returned a different response");
27920        };
27921        assert_eq!(echoed_route, unregistered_route);
27922        assert_eq!(external_entries.len(), 1);
27923        assert_eq!(external_entries[0].record_id, None);
27924        let external_group = external_entries[0]
27925            .external_group
27926            .as_ref()
27927            .expect("unregistered session must have an external group");
27928        assert_eq!(
27929            external_group.kind,
27930            gate4agent_types::NativeSessionExternalGroupKind::Project,
27931        );
27932        assert!(external_group.group_id.starts_with("external-"));
27933        assert_eq!(
27934            external_group.display_name,
27935            outside_root.file_name().unwrap().to_string_lossy(),
27936        );
27937        let external_selection = NativeSessionSelection {
27938            route: unregistered_route,
27939            catalog_revision: external_summary.catalog_revision,
27940            recent_cutoff_unix_ms: external_summary.recent_cutoff_unix_ms,
27941            selection_id: external_entries[0].selection_id.clone(),
27942        };
27943        let external_preview = process_request_inner(
27944            &shared,
27945            7,
27946            ClientRole::Operator,
27947            NodeRequest::PreviewNativeSession {
27948                selection: external_selection.clone(),
27949                message_limit: 1,
27950            },
27951        )
27952        .await
27953        .unwrap();
27954        let external_preview_json = serde_json::to_string(&external_preview).unwrap();
27955        assert!(!external_preview_json.contains("outside-session"));
27956        assert!(!external_preview_json.contains(outside_root.to_string_lossy().as_ref()));
27957        assert!(!external_preview_json.contains("\"session_id\""));
27958        let NodeResponse::NativeSessionPreviewed { preview, .. } = external_preview else {
27959            panic!("unregistered native preview returned a different response");
27960        };
27961        assert_eq!(preview.messages.len(), 1);
27962        let external_index = process_request_inner(
27963            &shared,
27964            7,
27965            ClientRole::Operator,
27966            NodeRequest::IndexNativeSession {
27967                selection: external_selection,
27968                display_name: "External Claude".to_owned(),
27969            },
27970        )
27971        .await
27972        .unwrap_err();
27973        assert_eq!(
27974            external_index.code,
27975            NodeFailureCode::WorkspaceRegistrationRequired,
27976        );
27977        let record_response = process_request_inner(
27978            &shared,
27979            7,
27980            ClientRole::Operator,
27981            NodeRequest::PreviewSessionRecord {
27982                record_id: record.record_id.clone(),
27983                message_limit: 2,
27984            },
27985        )
27986        .await
27987        .unwrap();
27988        let NodeResponse::SessionRecordPreviewed { record_id, preview } = record_response else {
27989            panic!("record preview returned a different response");
27990        };
27991        assert_eq!(record_id, record.record_id);
27992        assert_eq!(preview.messages.len(), 2);
27993        let encoded = serde_json::to_string(&preview).unwrap();
27994        assert!(!encoded.contains("stable-session"));
27995        assert!(!encoded.contains(history_root.to_string_lossy().as_ref()));
27996        let NodeResponse::Resync { events, .. } = shared.resync(0) else {
27997            panic!("record preview history summary returned a different response");
27998        };
27999        let summaries = events
28000            .iter()
28001            .filter_map(|envelope| match &envelope.event {
28002                NodeEvent::SessionRecordHistorySummarized { record_id, summary }
28003                    if record_id == &record.record_id =>
28004                {
28005                    Some(*summary)
28006                }
28007                _ => None,
28008            })
28009            .collect::<Vec<_>>();
28010        assert_eq!(summaries, vec![SessionHistorySummaryV1::from(&preview)]);
28011        let summary_json = serde_json::to_string(&summaries).unwrap();
28012        for private in [
28013            "stable-session",
28014            "raw user text must not cross the catalog wire",
28015            "raw answer",
28016            "Workspace review",
28017            "claude-sonnet",
28018        ] {
28019            assert!(!summary_json.contains(private), "{private}");
28020        }
28021        let identity = record.provider_session.clone().unwrap();
28022        assert_eq!(
28023            shared.revalidate_session_record_preview(&record, &identity),
28024            Ok(())
28025        );
28026        shared.remove_record_memory(&record.record_id);
28027        assert_eq!(
28028            shared
28029                .revalidate_session_record_preview(&record, &identity)
28030                .unwrap_err()
28031                .code,
28032            NodeFailureCode::UnknownSessionRecord
28033        );
28034        let released = process_request_inner(
28035            &shared,
28036            7,
28037            ClientRole::Operator,
28038            NodeRequest::ReleaseController,
28039        )
28040        .await
28041        .unwrap();
28042        assert!(matches!(released, NodeResponse::Controller { controller: None }));
28043        assert!(shared.controller_state().is_none());
28044        std::fs::remove_dir_all(workspace_root).unwrap();
28045        std::fs::remove_dir_all(history_root).unwrap();
28046        std::fs::remove_dir_all(outside_root).unwrap();
28047    }
28048
28049    #[tokio::test]
28050    async fn native_session_catalog_concurrent_operation_fails_fast_without_waiting_on_mutex() {
28051        let history_root = temporary_workspace_root("native-catalog-concurrent-operation");
28052        std::fs::create_dir_all(&history_root).unwrap();
28053        let history = NativeHistoryConfig::new(vec![
28054            NativeHistoryRoot::new(
28055                gate4agent_types::AdapterId::new("claude-code").unwrap(),
28056                HistorySourceLayout::SingleNdjson,
28057                history_root.clone(),
28058            )
28059            .unwrap(),
28060        ])
28061        .unwrap();
28062        let catalog = Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)));
28063        let held = catalog.lock().unwrap();
28064        let result = timeout(
28065            Duration::from_secs(1),
28066            run_native_session_catalog_operation(
28067                Arc::clone(&catalog),
28068                "native session catalog",
28069                |_| Ok::<(), std::convert::Infallible>(()),
28070            ),
28071        )
28072        .await
28073        .expect("concurrent native catalog request must not wait for the operation deadline")
28074        .unwrap_err();
28075        assert_eq!(result.code, NodeFailureCode::BackendBusy);
28076        drop(held);
28077        std::fs::remove_dir_all(history_root).unwrap();
28078    }
28079
28080    fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
28081        let unique = std::time::SystemTime::now()
28082            .duration_since(std::time::UNIX_EPOCH)
28083            .unwrap()
28084            .as_nanos();
28085        std::env::temp_dir().join(format!(
28086            "gate4agent-node-{label}-{}-{unique}",
28087            std::process::id(),
28088        ))
28089    }
28090
28091    fn git_snapshot_for_parser() -> GitSnapshot {
28092        GitSnapshot {
28093            is_repository: true,
28094            branch: Some("main".to_owned()),
28095            status: Vec::new(),
28096            recent_commits: Vec::new(),
28097            worktrees: Vec::new(),
28098            managed_worktree: None,
28099            truncated: false,
28100            diagnostic: None,
28101        }
28102    }
28103
28104    #[tokio::test]
28105    async fn workspace_inspection_scopes_only_exact_active_managed_branch() {
28106        let root = temporary_workspace_root("managed-git-scope");
28107        std::fs::create_dir_all(&root).unwrap();
28108        run_test_git(&root, &["init"]);
28109        run_test_git(&root, &["config", "user.email", "gate4agent@example.invalid"]);
28110        run_test_git(&root, &["config", "user.name", "Gate4Agent Test"]);
28111        std::fs::write(root.join("seed.txt"), "seed\n").unwrap();
28112        run_test_git(&root, &["add", "seed.txt"]);
28113        run_test_git(&root, &["commit", "-m", "seed"]);
28114        run_test_git(&root, &["checkout", "-b", "gate4agent/a"]);
28115
28116        let workspace_id = WorkspaceId::new("managed-a").unwrap();
28117        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
28118        let catalog = active_registry().unwrap();
28119        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28120        let shared = NodeShared::new(
28121            handle,
28122            "fixture-token".to_owned(),
28123            NodeId::new("node-1").unwrap(),
28124            vec![workspace],
28125            vec![agent("claude")],
28126        );
28127        let mut lease = ManagedWorktreeLeaseRecord {
28128            lease_id: ManagedWorktreeLeaseId::new("mw-a").unwrap(),
28129            source_workspace_id: WorkspaceId::new("primary").unwrap(),
28130            workspace_id: workspace_id.clone(),
28131            profile_id: WorktreeProfileId::new("default").unwrap(),
28132            profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
28133            target_root: root.to_string_lossy().into_owned(),
28134            branch: "gate4agent/a".to_owned(),
28135            base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
28136            expected_head: None,
28137            retention: ManagedWorktreeRetention::Retain,
28138            state: ManagedWorktreeLeaseState::InUse,
28139            session_holders: vec![ManagedWorktreeSessionHolder {
28140                incarnation_id: shared.incarnation_id,
28141                instance_id: AgentInstanceId(7),
28142                generation: SessionGeneration(1),
28143            }],
28144            record_holders: Vec::new(),
28145            cleanup_failure: None,
28146            created_at_unix_ms: 1,
28147            updated_at_unix_ms: 1,
28148        };
28149        *shared
28150            .managed_worktrees
28151            .lock()
28152            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
28153            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
28154
28155        let inspection = shared.inspect_workspace(workspace_id.clone()).await.unwrap();
28156        let scope = inspection.git.managed_worktree.unwrap();
28157        assert_eq!(scope.lease_id, lease.lease_id);
28158        assert_eq!(scope.branch, "gate4agent/a");
28159        assert_eq!(scope.active_session_count, 1);
28160
28161        lease.branch = "gate4agent/b".to_owned();
28162        *shared
28163            .managed_worktrees
28164            .lock()
28165            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
28166            ManagedWorktreeRegistry::from_records(vec![lease], Vec::new()).unwrap();
28167        let mismatch = shared.inspect_workspace(workspace_id).await.unwrap();
28168        assert!(mismatch.git.managed_worktree.is_none());
28169        std::fs::remove_dir_all(root).unwrap();
28170    }
28171
28172    #[tokio::test]
28173    async fn workspace_file_write_requires_controller_and_rejects_stale_revision() {
28174        let root = temporary_workspace_root("workspace-file-write-controller");
28175        std::fs::create_dir_all(root.join("src")).unwrap();
28176        std::fs::write(root.join("src/lib.rs"), b"old\n").unwrap();
28177        let workspace_id = WorkspaceId::new("primary").unwrap();
28178        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
28179        let catalog = active_registry().unwrap();
28180        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28181        let shared = NodeShared::new(
28182            handle,
28183            "fixture-token".to_owned(),
28184            NodeId::new("node-1").unwrap(),
28185            vec![workspace],
28186            vec![agent("claude")],
28187        );
28188        let path = RepositoryPath::utf8("src/lib.rs".to_owned()).unwrap();
28189        let expected_revision = workspace_file_revision(b"old\n");
28190        let request = NodeRequest::WriteWorkspaceFile {
28191            workspace_id: workspace_id.clone(),
28192            path: path.clone(),
28193            expected_revision: expected_revision.clone(),
28194            text: "new\n".to_owned(),
28195        };
28196        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
28197            .await
28198            .unwrap_err();
28199        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
28200        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
28201            .await
28202            .unwrap_err();
28203        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
28204        shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
28205        let response = process_request_inner(&shared, 77, ClientRole::Operator, request).await.unwrap();
28206        let NodeResponse::WorkspaceFileWritten { file } = response else {
28207            panic!("workspace file write returned another response");
28208        };
28209        assert_eq!(file.revision, Some(workspace_file_revision(b"new\n")));
28210        assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
28211        let stale = process_request_inner(
28212            &shared,
28213            77,
28214            ClientRole::Operator,
28215            NodeRequest::WriteWorkspaceFile {
28216                workspace_id,
28217                path,
28218                expected_revision,
28219                text: "lost update\n".to_owned(),
28220            },
28221        )
28222        .await
28223        .unwrap_err();
28224        assert_eq!(stale.code, NodeFailureCode::RepositoryFileRevisionConflict);
28225        assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
28226        std::fs::remove_dir_all(root).unwrap();
28227    }
28228
28229    #[tokio::test]
28230    async fn workspace_entry_create_requires_controller_and_never_overwrites() {
28231        let root = temporary_workspace_root("workspace-entry-create-controller");
28232        std::fs::create_dir_all(root.join("src")).unwrap();
28233        let workspace_id = WorkspaceId::new("primary").unwrap();
28234        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
28235        let catalog = active_registry().unwrap();
28236        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28237        let shared = NodeShared::new(
28238            handle,
28239            "fixture-token".to_owned(),
28240            NodeId::new("node-1").unwrap(),
28241            vec![workspace],
28242            vec![agent("claude")],
28243        );
28244        let path = RepositoryPath::utf8("src/new.rs".to_owned()).unwrap();
28245        let request = NodeRequest::CreateWorkspaceFile {
28246            workspace_id: workspace_id.clone(),
28247            path: path.clone(),
28248        };
28249        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
28250            capability.as_str() == NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY
28251        }));
28252        assert!(!request_uses_unnegotiated_capability(
28253            &request,
28254            &baseline_capabilities().unwrap(),
28255        ));
28256        assert!(request_uses_unnegotiated_capability(&request, &[]));
28257        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
28258            .await
28259            .unwrap_err();
28260        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
28261        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
28262            .await
28263            .unwrap_err();
28264        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
28265        shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
28266
28267        let response = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
28268            .await
28269            .unwrap();
28270        let NodeResponse::WorkspaceFileCreated { file } = response else {
28271            panic!("workspace file create returned another response");
28272        };
28273        assert_eq!(file.workspace_id, workspace_id);
28274        assert_eq!(file.path, path);
28275        assert_eq!(file.revision, Some(workspace_file_revision(&[])));
28276        assert_eq!(std::fs::read(root.join("src/new.rs")).unwrap(), Vec::<u8>::new());
28277
28278        let duplicate = process_request_inner(&shared, 77, ClientRole::Operator, request)
28279            .await
28280            .unwrap_err();
28281        assert_eq!(duplicate.code, NodeFailureCode::RepositoryEntryAlreadyExists);
28282
28283        let directory_path = RepositoryPath::utf8("src/nested".to_owned()).unwrap();
28284        let response = process_request_inner(
28285            &shared,
28286            77,
28287            ClientRole::Operator,
28288            NodeRequest::CreateWorkspaceDirectory {
28289                workspace_id: workspace_id.clone(),
28290                path: directory_path.clone(),
28291            },
28292        )
28293        .await
28294        .unwrap();
28295        assert_eq!(
28296            response,
28297            NodeResponse::WorkspaceDirectoryCreated {
28298                workspace_id: workspace_id.clone(),
28299                entry: WorkspaceEntry {
28300                    relative_path: directory_path,
28301                    kind: WorkspaceEntryKind::Directory,
28302                },
28303            },
28304        );
28305        assert!(root.join("src/nested").is_dir());
28306
28307        let missing_parent = process_request_inner(
28308            &shared,
28309            77,
28310            ClientRole::Operator,
28311            NodeRequest::CreateWorkspaceFile {
28312                workspace_id: workspace_id.clone(),
28313                path: RepositoryPath::utf8("missing/new.rs".to_owned()).unwrap(),
28314            },
28315        )
28316        .await
28317        .unwrap_err();
28318        assert_eq!(missing_parent.code, NodeFailureCode::RepositoryParentNotFound);
28319        let non_directory_parent = process_request_inner(
28320            &shared,
28321            77,
28322            ClientRole::Operator,
28323            NodeRequest::CreateWorkspaceFile {
28324                workspace_id,
28325                path: RepositoryPath::utf8("src/new.rs/child".to_owned()).unwrap(),
28326            },
28327        )
28328        .await
28329        .unwrap_err();
28330        assert_eq!(
28331            non_directory_parent.code,
28332            NodeFailureCode::RepositoryParentNotDirectory,
28333        );
28334        std::fs::remove_dir_all(root).unwrap();
28335    }
28336
28337    #[tokio::test]
28338    async fn workspace_entry_create_timeout_cancels_before_commit_without_late_mutation() {
28339        let root = temporary_workspace_root("workspace-entry-create-timeout-cancel");
28340        std::fs::create_dir_all(root.join("src")).unwrap();
28341        #[cfg(windows)]
28342        let root = std::fs::canonicalize(root).unwrap();
28343        let path = RepositoryPath::utf8("src/late.rs".to_owned()).unwrap();
28344        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
28345        let barrier = Arc::new(std::sync::Barrier::new(2));
28346        let done = Arc::new(AtomicBool::new(false));
28347        let task_commit_state = Arc::clone(&commit_state);
28348        let task_barrier = Arc::clone(&barrier);
28349        let task_done = Arc::clone(&done);
28350        let task_root = root.clone();
28351        let task = tokio::task::spawn_blocking(move || {
28352            task_barrier.wait();
28353            let result = create_workspace_file_on_disk(
28354                &task_root,
28355                &path,
28356                &task_commit_state,
28357            );
28358            task_done.store(true, Ordering::Release);
28359            result
28360        });
28361
28362        let error = settle_workspace_entry_create(
28363            task,
28364            Arc::clone(&commit_state),
28365            Duration::from_millis(10),
28366            "fixture deadline",
28367            "fixture task failed",
28368        )
28369        .await
28370        .unwrap_err();
28371        assert_eq!(error.code, NodeFailureCode::RepositoryEntryCreateTimedOut);
28372        assert_eq!(
28373            commit_state.load(Ordering::Acquire),
28374            WORKSPACE_ENTRY_CREATE_CANCELED,
28375        );
28376
28377        barrier.wait();
28378        timeout(Duration::from_secs(1), async {
28379            while !done.load(Ordering::Acquire) {
28380                tokio::task::yield_now().await;
28381            }
28382        })
28383        .await
28384        .expect("canceled blocking create did not settle");
28385        assert!(!root.join("src/late.rs").exists());
28386        std::fs::remove_dir_all(root).unwrap();
28387    }
28388}
28389
28390#[cfg(test)]
28391mod provisional_spawn_tests {
28392    use super::*;
28393    use std::sync::Arc;
28394
28395    fn shared() -> NodeShared {
28396        let catalog = active_registry().unwrap();
28397        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28398        let workspace = WorkspaceConfig::new(
28399            WorkspaceId::new("primary").unwrap(),
28400            std::env::current_dir().unwrap(),
28401        )
28402        .unwrap()
28403        .with_worktree_service_mode(WorktreeServiceMode::Manual);
28404        NodeShared::new(
28405            handle,
28406            "fixture-token".to_owned(),
28407            NodeId::new("node-provisional-test").unwrap(),
28408            vec![workspace],
28409            vec![AgentId::new("claude").unwrap()],
28410        )
28411    }
28412
28413    /// Provisional draft map: sessionize by instance id only; 50 sequential
28414    /// mark/claim cycles never collide, and a claimed draft is never reaped.
28415    #[test]
28416    fn provisional_spawn_keys_are_unique_and_sessionize_prevents_reap() {
28417        let shared = shared();
28418        let ws = WorkspaceId::new("primary").unwrap();
28419        let mut ids = Vec::with_capacity(50);
28420        for i in 1u64..=50 {
28421            let id = AgentInstanceId(i);
28422            ids.push(id);
28423            let address = SessionAddress {
28424                workspace_id: ws.clone(),
28425                session: SessionKey {
28426                    instance_id: id,
28427                    generation: SessionGeneration(1),
28428                },
28429            };
28430            shared.mark_spawn_provisional(address);
28431        }
28432        assert_eq!(
28433            shared
28434                .provisional_spawns
28435                .lock()
28436                .unwrap_or_else(|p| p.into_inner())
28437                .len(),
28438            50
28439        );
28440        for id in ids.iter().step_by(2) {
28441            shared.sessionize_spawn(*id);
28442        }
28443        assert_eq!(
28444            shared
28445                .provisional_spawns
28446                .lock()
28447                .unwrap_or_else(|p| p.into_inner())
28448                .len(),
28449            25
28450        );
28451        assert!(shared.take_due_provisional_spawns().is_empty());
28452        {
28453            let mut map = shared
28454                .provisional_spawns
28455                .lock()
28456                .unwrap_or_else(|p| p.into_inner());
28457            let past = Instant::now() - Duration::from_secs(91);
28458            for (_, (_, at)) in map.iter_mut() {
28459                *at = past;
28460            }
28461        }
28462        let due = shared.take_due_provisional_spawns();
28463        assert_eq!(due.len(), 25);
28464        let due_ids: std::collections::BTreeSet<_> =
28465            due.iter().map(|a| a.session.instance_id.0).collect();
28466        let expected: std::collections::BTreeSet<_> =
28467            ids.iter().skip(1).step_by(2).map(|id| id.0).collect();
28468        assert_eq!(due_ids, expected);
28469        assert!(shared
28470            .provisional_spawns
28471            .lock()
28472            .unwrap_or_else(|p| p.into_inner())
28473            .is_empty());
28474    }
28475
28476    /// Twenty parallel provisional marks must each get their own instance key;
28477    /// killing/sessionizing one id must not touch the others.
28478    #[test]
28479    fn provisional_spawn_parallel_marks_isolate_kills_by_instance_id() {
28480        let shared = Arc::new(shared());
28481        let ws = WorkspaceId::new("primary").unwrap();
28482        let mut handles = Vec::new();
28483        for i in 1u64..=20 {
28484            let shared = Arc::clone(&shared);
28485            let ws = ws.clone();
28486            handles.push(std::thread::spawn(move || {
28487                let id = AgentInstanceId(i);
28488                let address = SessionAddress {
28489                    workspace_id: ws,
28490                    session: SessionKey {
28491                        instance_id: id,
28492                        generation: SessionGeneration(1),
28493                    },
28494                };
28495                shared.mark_spawn_provisional(address);
28496                id
28497            }));
28498        }
28499        let mut ids = Vec::new();
28500        for h in handles {
28501            ids.push(h.join().unwrap());
28502        }
28503        ids.sort_by_key(|id| id.0);
28504        assert_eq!(ids.len(), 20);
28505        ids.dedup();
28506        assert_eq!(ids.len(), 20, "parallel marks must not share instance keys");
28507        assert_eq!(
28508            shared
28509                .provisional_spawns
28510                .lock()
28511                .unwrap_or_else(|p| p.into_inner())
28512                .len(),
28513            20
28514        );
28515        shared.sessionize_spawn(AgentInstanceId(7));
28516        let left: Vec<_> = shared
28517            .provisional_spawns
28518            .lock()
28519            .unwrap_or_else(|p| p.into_inner())
28520            .keys()
28521            .map(|id| id.0)
28522            .collect();
28523        assert!(!left.contains(&7));
28524        assert_eq!(left.len(), 19);
28525    }
28526}
28527
28528
28529#[cfg(unix)]
28530extern "C" {
28531    #[link_name = "geteuid"]
28532    fn libc_geteuid() -> u32;
28533}
28534
28535#[cfg(test)]
28536mod call_home_link_tests {
28537    use super::*;
28538
28539    #[test]
28540    fn call_home_backoff_is_capped_and_monotonic() {
28541        let mut last = Duration::ZERO;
28542        for attempt in 1..20 {
28543            let next = call_home_backoff(attempt);
28544            assert!(next >= last);
28545            assert!(next <= Duration::from_secs(8));
28546            last = next;
28547        }
28548        assert_eq!(call_home_backoff(0), Duration::from_millis(500));
28549    }
28550
28551    #[tokio::test]
28552    async fn call_home_socket_gets_keepalive() {
28553        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
28554        let addr = listener.local_addr().unwrap();
28555        let (client, _server) = tokio::join!(tokio::net::TcpStream::connect(addr), listener.accept());
28556        let client = client.unwrap();
28557        enable_call_home_keepalive(&client).unwrap();
28558        let sock = socket2::SockRef::from(&client);
28559        assert!(sock.keepalive().unwrap());
28560        #[cfg(any(target_os = "linux", target_os = "macos"))]
28561        assert_eq!(sock.tcp_keepalive_time().unwrap(), CALL_HOME_KEEPALIVE_IDLE);
28562    }
28563}
28564
28565/// A provider transcript for this session id exists in a known provider home.
28566fn provider_session_exists(provider: &str, id: &str) -> bool {
28567    if provider != "claude" { return false; }
28568    let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
28569    if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") { homes.extend(std::env::split_paths(&extra)); }
28570    let name = format!("{id}.jsonl");
28571    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())))
28572}
28573
28574/// Append launch args to a prepared overlay (or create an instance overlay for them).
28575fn overlay_with_extra_args(
28576    provider: &AgentId,
28577    mode: SessionMode,
28578    overlay: Option<PreparedNativeLaunchOverlay>,
28579    args: Vec<OsString>,
28580) -> Result<Option<PreparedNativeLaunchOverlay>, NodeFailure> {
28581    let bad = || failure(NodeFailureCode::BackendOperationFailed, "resume launch args overlay is invalid");
28582    Ok(match (overlay, args) {
28583        (prepared, args) if args.is_empty() => prepared,
28584        (Some(PreparedNativeLaunchOverlay::Instance(mut o)), args) => { o.append_extra_args(args).map_err(|_| bad())?; Some(PreparedNativeLaunchOverlay::Instance(o)) }
28585        (Some(PreparedNativeLaunchOverlay::Environment(o)), args) => Some(PreparedNativeLaunchOverlay::Instance(o.into_instance_with_extra_args(args).map_err(|_| bad())?)),
28586        (None, args) => Some(PreparedNativeLaunchOverlay::Instance(NativeInstanceLaunchOverlay::new(
28587            provider.clone(),
28588            match mode { SessionMode::Pty => TransportKind::Pty, SessionMode::Acp => TransportKind::Acp, SessionMode::Inline => TransportKind::Pipe },
28589            Vec::new(),
28590            args,
28591        ).map_err(|_| bad())?)),
28592    })
28593}
28594
28595/// Turn trace in the node log for ACP sessions (start, tools, permission asks, end, errors):
28596/// names and sizes only, never prompt, tool input or output text.
28597fn trace_acp_turn(address: &SessionAddress, event: &ControlEvent) {
28598    let id = address.session.instance_id.0;
28599    let ControlEventKind::ProviderEvent { event: e, .. } = &event.event else { return };
28600    match e {
28601        ProviderEvent::TurnStarted { prompt } => tracing::info!(instance_id = id, prompt_chars = prompt.as_deref().map(str::len).unwrap_or(0), "acp turn started"),
28602        ProviderEvent::ToolStarted { name, .. } => tracing::info!(instance_id = id, tool = %name, "acp tool call started"),
28603        ProviderEvent::ToolCompleted { is_error, duration_ms, non_execution_kind, .. } => tracing::info!(instance_id = id, is_error, duration_ms = ?duration_ms, not_run = ?non_execution_kind, "acp tool call finished"),
28604        ProviderEvent::InteractionRequested { interaction_kind, tool_name, options, .. } => tracing::info!(instance_id = id, kind = ?interaction_kind, tool = %tool_name, options = options.len(), "acp permission/interaction requested"),
28605        ProviderEvent::TurnInterrupted => tracing::info!(instance_id = id, "acp turn interrupted"),
28606        ProviderEvent::SessionEnded { is_error, stop_reason, .. } => tracing::info!(instance_id = id, is_error, stop_reason = ?stop_reason, "acp turn ended"),
28607        ProviderEvent::Error { message } => tracing::warn!(instance_id = id, error = %message.chars().take(200).collect::<String>(), "acp provider error"),
28608        _ => {}
28609    }
28610}