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        let stream_chunk = agent_stream_chunk(&event);
10923        self.publish(NodeEvent::Control {
10924            address: address.clone(),
10925            event,
10926        });
10927        if let Some(chunk) = stream_chunk {
10928            self.publish_agent_stream_chunk(address.clone(), chunk);
10929        }
10930        if revokes_harness_mcp {
10931            SessionLifecycleHook::on_session_ended(self, &address);
10932        }
10933    }
10934
10935    fn reconcile_managed_record(
10936        &self,
10937        record_id: &SessionRecordId,
10938        address: &SessionAddress,
10939        event: &ControlEvent,
10940        identity_admitted: bool,
10941        resume_command_rejected: bool,
10942    ) {
10943        let _transaction = self
10944            .state_transaction
10945            .lock()
10946            .unwrap_or_else(|poisoned| poisoned.into_inner());
10947        let observed_identity = match &event.event {
10948            ControlEventKind::ProviderEvent {
10949                event: gate4agent_types::ProviderEvent::SessionIdentityObserved { identity },
10950                ..
10951            } if identity_admitted => Some(identity.clone()),
10952            _ => None,
10953        };
10954        let identity_was_observed = observed_identity.is_some();
10955        let replacement_id = observed_identity
10956            .as_ref()
10957            .and_then(|_| self.allocate_record_id().ok());
10958        let mut upserts = Vec::new();
10959        let mut removals = Vec::new();
10960        let mut rebind = None;
10961        let original_records;
10962        let original_materializations = self
10963            .materializations
10964            .lock()
10965            .unwrap_or_else(|poisoned| poisoned.into_inner())
10966            .clone();
10967        {
10968            let mut records = self
10969                .session_records
10970                .lock()
10971                .unwrap_or_else(|poisoned| poisoned.into_inner());
10972            original_records = records.records.clone();
10973            let Some(current_snapshot) = records.records.get(record_id).cloned() else {
10974                return;
10975            };
10976            if let Some(identity) = observed_identity {
10977                let existing_id = records
10978                    .records
10979                    .values()
10980                    .find(|record| {
10981                        record.record_id != *record_id
10982                            && record.provider_session.as_ref().is_some_and(|record_identity| {
10983                                session_registry::same_provider_session(
10984                                    &record.provider,
10985                                    record_identity,
10986                                    &current_snapshot.provider,
10987                                    &identity,
10988                                )
10989                            })
10990                    })
10991                    .map(|record| record.record_id.clone());
10992                if let Some(existing_id) = existing_id {
10993                    let scope_matches = records.records.get(&existing_id).is_some_and(|record| {
10994                        record.mode == current_snapshot.mode
10995                            && record.workspace_id == current_snapshot.workspace_id
10996                            && record.environment_profile == current_snapshot.environment_profile
10997                            && record.bundle == current_snapshot.bundle
10998                            && record.context_id == current_snapshot.context_id
10999                            && record.context == current_snapshot.context
11000                            && record
11001                                .canonical_root
11002                                .as_utf8()
11003                                .zip(current_snapshot.canonical_root.as_utf8())
11004                                .is_some_and(|(left, right)| platform::roots_equal(left, right))
11005                    });
11006                    if !scope_matches {
11007                        if let Some(current) = records.records.get_mut(record_id) {
11008                            current.state = ManagedSessionState::Unavailable;
11009                            current.last_error =
11010                                Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR.to_owned());
11011                            current.updated_at_unix_ms = unix_time_ms();
11012                            upserts.push(current.clone());
11013                        }
11014                    } else {
11015                        let existing_is_active = records
11016                            .records
11017                            .get(&existing_id)
11018                            .is_some_and(|record| record.active_session.is_some());
11019                        if existing_is_active {
11020                            if let Some(current) = records.records.get_mut(record_id) {
11021                                current.state = ManagedSessionState::Unavailable;
11022                                current.last_error =
11023                                    Some(PROVIDER_SESSION_LIVE_CONFLICT_ERROR.to_owned());
11024                                current.updated_at_unix_ms = unix_time_ms();
11025                                upserts.push(current.clone());
11026                            }
11027                        } else {
11028                            if current_snapshot.provider_session.is_some() {
11029                                if let Some(current) = records.records.get_mut(record_id) {
11030                                    current.active_session = None;
11031                                    current.state = ManagedSessionState::Dormant;
11032                                    current.updated_at_unix_ms = unix_time_ms();
11033                                    upserts.push(current.clone());
11034                                }
11035                            } else {
11036                                records.records.remove(record_id);
11037                                removals.push(record_id.clone());
11038                            }
11039                            if let Some(existing) = records.records.get_mut(&existing_id) {
11040                                existing.provider_session = Some(identity.clone());
11041                                existing.active_session = Some(address.clone());
11042                                existing.state = ManagedSessionState::Live;
11043                                existing.updated_at_unix_ms = unix_time_ms();
11044                                existing.last_error = None;
11045                                upserts.push(existing.clone());
11046                            }
11047                            rebind = Some(existing_id);
11048                        }
11049                    }
11050                } else if current_snapshot
11051                    .provider_session
11052                    .as_ref()
11053                    .is_some_and(|current| {
11054                        !session_registry::same_provider_session(
11055                            &current_snapshot.provider,
11056                            current,
11057                            &current_snapshot.provider,
11058                            &identity,
11059                        )
11060                    })
11061                {
11062                    if let Some(current) = records.records.get_mut(record_id) {
11063                        current.active_session = None;
11064                        if current.bundle.is_some() || current.context.is_some() {
11065                            let had_bundle = current.bundle.is_some();
11066                            current.provider_session = None;
11067                            current.bundle = None;
11068                            current.context_id = None;
11069                            current.context = None;
11070                            current.state = ManagedSessionState::Unavailable;
11071                            current.last_error = Some(if had_bundle {
11072                                "bundle-unavailable".to_owned()
11073                            } else {
11074                                "context-unavailable".to_owned()
11075                            });
11076                        } else {
11077                            current.state = ManagedSessionState::Dormant;
11078                        }
11079                        current.updated_at_unix_ms = unix_time_ms();
11080                        upserts.push(current.clone());
11081                    }
11082                    if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
11083                        if let Some(current) = records.records.get_mut(record_id) {
11084                            current.state = ManagedSessionState::Unavailable;
11085                            current.last_error =
11086                                Some(MANAGED_SESSION_CAPACITY_ERROR.to_owned());
11087                            current.updated_at_unix_ms = unix_time_ms();
11088                            upserts.push(current.clone());
11089                        }
11090                    } else if let Some(new_record_id) = replacement_id {
11091                        let now = unix_time_ms();
11092                        let replacement = ManagedSessionRecord {
11093                            record_id: new_record_id.clone(),
11094                            display_name: format!(
11095                                "{} #{}",
11096                                current_snapshot.provider.as_str(),
11097                                records
11098                                    .records
11099                                    .values()
11100                                    .filter(|record| {
11101                                        record.provider == current_snapshot.provider
11102                                    })
11103                                    .count()
11104                                    .saturating_add(1),
11105                            ),
11106                            provider: current_snapshot.provider.clone(),
11107                            mode: current_snapshot.mode,
11108                            state: ManagedSessionState::Live,
11109                            workspace_id: current_snapshot.workspace_id,
11110                            canonical_root: current_snapshot.canonical_root,
11111                            provider_session: Some(identity),
11112                            active_session: Some(address.clone()),
11113                            environment_profile: current_snapshot.environment_profile,
11114                            bundle: current_snapshot.bundle,
11115                            context_id: current_snapshot.context_id,
11116                            context: current_snapshot.context,
11117                            // This is a fresh record_id representing the session under its
11118                            // newly-observed identity; it has not yet produced any clean
11119                            // exit of its own, so it starts with no exported pack (the old
11120                            // record, still addressable under its own record_id, keeps
11121                            // whatever exported_context it already had).
11122                            exported_context: None,
11123                            task_binding: current_snapshot.task_binding,
11124                            created_at_unix_ms: now,
11125                            updated_at_unix_ms: now,
11126                            last_error: None,
11127                        };
11128                        records
11129                            .records
11130                            .insert(new_record_id.clone(), replacement.clone());
11131                        upserts.push(replacement);
11132                        rebind = Some(new_record_id);
11133                    } else if let Some(current) = records.records.get_mut(record_id) {
11134                        current.state = ManagedSessionState::Unavailable;
11135                        current.last_error =
11136                            Some(PROVIDER_IDENTITY_ALLOCATION_ERROR.to_owned());
11137                        upserts.push(current.clone());
11138                    }
11139                } else if let Some(current) = records.records.get_mut(record_id) {
11140                    current.provider_session = Some(identity);
11141                    current.active_session = Some(address.clone());
11142                    current.state = ManagedSessionState::Live;
11143                    current.updated_at_unix_ms = unix_time_ms();
11144                    current.last_error = None;
11145                    upserts.push(current.clone());
11146                }
11147            } else if let Some(current) = records.records.get_mut(record_id) {
11148                if resume_command_rejected {
11149                    current.active_session = None;
11150                    current.state = detached_record_state(current);
11151                    current.updated_at_unix_ms = unix_time_ms();
11152                    current.last_error =
11153                        Some(PROVIDER_RESUME_REJECTED_ERROR.to_owned());
11154                    upserts.push(current.clone());
11155                } else {
11156                    match &event.event {
11157                    ControlEventKind::Running { .. }
11158                    | ControlEventKind::ResumeAuthorized { .. }
11159                    | ControlEventKind::Resumed { .. } => {
11160                        current.active_session = Some(address.clone());
11161                        current.state = active_record_state(
11162                            identity_admitted,
11163                            current.provider_session.is_some(),
11164                        );
11165                        current.updated_at_unix_ms = unix_time_ms();
11166                        current.last_error = None;
11167                        upserts.push(current.clone());
11168                    }
11169                    ControlEventKind::Exited { .. } | ControlEventKind::Removed => {
11170                        current.active_session = None;
11171                        current.state = detached_record_state(current);
11172                        current.updated_at_unix_ms = unix_time_ms();
11173                        upserts.push(current.clone());
11174                    }
11175                    ControlEventKind::Failed { message }
11176                    | ControlEventKind::ResumeFailed { message } => {
11177                        current.active_session = None;
11178                        current.state = detached_record_state(current);
11179                        current.updated_at_unix_ms = unix_time_ms();
11180                        current.last_error = Some(
11181                            session_registry::sanitized_record_error_summary(message),
11182                        );
11183                        upserts.push(current.clone());
11184                    }
11185                    ControlEventKind::ResumeDenied { reason } => {
11186                        current.active_session = None;
11187                        current.state = detached_record_state(current);
11188                        current.updated_at_unix_ms = unix_time_ms();
11189                        current.last_error = Some(
11190                            session_registry::sanitized_record_error_summary(reason),
11191                        );
11192                        upserts.push(current.clone());
11193                    }
11194                        _ => {}
11195                    }
11196                }
11197            }
11198        }
11199        let materialization_owner_record = identity_was_observed.then(|| {
11200            rebind.clone().unwrap_or_else(|| record_id.clone())
11201        }).and_then(|candidate| {
11202            self.session_records
11203                .lock()
11204                .unwrap_or_else(|poisoned| poisoned.into_inner())
11205                .records
11206                .get(&candidate)
11207                .filter(|record| {
11208                    record.provider_session.is_some()
11209                        && record.active_session.as_ref() == Some(address)
11210                })
11211                .map(|_| candidate)
11212        });
11213        if let Some(owner_record_id) = materialization_owner_record {
11214            let materialization_id = self
11215                .session_bindings
11216                .lock()
11217                .unwrap_or_else(|poisoned| poisoned.into_inner())
11218                .get(&address.session.instance_id)
11219                .and_then(|binding| binding.materialization_id.clone());
11220            if let Some(materialization_id) = materialization_id {
11221                let mut transfer_failed = false;
11222                {
11223                    let mut materializations = self
11224                        .materializations
11225                        .lock()
11226                        .unwrap_or_else(|poisoned| poisoned.into_inner());
11227                    if let Some(ownership) = materializations.get_mut(&materialization_id) {
11228                        match ownership.owner() {
11229                            MaterializationOwner::Session {
11230                                incarnation_id,
11231                                instance_id,
11232                                generation,
11233                            } if *incarnation_id == self.incarnation_id
11234                                && *instance_id == address.session.instance_id
11235                                && *generation == address.session.generation =>
11236                            {
11237                                transfer_failed = ownership
11238                                    .transfer_to_record(owner_record_id.clone(), unix_time_ms())
11239                                    .is_err();
11240                            }
11241                            MaterializationOwner::Record { record_id }
11242                                if record_id == &owner_record_id => {}
11243                            MaterializationOwner::Record { record_id } => {
11244                                let expected = record_id.clone();
11245                                transfer_failed = ownership
11246                                    .transfer_record_owner(
11247                                        &expected,
11248                                        owner_record_id.clone(),
11249                                        unix_time_ms(),
11250                                    )
11251                                    .is_err();
11252                            }
11253                            _ => transfer_failed = true,
11254                        }
11255                        if transfer_failed {
11256                            let _ = ownership.mark_recovery_required(unix_time_ms());
11257                        }
11258                    } else {
11259                        transfer_failed = true;
11260                    }
11261                }
11262                if transfer_failed {
11263                    if let Some(record) = self
11264                        .session_records
11265                        .lock()
11266                        .unwrap_or_else(|poisoned| poisoned.into_inner())
11267                        .records
11268                        .get_mut(&owner_record_id)
11269                    {
11270                        record.state = ManagedSessionState::Unavailable;
11271                        record.last_error = Some("environment-profile-unavailable".to_owned());
11272                        record.updated_at_unix_ms = unix_time_ms();
11273                        upserts.push(record.clone());
11274                    }
11275                }
11276            }
11277        }
11278        if let Some(new_record_id) = rebind.clone() {
11279            if let Some(binding) = self
11280                .session_bindings
11281                .lock()
11282                .unwrap_or_else(|poisoned| poisoned.into_inner())
11283                .get_mut(&address.session.instance_id)
11284            {
11285                binding.record_id = Some(new_record_id);
11286            }
11287        }
11288        if upserts.is_empty() && removals.is_empty() {
11289            return;
11290        }
11291        if self.persist_state_locked().is_err() {
11292            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
11293            let mut records = self
11294                .session_records
11295                .lock()
11296                .unwrap_or_else(|poisoned| poisoned.into_inner());
11297            records.records = original_records;
11298            *self
11299                .materializations
11300                .lock()
11301                .unwrap_or_else(|poisoned| poisoned.into_inner()) = original_materializations;
11302            if let Some(current) = records.records.get_mut(record_id) {
11303                current.state = ManagedSessionState::Unavailable;
11304                current.last_error = Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned());
11305                current.updated_at_unix_ms = unix_time_ms();
11306                upserts = vec![current.clone()];
11307            } else {
11308                upserts.clear();
11309            }
11310            removals.clear();
11311            drop(records);
11312            if let Some(binding) = self
11313                .session_bindings
11314                .lock()
11315                .unwrap_or_else(|poisoned| poisoned.into_inner())
11316                .get_mut(&address.session.instance_id)
11317            {
11318                binding.record_id = Some(record_id.clone());
11319            }
11320            eprintln!("gate4agent-node: {DURABLE_STATE_COMMIT_FAILED_ERROR}");
11321        }
11322        for record in upserts {
11323            self.publish_record(record);
11324        }
11325        for record_id in removals {
11326            self.publish(NodeEvent::SessionRecordRemoved { record_id });
11327        }
11328    }
11329
11330    fn snapshot(&self) -> NodeSnapshot {
11331        let control = self.handle.snapshot();
11332        let bindings = self
11333            .session_bindings
11334            .lock()
11335            .unwrap_or_else(|poisoned| poisoned.into_inner())
11336            .clone();
11337        let workspace_roots = self
11338            .workspaces
11339            .read()
11340            .unwrap_or_else(|poisoned| poisoned.into_inner())
11341            .clone();
11342        let environment_profiles = self
11343            .environment_profiles
11344            .read()
11345            .unwrap_or_else(|poisoned| poisoned.into_inner());
11346        let mut workspaces = workspace_roots
11347            .iter()
11348            .map(|(workspace_id, canonical_root)| {
11349                (
11350                    workspace_id.clone(),
11351                    WorkspaceSnapshot {
11352                        workspace_id: workspace_id.clone(),
11353                        canonical_root: opaque_windows_path(canonical_root.clone()),
11354                        sessions: Vec::new(),
11355                        worktree_service_mode: Some(
11356                            self.worktree_service_modes
11357                                .get(workspace_id)
11358                                .copied()
11359                                .unwrap_or(WorktreeServiceMode::Manual),
11360                        ),
11361                        managed_worktree_profiles: Some(WorktreeProfileInventory {
11362                            profiles: self
11363                                .managed_worktree_profiles
11364                                .get(workspace_id)
11365                                .into_iter()
11366                                .flat_map(BTreeMap::values)
11367                                .map(|profile| ManagedWorktreeProfileSummary {
11368                                    id: profile.profile_id().clone(),
11369                                    revision: profile.revision().clone(),
11370                                    retention: profile.retention(),
11371                                })
11372                                .collect(),
11373                        }),
11374                    },
11375                )
11376            })
11377            .collect::<BTreeMap<_, _>>();
11378        let mut agent_progress = Vec::new();
11379        for session in &control.sessions {
11380            let Some(binding) = bindings.get(&session.instance_id) else {
11381                continue;
11382            };
11383            if binding.generation != session.generation {
11384                continue;
11385            }
11386            if let Some(workspace) = workspaces.get_mut(&binding.workspace_id) {
11387                let address = SessionAddress {
11388                    workspace_id: binding.workspace_id.clone(),
11389                    session: SessionKey {
11390                        instance_id: session.instance_id,
11391                        generation: session.generation,
11392                    },
11393                };
11394                if let Some(progress) =
11395                    agent_progress_from_provider_snapshot(address, &session.provider)
11396                {
11397                    agent_progress.push(progress);
11398                }
11399                workspace.sessions.push(session.clone());
11400            }
11401        }
11402        NodeSnapshot {
11403            node_id: self.node_id.clone(),
11404            enabled_providers: self.enabled_providers.clone(),
11405            provider_runtime_statuses: {
11406                let updates = self
11407                    .provider_runtime_status_updates
11408                    .read()
11409                    .unwrap_or_else(|poisoned| poisoned.into_inner());
11410                ProviderRuntimeStatuses::new(
11411                    self.provider_runtime_statuses
11412                        .iter()
11413                        .filter(|status| !updates.contains_key(status.provider()))
11414                        .cloned()
11415                        .chain(updates.values().cloned()),
11416                )
11417                .expect("refreshed provider runtime inventory remains bounded and unique")
11418            },
11419            workspaces: workspaces.into_values().collect(),
11420            session_records: self
11421                .session_records
11422                .lock()
11423                .unwrap_or_else(|poisoned| poisoned.into_inner())
11424                .records
11425                .values()
11426                .cloned()
11427                .collect(),
11428            managed_worktrees: self.managed_worktrees
11429                .lock()
11430                .unwrap_or_else(|poisoned| poisoned.into_inner())
11431                .snapshots(),
11432            launch_inventory: Some(LaunchInventory {
11433                spawn_profiles: Some(
11434                    self.spawn_profiles
11435                        .iter()
11436                        .filter_map(|profile| {
11437                            resolved_spawn_profile_summary(profile, &environment_profiles)
11438                        })
11439                        .collect(),
11440                ),
11441                bundles: Some(
11442                    self.bundle_catalog
11443                        .read()
11444                        .unwrap_or_else(|poisoned| poisoned.into_inner())
11445                        .iter()
11446                        .map(NodeBundle::receipt)
11447                        .collect(),
11448                ),
11449                // Opaque station allowlist ids from empty-default catalog /
11450                // optional startup loader. Never secrets. Dig2 probe stays stub.
11451                network_allowlists: {
11452                    let catalog = self
11453                        .network_allowlist_catalog
11454                        .read()
11455                        .unwrap_or_else(|poisoned| poisoned.into_inner());
11456                    if catalog.is_empty() {
11457                        None
11458                    } else {
11459                        Some(catalog.ids().cloned().collect())
11460                    }
11461                },
11462            }),
11463            agent_progress,
11464        }
11465    }
11466
11467    fn publish(&self, event: NodeEvent) -> NodeEventEnvelope {
11468        assert!(
11469            !matches!(
11470                &event,
11471                NodeEvent::TerminalFrame { .. } | NodeEvent::AgentStream { .. }
11472            ),
11473            "terminal frame and agent stream events must use their own dedicated live channel",
11474        );
11475        let mut history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11476        let sequence = history
11477            .last_sequence
11478            .checked_add(1)
11479            .expect("node event sequence exhausted");
11480        if history.events.len() == NODE_EVENT_HISTORY_MAX {
11481            if let Some(evicted) = history.events.pop_front() {
11482                history.replay_floor_sequence = evicted
11483                    .sequence
11484                    .checked_add(1)
11485                    .expect("node replay floor sequence exhausted");
11486                history.removed_record_providers.remove(&evicted.sequence);
11487            }
11488        }
11489        match &event {
11490            NodeEvent::HarnessMcpReadCall { .. } => {
11491                panic!("transient harness MCP calls must not enter durable event history")
11492            }
11493            NodeEvent::SessionRecordUpserted { record } => {
11494                history
11495                    .record_providers
11496                    .insert(record.record_id.clone(), record.provider.clone());
11497            }
11498            NodeEvent::SessionRecordRemoved { record_id } => {
11499                if let Some(provider) = history.record_providers.remove(record_id) {
11500                    history.removed_record_providers.insert(sequence, provider);
11501                }
11502            }
11503            NodeEvent::ManagedWorktreeUpserted { .. }
11504            | NodeEvent::ManagedWorktreeRemoved { .. }
11505            | NodeEvent::Control { .. }
11506            | NodeEvent::ControllerChanged { .. }
11507            | NodeEvent::SessionRecordHistorySummarized { .. }
11508            | NodeEvent::WorkspaceAdded { .. }
11509            | NodeEvent::WorkspaceRemoved { .. }
11510            | NodeEvent::ResyncRequired { .. }
11511            | NodeEvent::AgentStream { .. }
11512            | NodeEvent::TerminalFrame { .. } => {}
11513        }
11514        let envelope = NodeEventEnvelope { sequence, event };
11515        history.events.push_back(envelope.clone());
11516        history.last_sequence = sequence;
11517        let _ = self.event_tx.send(envelope.clone());
11518        drop(history);
11519        envelope
11520    }
11521
11522    /// Publishes a transient (never durable) `NodeEvent` -- today only
11523    /// `NodeEvent::HarnessMcpReadCall` -- onto the dedicated
11524    /// `harness_mcp_proxy_event_tx` broadcast (see that field's own doc for
11525    /// why this must never ride the shared `event_tx` bus). Returns whether
11526    /// the send reached at least one subscriber: `broadcast::Sender::send`
11527    /// fails in exactly one way, no active receiver at that instant, and
11528    /// the caller (`HarnessMcpProxyRegistry::begin_call`) uses a `false`
11529    /// return to fail the pending call immediately rather than leave it to
11530    /// expire silently at `LOCAL_READ_TIMEOUT`/
11531    /// `MAX_HARNESS_MCP_CALL_DEADLINE_MS`.
11532    fn publish_transient(&self, event: NodeEvent) -> bool {
11533        assert!(event.requires_harness_mcp_proxy_capability());
11534        assert!(event.harness_mcp_contract_is_valid_at(unix_time_ms()));
11535        let envelope = NodeEventEnvelope { sequence: 0, event };
11536        match self.harness_mcp_proxy_event_tx.send(Arc::new(vec![envelope])) {
11537            Ok(_) => true,
11538            Err(broadcast::error::SendError(undelivered)) => {
11539                if let Some(NodeEventEnvelope {
11540                    event: NodeEvent::HarnessMcpReadCall { call_id, request, .. },
11541                    ..
11542                }) = undelivered.first() {
11543                    // `request.content_type` only, never a per-tool label --
11544                    // this crate does not decode `request.body` and does not
11545                    // hold `hatchery-harness-api` (Nested Control Plane
11546                    // doctrine, Law 3; see this crate's dependency on
11547                    // `gate4agent-node-protocol`'s own `Forbidden:` line).
11548                    tracing::warn!(
11549                        call_id = call_id.as_str(),
11550                        content_type = ?request.content_type,
11551                        cause = "no-subscribers",
11552                        "harness MCP proxy event could not be published",
11553                    );
11554                }
11555                false
11556            }
11557        }
11558    }
11559
11560    fn publish_terminal_frames(&self) {
11561        let snapshot = self.handle.snapshot();
11562        let bindings = self
11563            .session_bindings
11564            .lock()
11565            .unwrap_or_else(|poisoned| poisoned.into_inner());
11566        let candidates = snapshot
11567            .sessions
11568            .iter()
11569            .filter_map(|session| {
11570                let binding = bindings.get(&session.instance_id)?;
11571                if binding.generation != session.generation {
11572                    return None;
11573                }
11574                let frame = session.terminal_frame.clone()?;
11575                crate::session_signals::feed(
11576                    binding.workspace_id.as_str(),
11577                    session.instance_id.0,
11578                    session.agent_id.as_str(),
11579                    &frame.contents,
11580                );
11581                crate::provider_limits::observe_screen(session.agent_id.as_str(), &frame.contents);
11582                crate::git_watch::provider_hint(binding.workspace_id.as_str(), session.instance_id.0, session.agent_id.as_str());
11583                Some((
11584                    SessionAddress {
11585                        workspace_id: binding.workspace_id.clone(),
11586                        session: SessionKey {
11587                            instance_id: session.instance_id,
11588                            generation: session.generation,
11589                        },
11590                    },
11591                    frame,
11592                ))
11593            })
11594            .collect();
11595        drop(bindings);
11596        self.publish_terminal_frame_candidates(candidates);
11597    }
11598
11599    fn publish_terminal_frame_candidates(
11600        &self,
11601        candidates: Vec<(SessionAddress, TerminalFrame)>,
11602    ) {
11603        let mut watermarks = self
11604            .terminal_frame_watermarks
11605            .lock()
11606            .unwrap_or_else(|poisoned| poisoned.into_inner());
11607        let mut events = Vec::with_capacity(candidates.len());
11608        for (address, frame) in candidates {
11609            let is_first_frame = !watermarks.contains_key(&address.session.instance_id);
11610            let advanced = watermarks
11611                .get(&address.session.instance_id)
11612                .map_or(true, |(current_address, sequence)| {
11613                    current_address != &address || frame.sequence > *sequence
11614                });
11615            if !advanced {
11616                continue;
11617            }
11618            if is_first_frame {
11619                // Do NOT sessionize on first PTY output: a session is a
11620                // provider conversation id, not bytes on the wire. Drafts
11621                // leave the provisional map only when HQ attaches
11622                // (Resize / TerminalBytes). The attach-never-arrived timer
11623                // (PROVISIONAL_SPAWN_GRACE_MS) covers deploy lag; HQ's
11624                // disconnect grace starts only on a real client WS close.
11625                // This is the first PTY output this node has observed for
11626                // this session -- a one-time event per spawn, not per-frame
11627                // chatter (the watermark makes sure of that). A session that
11628                // reaches this line with no controller attached anywhere on
11629                // the node is running unobserved: exactly how processes from
11630                // repeated spawn attempts went orphaned for hours without a
11631                // single line about it.
11632                if self.controller_state().is_none() {
11633                    tracing::warn!(
11634                        node_id = %self.node_id,
11635                        workspace_id = %address.workspace_id,
11636                        session = ?address.session,
11637                        terminal_size = ?frame.size,
11638                        "session producing output with no controller attached",
11639                    );
11640                } else {
11641                    tracing::info!(
11642                        node_id = %self.node_id,
11643                        workspace_id = %address.workspace_id,
11644                        session = ?address.session,
11645                        terminal_size = ?frame.size,
11646                        "session produced its first output",
11647                    );
11648                }
11649            }
11650            watermarks.insert(
11651                address.session.instance_id,
11652                (address.clone(), frame.sequence),
11653            );
11654            if let Some((at, reported)) = self
11655                .pty_input_marks
11656                .lock()
11657                .unwrap_or_else(|poisoned| poisoned.into_inner())
11658                .get_mut(&address.session.instance_id)
11659            {
11660                // First frame after an input: PTY write -> output published.
11661                if !*reported && at.elapsed() < Duration::from_secs(5) {
11662                    *reported = true;
11663                    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");
11664                }
11665            }
11666            events.push(NodeEvent::TerminalFrame { address, frame });
11667        }
11668        drop(watermarks);
11669        if events.is_empty() {
11670            return;
11671        }
11672        let mut history = self
11673            .history
11674            .lock()
11675            .unwrap_or_else(|poisoned| poisoned.into_inner());
11676        let mut envelopes = Vec::with_capacity(events.len());
11677        for event in events {
11678            let sequence = history
11679                .last_sequence
11680                .checked_add(1)
11681                .expect("node event sequence exhausted");
11682            history.last_sequence = sequence;
11683            envelopes.push(NodeEventEnvelope { sequence, event });
11684        }
11685        let _ = self.terminal_event_tx.send(Arc::new(envelopes));
11686        drop(history);
11687    }
11688
11689    /// Publishes one `AgentStreamChunkV1` onto the dedicated agent-stream
11690    /// channel, mirroring `publish_terminal_frame_candidates` above: the
11691    /// envelope's `sequence` is drawn from the same `history.last_sequence`
11692    /// counter every other event kind shares, never a counter of its own --
11693    /// `queue_connection_event`/`queue_connection_event_batch` compare
11694    /// `envelope.sequence` against one `discard_events_through` watermark
11695    /// regardless of which broadcast channel it arrived on, so a
11696    /// per-channel counter would desynchronize that comparison.
11697    fn publish_agent_stream_chunk(&self, address: SessionAddress, chunk: AgentStreamChunkV1) {
11698        let mut history = self
11699            .history
11700            .lock()
11701            .unwrap_or_else(|poisoned| poisoned.into_inner());
11702        let sequence = history
11703            .last_sequence
11704            .checked_add(1)
11705            .expect("node event sequence exhausted");
11706        history.last_sequence = sequence;
11707        drop(history);
11708        // Debug, not info/warn -- this fires on every single chunk, but it
11709        // is exactly the counter the next drop-measurement pass needs: what
11710        // the node actually emitted (`source_sequence`, `kind`) versus what
11711        // a harness subscriber's own forwarder later logs having received
11712        // (`hatchery-harness-service::runtime`'s "forwarding agent stream
11713        // event"), so a gap between the two logs names itself instead of
11714        // needing a new counter added ad hoc.
11715        tracing::debug!(
11716            source_sequence = chunk.source_sequence,
11717            kind = agent_stream_chunk_kind_label(&chunk.kind),
11718            envelope_sequence = sequence,
11719            "publishing agent stream chunk",
11720        );
11721        let envelope = NodeEventEnvelope {
11722            sequence,
11723            event: NodeEvent::AgentStream { address, chunk },
11724        };
11725        let _ = self.agent_stream_event_tx.send(Arc::new(vec![envelope]));
11726    }
11727
11728    fn current_sequence(&self) -> u64 {
11729        self.history
11730            .lock()
11731            .unwrap_or_else(|poisoned| poisoned.into_inner())
11732            .last_sequence
11733    }
11734
11735    fn controller_state(&self) -> Option<ControllerState> {
11736        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11737        let lease = controller.as_ref().copied()?;
11738        let remaining = lease.expires_at.saturating_duration_since(Instant::now());
11739        if remaining.is_zero() {
11740            *controller = None;
11741            return None;
11742        }
11743        Some(ControllerState {
11744            connection_id: lease.connection_id,
11745            lease_remaining_ms: remaining.as_millis().min(u64::MAX as u128) as u64,
11746        })
11747    }
11748
11749    fn acquire_controller(&self, connection_id: u64, role: ClientRole, lease_ms: u64) -> Result<ControllerState, NodeFailure> {
11750        if role != ClientRole::Operator {
11751            tracing::warn!(
11752                node_id = %self.node_id,
11753                connection_id,
11754                "controller lease rejected: observer connections cannot acquire control",
11755            );
11756            return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections cannot acquire control"));
11757        }
11758        let lease_ms = if lease_ms == 0 {
11759            DEFAULT_CONTROLLER_LEASE_MS
11760        } else {
11761            lease_ms.clamp(MIN_CONTROLLER_LEASE_MS, MAX_CONTROLLER_LEASE_MS)
11762        };
11763        let now = Instant::now();
11764        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11765        if let Some(active) = controller.as_ref() {
11766            if active.connection_id != connection_id && active.expires_at > now {
11767                tracing::warn!(
11768                    node_id = %self.node_id,
11769                    connection_id,
11770                    holder_connection_id = active.connection_id,
11771                    "controller lease rejected: another operator holds it",
11772                );
11773                return Err(failure(NodeFailureCode::ControllerBusy, "another operator holds the controller lease"));
11774            }
11775        }
11776        // A reconnect (this connection already holds the lease, renewing
11777        // before expiry) happens on a periodic cadence; logging it at INFO
11778        // every renewal would be exactly the heartbeat chatter this crate
11779        // family's level discipline forbids. A genuine attach -- the
11780        // controller identity actually changing -- is a state change and
11781        // gets INFO; a same-connection renewal gets DEBUG.
11782        let is_new_attach = match controller.as_ref() {
11783            Some(active) => active.connection_id != connection_id,
11784            None => true,
11785        };
11786        *controller = Some(ControllerLease {
11787            connection_id,
11788            expires_at: now + Duration::from_millis(lease_ms),
11789        });
11790        drop(controller);
11791        let state = ControllerState { connection_id, lease_remaining_ms: lease_ms };
11792        if is_new_attach {
11793            tracing::info!(
11794                node_id = %self.node_id,
11795                connection_id,
11796                lease_ms,
11797                "controller lease acquired",
11798            );
11799        } else {
11800            tracing::debug!(
11801                node_id = %self.node_id,
11802                connection_id,
11803                lease_ms,
11804                "controller lease renewed",
11805            );
11806        }
11807        self.publish(NodeEvent::ControllerChanged { controller: Some(state.clone()) });
11808        Ok(state)
11809    }
11810
11811    fn release_controller(&self, connection_id: u64) -> bool {
11812        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11813        let released = controller.as_ref().is_some_and(|active| active.connection_id == connection_id);
11814        if released {
11815            *controller = None;
11816        }
11817        drop(controller);
11818        if released {
11819            tracing::info!(
11820                node_id = %self.node_id,
11821                connection_id,
11822                "controller lease released",
11823            );
11824            self.publish(NodeEvent::ControllerChanged { controller: None });
11825        }
11826        released
11827    }
11828
11829    /// Mark a fresh Spawn as provisional and arm the attach-never-arrived
11830    /// reaper (PROVISIONAL_SPAWN_GRACE_MS from spawn). Removed from this map
11831    /// only when HQ attaches (Resize / TerminalBytes) — that is *not* a
11832    /// provider session; it only cancels the orphan timer. Disconnect/reload
11833    /// draft kills are owned by HQ and arm only on a real client WS close.
11834    /// ACP path of launch options: once the agent advertises its config options, write the
11835    /// pending model / effort through session/set_config_option (session/set_model when the
11836    /// agent has no model option). Gaps are logged, never forced.
11837    fn apply_pending_acp_options(&self, address: &SessionAddress, event: &ControlEvent) {
11838        let ControlEventKind::ProviderEvent { event: ProviderEvent::ConfigOptionsUpdated { options }, .. } = &event.event else {
11839            return;
11840        };
11841        let id = address.session.instance_id;
11842        let Some((model, effort)) = self.pending_acp_options.lock().unwrap_or_else(|p| p.into_inner()).remove(&id) else {
11843            return;
11844        };
11845        let advertised: Vec<(String, Option<String>, Vec<String>)> = options
11846            .iter()
11847            .map(|o| (o.id.clone(), o.category.clone(), o.choices.iter().map(|c| c.value_json.clone()).collect()))
11848            .collect();
11849        let (writes, set_model, effort_gap) =
11850            backend_paths::acp_option_writes(&advertised, model.as_deref(), effort.as_deref());
11851        if set_model {
11852            if let Some(model_id) = model.clone() {
11853                if let Err(e) = self.dispatch(ControlCommand::SetSessionModel { instance_id: id, model_id }) {
11854                    tracing::warn!(instance_id = id.0, message = %e.message, "acp set_model failed");
11855                }
11856            }
11857        }
11858        for (option_id, value_json) in writes {
11859            if let Err(e) = self.dispatch(ControlCommand::SetSessionConfigOption { instance_id: id, option_id, value_json }) {
11860                tracing::warn!(instance_id = id.0, message = %e.message, "acp set_config_option failed");
11861            }
11862        }
11863        if effort_gap {
11864            tracing::warn!(instance_id = id.0, effort = ?effort, "acp agent advertises no matching thought_level option; effort not applied");
11865        }
11866        tracing::info!(instance_id = id.0, model = ?model, effort = ?effort, "launch options applied over acp");
11867    }
11868
11869    /// ACP auto-permit: switch to the provider's no-ask mode once modes are advertised, and
11870    /// answer every session/request_permission with approve.
11871    fn apply_acp_auto_permit(&self, address: &SessionAddress, event: &ControlEvent) {
11872        let id = address.session.instance_id;
11873        let Some(provider) = crate::auto_permit::acp_provider(id.0) else { return };
11874        if matches!(&event.event, ControlEventKind::Exited { .. } | ControlEventKind::Failed { .. } | ControlEventKind::Removed) {
11875            crate::auto_permit::forget_acp(id.0);
11876            return;
11877        }
11878        let ControlEventKind::ProviderEvent { sequence, event: provider_event, .. } = &event.event else { return };
11879        if let ProviderEvent::InteractionRequested { request_id, interaction_kind, options, .. } = provider_event {
11880            tracing::debug!(instance_id = id.0, sequence = *sequence, request_id = ?request_id, kind = ?interaction_kind, options = options.len(), "acp interaction requested");
11881        }
11882        match provider_event {
11883            ProviderEvent::ModeChanged { mode_id, available } => {
11884                if let Some(want) = crate::auto_permit::pick_acp_mode(provider, mode_id, available.iter().map(|m| m.id.as_str())) {
11885                    match self.dispatch(ControlCommand::SetSessionMode { instance_id: id, mode_id: want.clone() }) {
11886                        Ok(_) => tracing::info!(instance_id = id.0, provider, mode = %want, "acp auto-permit mode set"),
11887                        Err(e) => tracing::warn!(instance_id = id.0, provider, message = %e.message, "acp auto-permit set_mode failed"),
11888                    }
11889                }
11890            }
11891            ProviderEvent::InteractionRequested { interaction_kind: gate4agent_types::ProviderInteractionKind::Approval, tool_name, options, .. } if !options.is_empty() => {
11892                match self.dispatch(ControlCommand::ResolveInteraction {
11893                    instance_id: id,
11894                    generation: address.session.generation,
11895                    interaction_id: gate4agent_types::ProviderInteractionId(*sequence),
11896                    response: gate4agent_types::ProviderInteractionResponse::ApproveOnce,
11897                }) {
11898                    Ok(_) => tracing::info!(instance_id = id.0, provider, tool = %tool_name, "acp permission auto-approved"),
11899                    Err(e) => tracing::warn!(instance_id = id.0, provider, message = %e.message, "acp auto-approve failed"),
11900                }
11901            }
11902            // NB: ACP emits SessionEnded at the end of every *turn* (prompt stopReason), so it
11903            // must not drop the auto-permit registration; that happens on process exit below.
11904            _ => {}
11905        }
11906    }
11907
11908    fn mark_spawn_provisional(&self, address: SessionAddress) {
11909        let id = address.session.instance_id;
11910        self.provisional_spawns
11911            .lock()
11912            .unwrap_or_else(|poisoned| poisoned.into_inner())
11913            .insert(id, (address, Instant::now()));
11914        tracing::info!(
11915            node_id = %self.node_id,
11916            instance_id = id.0,
11917            grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
11918            "draft spawn; waiting for HQ view",
11919        );
11920    }
11921
11922    fn sessionize_spawn(&self, instance_id: AgentInstanceId) {
11923        let removed = self
11924            .provisional_spawns
11925            .lock()
11926            .unwrap_or_else(|poisoned| poisoned.into_inner())
11927            .remove(&instance_id);
11928        if removed.is_some() {
11929            tracing::info!(
11930                node_id = %self.node_id,
11931                instance_id = instance_id.0,
11932                "draft spawn claimed by HQ view",
11933            );
11934        }
11935    }
11936
11937    fn take_due_provisional_spawns(&self) -> Vec<SessionAddress> {
11938        let grace = Duration::from_millis(PROVISIONAL_SPAWN_GRACE_MS);
11939        let mut map = self
11940            .provisional_spawns
11941            .lock()
11942            .unwrap_or_else(|poisoned| poisoned.into_inner());
11943        let due: Vec<AgentInstanceId> = map
11944            .iter()
11945            .filter(|(_, (_, at))| at.elapsed() >= grace)
11946            .map(|(id, _)| *id)
11947            .collect();
11948        due.into_iter()
11949            .filter_map(|id| map.remove(&id).map(|(addr, _)| addr))
11950            .collect()
11951    }
11952
11953    async fn reap_provisional_spawn(&self, address: SessionAddress) {
11954        let instance_id = address.session.instance_id;
11955        tracing::warn!(
11956            node_id = %self.node_id,
11957            instance_id = instance_id.0,
11958            workspace = %address.workspace_id,
11959            reason = "attach_never_arrived",
11960            grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
11961            "draft spawn never viewed; stopping PTY",
11962        );
11963        let _ = self.dispatch(ControlCommand::Stop {
11964            instance_id,
11965            force: true,
11966        });
11967        let _ = self.remove_session(&address).await;
11968    }
11969
11970    fn require_controller(&self, connection_id: u64, role: ClientRole) -> Result<(), NodeFailure> {
11971        if role != ClientRole::Operator {
11972            return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections are read-only"));
11973        }
11974        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11975        let now = Instant::now();
11976        match controller.as_ref().copied() {
11977            Some(active) if active.connection_id == connection_id && active.expires_at > now => Ok(()),
11978            Some(active) if active.expires_at <= now => {
11979                *controller = None;
11980                Err(failure(NodeFailureCode::ControllerRequired, "controller lease expired"))
11981            }
11982            _ => Err(failure(NodeFailureCode::ControllerRequired, "controller lease is required")),
11983        }
11984    }
11985
11986    fn validate_address(&self, address: &SessionAddress) -> Result<AgentId, NodeFailure> {
11987        if !self
11988            .workspaces
11989            .read()
11990            .unwrap_or_else(|poisoned| poisoned.into_inner())
11991            .contains_key(&address.workspace_id)
11992        {
11993            return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
11994        }
11995        let binding = self
11996            .session_bindings
11997            .lock()
11998            .unwrap_or_else(|poisoned| poisoned.into_inner())
11999            .get(&address.session.instance_id)
12000            .cloned()
12001            .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
12002        if binding.workspace_id != address.workspace_id {
12003            return Err(failure(
12004                NodeFailureCode::SessionWorkspaceMismatch,
12005                "session belongs to another workspace",
12006            ));
12007        }
12008        if binding.generation != address.session.generation {
12009            return Err(failure(
12010                NodeFailureCode::StaleGeneration,
12011                &format!(
12012                    "stale session generation: expected {}, received {}",
12013                    binding.generation.0,
12014                    address.session.generation.0,
12015                ),
12016            ));
12017        }
12018        let snapshot = self.handle.snapshot();
12019        let Some(current) = snapshot
12020            .sessions
12021            .iter()
12022            .find(|item| item.instance_id == address.session.instance_id)
12023        else {
12024            return Err(failure(NodeFailureCode::UnknownSession, "session instance does not exist"));
12025        };
12026        if current.generation != address.session.generation {
12027            return Err(failure(
12028                NodeFailureCode::StaleGeneration,
12029                &format!(
12030                    "stale session generation: expected {}, received {}",
12031                    current.generation.0,
12032                    address.session.generation.0,
12033                ),
12034            ));
12035        }
12036        Ok(current.agent_id.clone())
12037    }
12038
12039    fn dispatch(&self, command: ControlCommand) -> Result<CommandId, NodeFailure> {
12040        self.dispatch_envelope(self.prepare_command(command))
12041    }
12042
12043    fn prepare_command(&self, command: ControlCommand) -> CommandEnvelope {
12044        let id = self.next_command_id.fetch_add(1, Ordering::AcqRel);
12045        CommandEnvelope {
12046            id: CommandId(id),
12047            command,
12048        }
12049    }
12050
12051    fn dispatch_envelope(&self, envelope: CommandEnvelope) -> Result<CommandId, NodeFailure> {
12052        let command_id = envelope.id;
12053        self.handle
12054            .dispatch(envelope)
12055            .map_err(|error| match error {
12056                PortDispatchError::Full => failure(NodeFailureCode::BackendBusy, "backend command ingress is full"),
12057                PortDispatchError::Disconnected => failure(NodeFailureCode::BackendDisconnected, "backend command ingress is disconnected"),
12058            })?;
12059        Ok(command_id)
12060    }
12061
12062    /// Records the kernel's own reason for asynchronously refusing
12063    /// `command_id` -- see `rejected_command_reasons`'s doc for why a
12064    /// spawn-dispatch waiter otherwise has no way to learn it. Bounded by
12065    /// `REJECTED_COMMAND_REASON_MAX_ENTRIES`: expired entries are swept
12066    /// first, and if the map is still at capacity after that, the single
12067    /// oldest surviving entry is evicted -- `CommandId` is monotonically
12068    /// assigned (`next_command_id.fetch_add`), so the `BTreeMap`'s first key
12069    /// is always the oldest.
12070    fn record_rejected_command(
12071        &self,
12072        command_id: CommandId,
12073        message: String,
12074        unsupported_transport: bool,
12075    ) {
12076        let now = Instant::now();
12077        let mut reasons = self.rejected_command_reasons.lock()
12078            .unwrap_or_else(|poisoned| poisoned.into_inner());
12079        reasons.retain(|_, reason| reason.expires_at > now);
12080        if reasons.len() >= REJECTED_COMMAND_REASON_MAX_ENTRIES {
12081            if let Some(&oldest) = reasons.keys().next() {
12082                reasons.remove(&oldest);
12083            }
12084        }
12085        reasons.insert(
12086            command_id,
12087            RejectedCommandReason {
12088                message,
12089                unsupported_transport,
12090                expires_at: now + Duration::from_millis(REJECTED_COMMAND_REASON_TTL_MS),
12091            },
12092        );
12093    }
12094
12095    /// Removes and returns the kernel's reason for refusing `command_id`,
12096    /// if the drive loop has recorded one and it has not expired -- see
12097    /// `rejected_command_reasons`'s doc. Consumed at most once: a spawn
12098    /// wait loop calls this instead of blindly polling out its whole
12099    /// deadline when the command it is waiting on turns out to have been
12100    /// refused rather than merely slow.
12101    fn take_rejected_command_reason(&self, command_id: CommandId) -> Option<RejectedCommandReason> {
12102        let now = Instant::now();
12103        let mut reasons = self.rejected_command_reasons.lock()
12104            .unwrap_or_else(|poisoned| poisoned.into_inner());
12105        match reasons.remove(&command_id) {
12106            Some(reason) if reason.expires_at > now => Some(reason),
12107            _ => None,
12108        }
12109    }
12110
12111    async fn dispatch_bounded(
12112        &self,
12113        command: ControlCommand,
12114        timeout_duration: Duration,
12115    ) -> Result<CommandId, NodeFailure> {
12116        let deadline = Instant::now() + timeout_duration;
12117        loop {
12118            match self.dispatch(command.clone()) {
12119                Ok(command_id) => return Ok(command_id),
12120                Err(error) if error.code == NodeFailureCode::BackendBusy && Instant::now() < deadline => {
12121                    sleep(Duration::from_millis(2)).await;
12122                }
12123                Err(error) => return Err(error),
12124            }
12125        }
12126    }
12127
12128    fn internal_session_snapshot(
12129        &self,
12130        address: &SessionAddress,
12131    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
12132        self.validate_address(address)?;
12133        self.handle
12134            .snapshot()
12135            .sessions
12136            .clone()
12137            .into_iter()
12138            .find(|session| {
12139                session.instance_id == address.session.instance_id
12140                    && session.generation == address.session.generation
12141            })
12142            .ok_or_else(|| {
12143                failure(
12144                    NodeFailureCode::UnknownSession,
12145                    "session snapshot is unavailable",
12146                )
12147            })
12148    }
12149
12150    /// Confirms `address` names a live session spawned over the ACP
12151    /// transport and returns its current snapshot -- the gate every
12152    /// `acp-control-v1` verb shares. `require_session_runtime_policy`
12153    /// cannot answer this by itself: `ProviderRuntimePolicy` carries no
12154    /// transport bit for `Acp`/`Inline` (see that method's own doc), only
12155    /// the session's own recorded `TransportKind` does, and that lives on
12156    /// the live snapshot, not the binding.
12157    fn require_acp_session(
12158        &self,
12159        address: &SessionAddress,
12160    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
12161        let snapshot = self.internal_session_snapshot(address)?;
12162        if snapshot.transport != TransportKind::Acp {
12163            return Err(failure(
12164                NodeFailureCode::UnsupportedTransport,
12165                "this verb requires a session spawned over the ACP transport",
12166            ));
12167        }
12168        Ok(snapshot)
12169    }
12170
12171    async fn dispatch_history_bounded(
12172        &self,
12173        address: &SessionAddress,
12174        command: ControlCommand,
12175        operation: HistoryOperation,
12176    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
12177        self.validate_address(address)?;
12178        let started_after = self.current_sequence();
12179        let command_id = self
12180            .dispatch_bounded(
12181                command,
12182                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12183            )
12184            .await?;
12185        let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
12186        let mut scan_after = started_after;
12187        let mut accepted_after = None;
12188        loop {
12189            let events = self
12190                .history
12191                .lock()
12192                .unwrap_or_else(|poisoned| poisoned.into_inner())
12193                .events
12194                .iter()
12195                .filter(|envelope| envelope.sequence > scan_after)
12196                .cloned()
12197                .collect::<Vec<_>>();
12198            for envelope in events {
12199                scan_after = scan_after.max(envelope.sequence);
12200                let NodeEvent::Control {
12201                    address: event_address,
12202                    event,
12203                } = envelope.event
12204                else {
12205                    continue;
12206                };
12207                if event_address != *address {
12208                    continue;
12209                }
12210                if event.command_id == Some(command_id) {
12211                    match &event.event {
12212                        ControlEventKind::CommandRejected { .. } => {
12213                            return Err(failure(
12214                                NodeFailureCode::InvalidRequest,
12215                                "history request was rejected",
12216                            ));
12217                        }
12218                        ControlEventKind::HistoryRequested {
12219                            operation: requested,
12220                            ..
12221                        } if requested == &operation => {
12222                            accepted_after = Some(envelope.sequence);
12223                            continue;
12224                        }
12225                        _ => {}
12226                    }
12227                }
12228                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12229                    match (&operation, &event.event) {
12230                        (
12231                            HistoryOperation::Discover { .. },
12232                            ControlEventKind::HistoryDiscovered { .. },
12233                        )
12234                        | (
12235                            HistoryOperation::Load { .. },
12236                            ControlEventKind::HistoryLoaded { .. },
12237                        ) => return self.internal_session_snapshot(address),
12238                        (_, ControlEventKind::HistoryFailed { .. }) => {
12239                            return Err(failure(
12240                                NodeFailureCode::BackendOperationFailed,
12241                                "history operation failed",
12242                            ));
12243                        }
12244                        _ => {}
12245                    }
12246                }
12247            }
12248            if Instant::now() >= deadline {
12249                return Err(failure(
12250                    NodeFailureCode::BackendBusy,
12251                    if accepted_after.is_some() {
12252                        "history operation did not settle before the bounded deadline"
12253                    } else {
12254                        "history operation was not accepted before the bounded deadline"
12255                    },
12256                ));
12257            }
12258            sleep(Duration::from_millis(2)).await;
12259        }
12260    }
12261
12262    async fn discover_history(
12263        &self,
12264        address: &SessionAddress,
12265        limit: u16,
12266    ) -> Result<Vec<HistoryCandidateSummary>, NodeFailure> {
12267        let query = HistoryQuery {
12268            working_directory: Some(self.workspace_root(&address.workspace_id)?),
12269            limit,
12270        };
12271        query
12272            .validate()
12273            .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history query"))?;
12274        let snapshot = self
12275            .dispatch_history_bounded(
12276                address,
12277                ControlCommand::DiscoverHistory {
12278                    instance_id: address.session.instance_id,
12279                    query: query.clone(),
12280                },
12281                HistoryOperation::Discover { query },
12282            )
12283            .await?;
12284        if snapshot.history.pending.is_some() || snapshot.history.loaded.is_some() {
12285            return Err(failure(
12286                NodeFailureCode::BackendOperationFailed,
12287                "history discovery settled into an invalid state",
12288            ));
12289        }
12290        Ok(snapshot.history.candidates)
12291    }
12292
12293    async fn load_history(
12294        &self,
12295        address: &SessionAddress,
12296        candidate_id: String,
12297    ) -> Result<HistorySessionRecord, NodeFailure> {
12298        validate_candidate_id(&candidate_id)
12299            .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history candidate"))?;
12300        let snapshot = self
12301            .dispatch_history_bounded(
12302                address,
12303                ControlCommand::LoadHistory {
12304                    instance_id: address.session.instance_id,
12305                    candidate_id: candidate_id.clone(),
12306                },
12307                HistoryOperation::Load {
12308                    candidate_id: candidate_id.clone(),
12309                },
12310            )
12311            .await?;
12312        if snapshot.history.pending.is_some()
12313            || snapshot.history.loaded_candidate_id.as_ref() != Some(&candidate_id)
12314        {
12315            return Err(failure(
12316                NodeFailureCode::BackendOperationFailed,
12317                "history load settled into an invalid state",
12318            ));
12319        }
12320        snapshot.history.loaded.ok_or_else(|| {
12321            failure(
12322                NodeFailureCode::BackendOperationFailed,
12323                "history load produced no bounded session",
12324            )
12325        })
12326    }
12327
12328    /// `allow_clean_detachment`: `false` for the on-demand
12329    /// `NodeRequest::ExportContextPackForSessionRecord` API path (the
12330    /// managed record must stay untouched, still live, for the whole call);
12331    /// `true` only for the reactive auto-export-at-exit path (`§2.1`), which
12332    /// reacts to the very clean-exit event that detaches the record out from
12333    /// under this same call — see `session_record_export_target_matches`.
12334    async fn export_context_pack_for_session_record(
12335        &self,
12336        record_id: &SessionRecordId,
12337        session: &SessionAddress,
12338        allow_clean_detachment: bool,
12339    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12340        // C2 replaces a relayed failure's prose with a canned per-code
12341        // string (`C2NodeFailure`'s own `From`), so this path's dozen
12342        // distinct refusals are indistinguishable to anyone downstream --
12343        // and a refusal here expires a continuation, which keeps no field
12344        // for why. The prose stays on the node, where it was written.
12345        let outcome = self
12346            .export_context_pack_for_session_record_inner(record_id, session, allow_clean_detachment)
12347            .await;
12348        if let Err(failure) = &outcome {
12349            tracing::warn!(
12350                record = %record_id,
12351                code = ?failure.code,
12352                reason = %failure.message,
12353                "session-record context-pack export refused",
12354            );
12355        }
12356        outcome
12357    }
12358
12359    async fn export_context_pack_for_session_record_inner(
12360        &self,
12361        record_id: &SessionRecordId,
12362        session: &SessionAddress,
12363        allow_clean_detachment: bool,
12364    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12365        // Several steps here refuse with the same BackendOperationFailed
12366        // code and no prose of their own, so which step failed is
12367        // otherwise unrecoverable -- and a refusal here expires a
12368        // continuation.
12369        let step = |name: &'static str| move |failure: NodeFailure| {
12370            tracing::warn!(step = name, code = ?failure.code, "context-pack export step refused");
12371            failure
12372        };
12373        let record = self.record(record_id).map_err(step("record-lookup"))?;
12374        // `provider_session` is `Some` only for a provider whose resolved
12375        // runtime policy admits live identity verification (raw PTY plus a
12376        // semantic PTY adapter -- `provider_runtime::policy_from_capability_flags`).
12377        // The PTY-sidecar-observation shape (no fleet provider today, but
12378        // capability-keyed rather than vendor-locked) hardcodes
12379        // `provider_session_identity: false` in `provider_runtime::evaluate`,
12380        // so its records go straight to `Live` with `provider_session: None`
12381        // and stay that way for the record's entire life -- that is this
12382        // shape's permanent resting state, not a race with observation, and
12383        // `discover_history`/`load_history` below do not need an identity to
12384        // do their job for it. `IdentityPending` is the actual race (an
12385        // identity-admitted provider whose observation has not landed yet):
12386        // it keeps refusing here rather than falling back, because that
12387        // provider's own history the fallback would otherwise trust is not
12388        // guaranteed to be exact without the identity it is still waiting on.
12389        let identity = match record.provider_session.clone() {
12390            Some(identity) => {
12391                if identity.key != ProviderSessionKey::SessionId {
12392                    return Err(step("provider-identity-key")(failure(
12393                        NodeFailureCode::BackendOperationFailed,
12394                        "managed session provider identity cannot bind history exactly",
12395                    )));
12396                }
12397                Some(identity)
12398            }
12399            None if record.state == ManagedSessionState::IdentityPending => {
12400                return Err(step("provider-session-identity")(failure(
12401                    NodeFailureCode::BackendOperationFailed,
12402                    "managed session has not yet observed its provider session identity",
12403                )));
12404            }
12405            None => None,
12406        };
12407        self.revalidate_session_record_context_export(
12408            &record,
12409            identity.as_ref(),
12410            session,
12411            allow_clean_detachment,
12412        ).map_err(step("revalidate-before-discovery"))?;
12413        let candidates = self
12414            .discover_history(session, HISTORY_DISCOVERY_LIMIT_MAX)
12415            .await
12416            .map_err(step("discover-history"))?;
12417        // With no verified identity to filter by, the discovered set must
12418        // still resolve to exactly one candidate: the same ambiguity refusal
12419        // an identity filter would otherwise produce is the only safety
12420        // property left available for this shape.
12421        let candidate_id = select_history_candidate(
12422            &candidates,
12423            identity.as_ref().map(|identity| identity.id.as_str()),
12424        ).map_err(step("unique-candidate"))?;
12425        let loaded = self.load_history(session, candidate_id.clone()).await
12426            .map_err(step("load-history"))?;
12427        if let Some(identity) = identity.as_ref() {
12428            if loaded.session_id != identity.id {
12429                return Err(failure(
12430                    NodeFailureCode::SessionRecordConflict,
12431                    "loaded history identity does not match the managed session record",
12432                ));
12433            }
12434        }
12435        self.revalidate_session_record_context_export(
12436            &record,
12437            identity.as_ref(),
12438            session,
12439            allow_clean_detachment,
12440        )?;
12441        self.revalidate_loaded_history(session, &candidate_id, &loaded)
12442            .map_err(step("revalidate-loaded-history"))?;
12443        let pack = self.materialize_context_pack(session).await
12444            .map_err(step("materialize-context-pack"))?;
12445        self.commit_context_pack_for_session_record(
12446            &record,
12447            identity.as_ref(),
12448            session,
12449            &candidate_id,
12450            &loaded,
12451            pack,
12452            allow_clean_detachment,
12453        )
12454    }
12455
12456    async fn export_context_pack(
12457        &self,
12458        address: &SessionAddress,
12459    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12460        let pack = self.materialize_context_pack(address).await?;
12461        self.commit_context_pack(pack)
12462    }
12463
12464    async fn materialize_context_pack(
12465        &self,
12466        address: &SessionAddress,
12467    ) -> Result<NodeContextPack, NodeFailure> {
12468        let snapshot = self.internal_session_snapshot(address)?;
12469        if !context_pack_source_status_is_usable(&snapshot.status) {
12470            return Err(failure(
12471                NodeFailureCode::InvalidRequest,
12472                "context export requires a running or successfully exited source session",
12473            ));
12474        }
12475        if snapshot.history.pending.is_some()
12476            || snapshot.history.loaded_candidate_id.is_none()
12477        {
12478            return Err(failure(
12479                NodeFailureCode::InvalidRequest,
12480                "context export requires settled loaded history",
12481            ));
12482        }
12483        let loaded = snapshot.history.loaded.clone().ok_or_else(|| {
12484            failure(
12485                NodeFailureCode::InvalidRequest,
12486                "context export requires settled loaded history",
12487            )
12488        })?;
12489        let source_provider = snapshot.agent_id.clone();
12490        let loaded_candidate_id = snapshot.history.loaded_candidate_id.clone();
12491        let repository = self
12492            .context_pack_repository(&address.workspace_id)
12493            .await?;
12494        let current = self.internal_session_snapshot(address)?;
12495        if !context_pack_source_status_is_usable(&current.status)
12496            || current.agent_id != source_provider
12497            || current.history.pending.is_some()
12498            || current.history.loaded_candidate_id != loaded_candidate_id
12499            || current.history.loaded.as_ref() != Some(&loaded)
12500        {
12501            return Err(failure(
12502                NodeFailureCode::ContextPackMaterializationFailed,
12503                "context source changed during bounded repository capture",
12504            ));
12505        }
12506        NodeContextPack::export_with_repository(
12507            ContextPackLineageReceipt {
12508                source_node_id: self.node_id.clone(),
12509                source_session: address.clone(),
12510                source_provider,
12511            },
12512            &loaded,
12513            Some(repository),
12514        )
12515        .map_err(|_| {
12516            failure(
12517                NodeFailureCode::ContextPackMaterializationFailed,
12518                "bounded context pack export failed",
12519            )
12520        })
12521    }
12522
12523    /// Write-through to the durable [`ContextPackStore`], if one is
12524    /// configured (`state_path` is `Some`). A node with no durable state
12525    /// path — fixtures, tests, ephemeral runs — has no `ContextPackStore`
12526    /// open at all and this is a no-op, matching `persist_state`'s own
12527    /// "durability optional, degrade gracefully when unconfigured" idiom.
12528    fn commit_context_pack_durably(&self, pack: &NodeContextPack) -> Result<(), NodeFailure> {
12529        let mut store = self
12530            .context_pack_store
12531            .lock()
12532            .unwrap_or_else(|poisoned| poisoned.into_inner());
12533        let Some(store) = store.as_mut() else {
12534            return Ok(());
12535        };
12536        store.commit(pack).map_err(|_| {
12537            failure(
12538                NodeFailureCode::ContextPackMaterializationFailed,
12539                "durable context pack commit failed",
12540            )
12541        })
12542    }
12543
12544    fn commit_context_pack(
12545        &self,
12546        pack: NodeContextPack,
12547    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12548        self.commit_context_pack_durably(&pack)?;
12549        self.context_catalog
12550            .write()
12551            .unwrap_or_else(|poisoned| poisoned.into_inner())
12552            .insert(pack)
12553            .map_err(|_| {
12554                failure(
12555                    NodeFailureCode::ContextPackMaterializationFailed,
12556                    "context pack catalog is full",
12557                )
12558            })
12559    }
12560
12561    fn commit_context_pack_for_session_record(
12562        &self,
12563        expected: &ManagedSessionRecord,
12564        identity: Option<&ProviderSessionIdentity>,
12565        session: &SessionAddress,
12566        candidate_id: &str,
12567        loaded: &HistorySessionRecord,
12568        pack: NodeContextPack,
12569        allow_clean_detachment: bool,
12570    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12571        let current_root = self.workspace_root(&expected.workspace_id)?;
12572        let _transaction = self
12573            .state_transaction
12574            .lock()
12575            .unwrap_or_else(|poisoned| poisoned.into_inner());
12576        let records = self
12577            .session_records
12578            .lock()
12579            .unwrap_or_else(|poisoned| poisoned.into_inner());
12580        let current = records.records.get(&expected.record_id).ok_or_else(|| {
12581            failure(
12582                NodeFailureCode::SessionRecordConflict,
12583                "managed session record disappeared during context export",
12584            )
12585        })?;
12586        if !session_record_export_target_matches(expected, current, session, allow_clean_detachment) {
12587            return Err(failure(
12588                NodeFailureCode::SessionRecordConflict,
12589                "managed session record changed during context export",
12590            ));
12591        }
12592        let bindings = self
12593            .session_bindings
12594            .lock()
12595            .unwrap_or_else(|poisoned| poisoned.into_inner());
12596        let binding = bindings.get(&session.session.instance_id).ok_or_else(|| {
12597            failure(
12598                NodeFailureCode::SessionRecordConflict,
12599                "managed session runtime binding disappeared during context export",
12600            )
12601        })?;
12602        let runtime = self
12603            .handle
12604            .snapshot()
12605            .sessions
12606            .iter()
12607            .find(|snapshot| {
12608                snapshot.instance_id == session.session.instance_id
12609                    && snapshot.generation == session.session.generation
12610            })
12611            .cloned()
12612            .ok_or_else(|| {
12613                failure(
12614                    NodeFailureCode::SessionRecordConflict,
12615                    "managed session runtime disappeared during context export",
12616                )
12617            })?;
12618        let receipt = pack.receipt();
12619        if !session_record_context_export_binding_is_exact(
12620            expected,
12621            current,
12622            identity,
12623            session,
12624            binding,
12625            &runtime.agent_id,
12626            &current_root,
12627            allow_clean_detachment,
12628        ) || !session_record_context_export_source_is_usable(
12629            current,
12630            &runtime.status,
12631            allow_clean_detachment,
12632        ) || runtime.history.pending.is_some()
12633            || runtime.history.loaded_candidate_id.as_deref() != Some(candidate_id)
12634            || runtime.history.loaded.as_ref() != Some(loaded)
12635            || receipt.lineage.source_node_id != self.node_id
12636            || receipt.lineage.source_session != *session
12637            || receipt.lineage.source_provider != current.provider
12638            || receipt.source_message_count != loaded.message_count
12639        {
12640            return Err(failure(
12641                NodeFailureCode::SessionRecordConflict,
12642                "managed session source changed before context catalog commit",
12643            ));
12644        }
12645        self.commit_context_pack_durably(&pack)?;
12646        self.context_catalog
12647            .write()
12648            .unwrap_or_else(|poisoned| poisoned.into_inner())
12649            .insert(pack)
12650            .map_err(|_| {
12651                failure(
12652                    NodeFailureCode::ContextPackMaterializationFailed,
12653                    "context pack catalog is full",
12654                )
12655            })
12656    }
12657
12658    async fn context_pack_repository(
12659        &self,
12660        workspace_id: &WorkspaceId,
12661    ) -> Result<ContextPackRepository, NodeFailure> {
12662        let first = self
12663            .context_pack_repository_observation(workspace_id)
12664            .await?;
12665        let second = self
12666            .context_pack_repository_observation(workspace_id)
12667            .await?;
12668        stable_context_pack_repository(first, second)
12669    }
12670
12671    async fn context_pack_repository_observation(
12672        &self,
12673        workspace_id: &WorkspaceId,
12674    ) -> Result<ContextPackRepositoryObservation, NodeFailure> {
12675        let git = self.inspect_workspace(workspace_id.clone()).await?.git;
12676        let head = if git.is_repository {
12677            let root = self.workspace_root(workspace_id)?;
12678            match resolve_base_commit_with_timeout(
12679                &root,
12680                "HEAD",
12681                GIT_COMMAND_TIMEOUT_MS,
12682            )
12683            .await
12684            {
12685                Ok(commit) => ContextPackRepositoryHead::Commit(commit),
12686                Err(error) if error.kind == GitWorktreeErrorKind::Conflict => {
12687                    ContextPackRepositoryHead::Unborn
12688                }
12689                Err(_) => {
12690                    return Err(failure(
12691                        NodeFailureCode::ContextPackMaterializationFailed,
12692                        "context pack HEAD observation failed",
12693                    ));
12694                }
12695            }
12696        } else {
12697            ContextPackRepositoryHead::NotRepository
12698        };
12699        let mut files = Vec::with_capacity(CONTEXT_PACK_SELECTED_FILES.len());
12700        for selected_path in CONTEXT_PACK_SELECTED_FILES {
12701            let path = RepositoryPath::utf8(selected_path.to_owned()).map_err(|_| {
12702                failure(
12703                    NodeFailureCode::InvalidRepositoryPath,
12704                    "context pack selected file path is invalid",
12705                )
12706            })?;
12707            match self.read_workspace_file(workspace_id.clone(), path).await {
12708                Ok(WorkspaceFileRead {
12709                    content: WorkspaceFileContent::Utf8 { text, byte_len },
12710                    ..
12711                }) => files.push(ContextPackRepositoryFileSource::utf8(
12712                    selected_path,
12713                    text,
12714                    byte_len,
12715                )),
12716                Ok(WorkspaceFileRead {
12717                    content: WorkspaceFileContent::NonUtf8 { byte_len },
12718                    ..
12719                }) => files.push(ContextPackRepositoryFileSource::skipped(
12720                    selected_path,
12721                    ContextPackSelectedFileSkipReason::NonUtf8,
12722                    Some(byte_len),
12723                )),
12724                Ok(WorkspaceFileRead {
12725                    content: WorkspaceFileContent::TooLarge { .. },
12726                    ..
12727                }) => files.push(ContextPackRepositoryFileSource::skipped(
12728                    selected_path,
12729                    ContextPackSelectedFileSkipReason::TooLarge,
12730                    None,
12731                )),
12732                Err(error) => {
12733                    let reason = if matches!(
12734                        error.code,
12735                        NodeFailureCode::InvalidRepositoryPath
12736                            | NodeFailureCode::RepositoryFileNotRegular
12737                            | NodeFailureCode::RepositoryPathUnsafe
12738                    ) {
12739                        ContextPackSelectedFileSkipReason::Unsafe
12740                    } else {
12741                        ContextPackSelectedFileSkipReason::Unavailable
12742                    };
12743                    files.push(ContextPackRepositoryFileSource::skipped(
12744                        selected_path,
12745                        reason,
12746                        None,
12747                    ));
12748                }
12749            }
12750        }
12751        Ok(ContextPackRepositoryObservation { head, git, files })
12752    }
12753
12754    fn forget_context_pack(&self, context_id: &SpawnContextId) -> Result<(), NodeFailure> {
12755        let referenced_by_binding = self
12756            .session_bindings
12757            .lock()
12758            .unwrap_or_else(|poisoned| poisoned.into_inner())
12759            .values()
12760            .any(|binding| {
12761                binding.context.as_ref().map(|receipt| &receipt.id) == Some(context_id)
12762            });
12763        let referenced_by_record = self
12764            .session_records
12765            .lock()
12766            .unwrap_or_else(|poisoned| poisoned.into_inner())
12767            .records
12768            .values()
12769            .any(|record| record.context_id.as_ref() == Some(context_id));
12770        let referenced_by_materialization = self
12771            .materializations
12772            .lock()
12773            .unwrap_or_else(|poisoned| poisoned.into_inner())
12774            .values()
12775            .any(|ownership| {
12776                ownership.context().map(|receipt| &receipt.id) == Some(context_id)
12777            });
12778        if referenced_by_binding
12779            || referenced_by_record
12780            || referenced_by_materialization
12781        {
12782            return Err(failure(
12783                NodeFailureCode::ContextPackBusy,
12784                "context pack is still referenced",
12785            ));
12786        }
12787        let mut catalog = self.context_catalog
12788            .write()
12789            .unwrap_or_else(|poisoned| poisoned.into_inner());
12790        if catalog.get(context_id).is_none() {
12791            return Err(failure(
12792                NodeFailureCode::UnknownContextPack,
12793                "context pack is unavailable",
12794            ));
12795        }
12796        self.spawn_idempotency
12797            .lock()
12798            .unwrap_or_else(|poisoned| poisoned.into_inner())
12799            .entries
12800            .retain(|_, entry| !entry.value.references_context(context_id));
12801        let removed = catalog.remove(context_id);
12802        debug_assert!(removed.is_some(), "validated context pack remains present");
12803        Ok(())
12804    }
12805
12806    async fn dispatch_input_bounded(
12807        &self,
12808        address: &SessionAddress,
12809        action: InputAction,
12810    ) -> Result<(), NodeFailure> {
12811        let started_after = self.current_sequence();
12812        self.pty_input_marks
12813            .lock()
12814            .unwrap_or_else(|poisoned| poisoned.into_inner())
12815            .insert(address.session.instance_id, (Instant::now(), false));
12816        self.pty_input_wake.notify_one();
12817        let command_id = self
12818            .dispatch_bounded(
12819                ControlCommand::SendInput {
12820                    instance_id: address.session.instance_id,
12821                    action,
12822                },
12823                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12824            )
12825            .await?;
12826        // A semantic prompt may spend the provider's full readiness budget before
12827        // the runtime emits InputCompleted. Keep the node request deadline bounded,
12828        // but strictly beyond every readiness policy in this node's catalog.
12829        let deadline = Instant::now() + Duration::from_millis(self.input_settle_timeout_ms);
12830        let mut scan_after = started_after;
12831        let mut accepted_after = None;
12832        loop {
12833            let events = self
12834                .history
12835                .lock()
12836                .unwrap_or_else(|poisoned| poisoned.into_inner())
12837                .events
12838                .iter()
12839                .filter(|envelope| envelope.sequence > scan_after)
12840                .cloned()
12841                .collect::<Vec<_>>();
12842            for envelope in events {
12843                scan_after = scan_after.max(envelope.sequence);
12844                let NodeEvent::Control {
12845                    address: event_address,
12846                    event,
12847                } = envelope.event
12848                else {
12849                    continue;
12850                };
12851                if event_address != *address {
12852                    continue;
12853                }
12854                if event.command_id == Some(command_id) {
12855                    match &event.event {
12856                        ControlEventKind::CommandRejected { message } => {
12857                            return Err(failure(NodeFailureCode::InvalidRequest, message));
12858                        }
12859                        ControlEventKind::InputRequested { .. } => {
12860                            accepted_after = Some(envelope.sequence);
12861                            continue;
12862                        }
12863                        _ => {}
12864                    }
12865                }
12866                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12867                    match &event.event {
12868                        ControlEventKind::InputCompleted { .. } => return Ok(()),
12869                        ControlEventKind::InputFailed { message, .. } => {
12870                            return Err(failure(
12871                                NodeFailureCode::BackendOperationFailed,
12872                                message,
12873                            ));
12874                        }
12875                        _ => {}
12876                    }
12877                }
12878            }
12879            if Instant::now() >= deadline {
12880                return Err(failure(
12881                    NodeFailureCode::BackendBusy,
12882                    if accepted_after.is_some() {
12883                        "input was accepted but did not settle before the bounded deadline"
12884                    } else {
12885                        "input was not accepted before the bounded deadline"
12886                    },
12887                ));
12888            }
12889            sleep(Duration::from_millis(2)).await;
12890        }
12891    }
12892
12893    async fn dispatch_resize_bounded(
12894        &self,
12895        address: &SessionAddress,
12896        size: gate4agent_types::TerminalSize,
12897    ) -> Result<(), NodeFailure> {
12898        let started_after = self.current_sequence();
12899        let command_id = self
12900            .dispatch_bounded(
12901                ControlCommand::Resize {
12902                    instance_id: address.session.instance_id,
12903                    size,
12904                },
12905                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12906            )
12907            .await?;
12908        let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
12909        let mut scan_after = started_after;
12910        let mut accepted_after = None;
12911        loop {
12912            let events = self
12913                .history
12914                .lock()
12915                .unwrap_or_else(|poisoned| poisoned.into_inner())
12916                .events
12917                .iter()
12918                .filter(|envelope| envelope.sequence > scan_after)
12919                .cloned()
12920                .collect::<Vec<_>>();
12921            for envelope in events {
12922                scan_after = scan_after.max(envelope.sequence);
12923                let NodeEvent::Control {
12924                    address: event_address,
12925                    event,
12926                } = envelope.event
12927                else {
12928                    continue;
12929                };
12930                if event_address != *address {
12931                    continue;
12932                }
12933                if event.command_id == Some(command_id) {
12934                    match &event.event {
12935                        ControlEventKind::CommandRejected { message } => {
12936                            return Err(failure(NodeFailureCode::InvalidRequest, message));
12937                        }
12938                        ControlEventKind::ResizeRequested {
12939                            size: requested_size,
12940                            ..
12941                        } if *requested_size == size => {
12942                            accepted_after = Some(envelope.sequence);
12943                            continue;
12944                        }
12945                        _ => {}
12946                    }
12947                }
12948                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12949                    match &event.event {
12950                        ControlEventKind::Resized { size: settled_size }
12951                            if *settled_size == size =>
12952                        {
12953                            return Ok(());
12954                        }
12955                        ControlEventKind::ResizeFailed { message } => {
12956                            return Err(failure(
12957                                NodeFailureCode::BackendOperationFailed,
12958                                message,
12959                            ));
12960                        }
12961                        _ => {}
12962                    }
12963                }
12964            }
12965            if Instant::now() >= deadline {
12966                return Err(failure(
12967                    NodeFailureCode::BackendBusy,
12968                    if accepted_after.is_some() {
12969                        "resize was accepted but did not settle before the bounded deadline"
12970                    } else {
12971                        "resize was not accepted before the bounded deadline"
12972                    },
12973                ));
12974            }
12975            sleep(Duration::from_millis(2)).await;
12976        }
12977    }
12978
12979    async fn spawn_session(
12980        &self,
12981        workspace_id: WorkspaceId,
12982        provider: AgentId,
12983        mode: SessionMode,
12984        terminal_size: gate4agent_types::TerminalSize,
12985        initial_prompt: Option<String>,
12986    ) -> Result<SessionAddress, NodeFailure> {
12987        if self.managed_worktrees.lock()
12988            .unwrap_or_else(|poisoned| poisoned.into_inner())
12989            .lease_for_workspace(&workspace_id)
12990            .is_some()
12991        {
12992            return Err(failure(
12993                NodeFailureCode::ManagedWorktreeOwnershipConflict,
12994                "managed worktree targets may only be spawned through their lease request",
12995            ));
12996        }
12997        self
12998            .spawn_session_with_deadline(
12999                workspace_id,
13000                provider.clone(),
13001                mode,
13002                terminal_size,
13003                initial_prompt,
13004                None,
13005                None,
13006                None,
13007                None,
13008                None,
13009                SpawnRecordPolicy::ProviderIdentityOnly,
13010                None,
13011                &[],
13012                None,
13013                // `NodeRequest::Spawn` (this method's only caller) carries no
13014                // approval-level selection of its own -- see
13015                // `HarnessOperatorRequestV1::SpawnSession`'s own field for
13016                // the one operator-wire path that can choose something other
13017                // than the axis default. Auto-permit may lower it where the agent
13018                // cannot offer its full-auto mode (claude ACP under root).
13019                crate::auto_permit::spawn_approval_level(provider.as_str(), mode).unwrap_or_default(),
13020                None,
13021                None,
13022            )
13023            .await
13024            .map(|(session, _runtime_policy)| session)
13025    }
13026
13027    async fn spawn_session_with_deadline(
13028        &self,
13029        workspace_id: WorkspaceId,
13030        provider: AgentId,
13031        mode: SessionMode,
13032        terminal_size: gate4agent_types::TerminalSize,
13033        initial_prompt: Option<String>,
13034        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
13035        bundle: Option<ResolvedBundleReceipt>,
13036        context: Option<ResolvedContextPackReceipt>,
13037        managed_authority: Option<ManagedWorktreeLeaseId>,
13038        admitted_runtime_policy: Option<ProviderRuntimePolicy>,
13039        record_policy: SpawnRecordPolicy,
13040        deadline: Option<Instant>,
13041        required_capabilities: &[ProviderRuntimeCapability],
13042        harness_mcp: Option<&PreparedHarnessMcpSpawn>,
13043        approval_level: ApprovalLevel,
13044        network_allowlist: Option<crate::protocol::SpawnNetworkAllowlistId>,
13045        // Opaque dig2browser station profile id (ids only). Feature
13046        // `dig2-station-probe`: after probe at resolve, spawn takes an
13047        // exclusive node-local lease; cleared on session end. Never
13048        // ImportSession / cookies on C2. Feature off: ignored (stub echo
13049        // already happened on the receipt path).
13050        browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
13051    ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
13052        let reserved_lease = self.managed_worktrees.lock()
13053            .unwrap_or_else(|poisoned| poisoned.into_inner())
13054            .lease_for_workspace(&workspace_id);
13055        match (reserved_lease.as_ref(), managed_authority.as_ref()) {
13056            (Some(expected), Some(provided)) if expected == provided => {}
13057            (None, None) => {}
13058            _ => {
13059                return Err(failure(
13060                    NodeFailureCode::ManagedWorktreeOwnershipConflict,
13061                    "spawn lacks exact managed worktree lease authority",
13062                ));
13063            }
13064        }
13065        // Refuse concurrent distinct environment profiles (incl. ProviderHome
13066        // bindings) on the same workspace cwd. Parallel path: managed worktree
13067        // spawn so each profile gets its own workspace_id.
13068        self.ensure_workspace_environment_profile_compatible(
13069            &workspace_id,
13070            environment_profile.as_ref(),
13071        )?;
13072        let runtime_requirement = match (mode, initial_prompt.is_some()) {
13073            (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
13074            (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
13075            (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
13076            (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
13077        };
13078        // This session's own transport -- computed once, here, from `mode`,
13079        // and reused both to admit spawn (below) and, further down, to
13080        // compute the final `runtime_policy` this session actually operates
13081        // under (`provider_runtime::policy_for_transport`). Registration
13082        // reuses this exact value rather than recomputing it a second time.
13083        let transport = match mode {
13084            SessionMode::Pty => TransportKind::Pty,
13085            SessionMode::Inline => TransportKind::Pipe,
13086            SessionMode::Acp => TransportKind::Acp,
13087        };
13088        let pipe_transport = self.provider_supports_pipe_transport(&provider);
13089        let acp_transport = self.provider_supports_acp_transport(&provider);
13090        let runtime_policy = if let Some(runtime_policy) = admitted_runtime_policy {
13091            require_policy(runtime_policy, pipe_transport, acp_transport, runtime_requirement).map_err(|_| failure(
13092                NodeFailureCode::UnsupportedSpawnCapability,
13093                "pre-admitted provider runtime no longer satisfies spawn requirements",
13094            ))?;
13095            runtime_policy
13096        } else if let Some(deadline) = deadline {
13097            let remaining = spawn_deadline_remaining(deadline)?;
13098            timeout(
13099                remaining,
13100                self.admit_provider_runtime(&provider, runtime_requirement),
13101            )
13102            .await
13103            .map_err(|_| {
13104                failure(
13105                    NodeFailureCode::SpawnDeadlineExceeded,
13106                    "provider admission exceeded the spawn deadline",
13107                )
13108            })??
13109        } else {
13110            self.admit_provider_runtime(&provider, runtime_requirement).await?
13111        };
13112        let runtime_policy = self.effective_spawn_runtime_policy(runtime_policy);
13113        // The single point where this session's `runtime_policy` becomes
13114        // transport-correct: `admit_provider_runtime` (and the pre-admitted
13115        // branch above) only ever compute a PTY-terminal-verification-shaped
13116        // value, sound outright only for `TransportKind::Pty` (Pipe still
13117        // relies on that same value downstream -- see `policy_for_transport`'s
13118        // own doc for why). `policy_for_transport` is the explicit,
13119        // exhaustive match Item 1 asks for -- this is the ONE place its
13120        // output becomes the session's `runtime_policy`, so every later
13121        // reader (`ControlCommand::Start`, `session_bindings`, and every
13122        // engine gate that reads `state.runtime_policy`) sees the same,
13123        // correctly-computed value.
13124        let runtime_policy =
13125            crate::provider_runtime::policy_for_transport(transport, runtime_policy);
13126        if required_capabilities
13127            .iter()
13128            .any(|capability| !runtime_policy.admits(*capability))
13129        {
13130            return Err(failure(
13131                NodeFailureCode::UnsupportedSpawnCapability,
13132                "provider runtime does not admit a required spawn capability",
13133            ));
13134        }
13135        if let Some(deadline) = deadline {
13136            spawn_deadline_remaining(deadline)?;
13137        }
13138        self.ensure_binding_capacity()?;
13139        let mut working_directory = self.workspace_root(&workspace_id)?;
13140        let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
13141        let session = SessionKey {
13142            instance_id,
13143            generation: SessionGeneration(1),
13144        };
13145        let address = SessionAddress {
13146            workspace_id: workspace_id.clone(),
13147            session,
13148        };
13149        // This is the request-accepted checkpoint: admission (managed
13150        // worktree authority, runtime capability, dispatch deadline) already
13151        // passed and a concrete session identity is minted. It is a distinct
13152        // state from the process actually running (`ControlEventKind::Running`,
13153        // logged in `publish_control`) or the terminal outcome (`Exited`/
13154        // `Failed`, logged there too) -- this line exists so that gap is no
13155        // longer silent.
13156        tracing::info!(
13157            node_id = %self.node_id,
13158            workspace_id = %workspace_id,
13159            provider = %provider,
13160            environment_profile = ?environment_profile.as_ref().map(|receipt| &receipt.profile_id),
13161            session = ?address.session,
13162            "session spawn request accepted",
13163        );
13164        let mut prepared_spawn = SpawnPreparer::prepare(
13165            self,
13166            SpawnPrepareRequest {
13167                instance_id,
13168                address: &address,
13169                provider: &provider,
13170                mode,
13171                environment_profile: environment_profile.as_ref(),
13172                bundle: bundle.as_ref(),
13173                context: context.as_ref(),
13174                managed_authority: managed_authority.as_ref(),
13175                harness_mcp,
13176                approval_level,
13177                network_allowlist: network_allowlist.as_ref(),
13178                browser_profile_id,
13179            },
13180        )?;
13181        let materialization_id = prepared_spawn.materialization_id.take();
13182        let dispatch_timeout = spawn_dispatch_timeout(deadline)?;
13183        let record_id = self.bind_spawn_session_with_materialization(
13184            &address,
13185            provider.clone(),
13186            mode,
13187            runtime_policy,
13188            record_policy,
13189            environment_profile,
13190            bundle,
13191            context,
13192            materialization_id,
13193        )?;
13194        prepared_spawn.retain_session_guards();
13195        // `transport` was already computed above, alongside this session's
13196        // `runtime_policy`; reused here for `Register` rather than derived a
13197        // second time.
13198        let session_provider = provider.clone();
13199        let agent_id = provider;
13200        let register_command_id = match self.dispatch_bounded(
13201            ControlCommand::Register {
13202                instance_id,
13203                agent_id,
13204                transport,
13205            },
13206            dispatch_timeout,
13207        )
13208        .await {
13209            Ok(command_id) => command_id,
13210            Err(error) => {
13211                if let Some(binding) = self.remove_binding(&address) {
13212                    SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
13213                }
13214                if let Some(record_id) = record_id.as_ref() {
13215                    self.discard_record(record_id)?;
13216                }
13217                return Err(spawn_dispatch_error(error, deadline));
13218            }
13219        };
13220        let start_timeout = match spawn_dispatch_timeout(deadline) {
13221            Ok(timeout) => timeout,
13222            Err(error) => {
13223                let recovery = self
13224                    .rollback_spawn(
13225                        &address,
13226                        Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13227                    )
13228                    .await;
13229                return match recovery {
13230                    Ok(()) => {
13231                        if let Some(record_id) = record_id.as_ref() {
13232                            self.discard_record(record_id)?;
13233                        }
13234                        Err(error)
13235                    }
13236                    Err(recovery_error) => {
13237                        if let Some(record_id) = record_id.as_ref() {
13238                            self.mark_record_error(
13239                                record_id,
13240                                "spawn deadline elapsed and registration recovery failed",
13241                            )?;
13242                        }
13243                        Err(recovery_error)
13244                    }
13245                };
13246            }
13247        };
13248        if let Err(start_error) = self.verify_harness_mcp_before_start(harness_mcp) {
13249            if let Err(recovery_error) = self
13250                .rollback_spawn(&address, Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS))
13251                .await
13252            {
13253                return Err(recovery_error);
13254            }
13255            if let Some(record_id) = record_id.as_ref() {
13256                self.discard_record(record_id)?;
13257            }
13258            return Err(start_error);
13259        }
13260        // A session with its own m4a account runs in its own folder inside the workspace.
13261        if let Some(dir) = self.session_workdir(std::path::Path::new(&working_directory), instance_id, &session_provider) {
13262            working_directory = dir.to_string_lossy().into_owned();
13263        }
13264        let start_result = self
13265            .dispatch_bounded(
13266                ControlCommand::Start {
13267                    instance_id,
13268                    runtime_policy,
13269                    request: StartRequest {
13270                        working_directory,
13271                        terminal_size,
13272                        initial_prompt,
13273                        session_options: SPAWN_SESSION_OPTIONS.try_with(|o| o.clone()).ok().flatten(),
13274                        approval_level,
13275                    },
13276                },
13277                start_timeout,
13278            )
13279            .await;
13280        let start_command_id = match start_result {
13281            Ok(command_id) => command_id,
13282            Err(start_error) => {
13283                let start_error = spawn_dispatch_error(start_error, deadline);
13284                let recovery = self
13285                    .rollback_spawn(
13286                        &address,
13287                        Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13288                    )
13289                    .await;
13290                return match recovery {
13291                    Ok(()) => {
13292                        if let Some(record_id) = record_id.as_ref() {
13293                            self.discard_record(record_id)?;
13294                        }
13295                        Err(start_error)
13296                    }
13297                    Err(recovery_error) => {
13298                        let diagnostic = format!(
13299                            "start failed and registration recovery failed: {}",
13300                            recovery_error.message,
13301                        );
13302                        if let Some(record_id) = record_id.as_ref() {
13303                            self.mark_record_error(record_id, &diagnostic)?;
13304                        }
13305                        Err(failure(recovery_error.code, &diagnostic))
13306                    }
13307                };
13308            }
13309        };
13310
13311        let commit_deadline = deadline.unwrap_or_else(|| Instant::now() + dispatch_timeout);
13312        loop {
13313            // `Register`/`Start` only fail synchronously for a port-level
13314            // ingress problem (see `dispatch_bounded`); the kernel refusing
13315            // either command's content -- an unsupported transport, an
13316            // unknown agent -- lands one drive-loop tick later, and no
13317            // session ever appears in `self.handle.snapshot()` for it to
13318            // notice below. Check for that named reason before falling
13319            // through to the blind presence poll, so a rejected spawn
13320            // reports why instead of just timing out.
13321            if let Some(reason) = self.take_rejected_command_reason(register_command_id)
13322                .or_else(|| self.take_rejected_command_reason(start_command_id))
13323            {
13324                self.rollback_spawn(
13325                    &address,
13326                    Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13327                ).await.map_err(|error| {
13328                        failure(
13329                            error.code,
13330                            &format!(
13331                                "spawn was rejected ({}) and registration recovery failed: {}",
13332                                reason.message, error.message
13333                            ),
13334                        )
13335                    })?;
13336                if let Some(record_id) = record_id.as_ref() {
13337                    self.discard_record(record_id)?;
13338                }
13339                let code = if reason.unsupported_transport {
13340                    NodeFailureCode::UnsupportedTransport
13341                } else {
13342                    NodeFailureCode::BackendOperationFailed
13343                };
13344                return Err(failure(code, &reason.message));
13345            }
13346            if let Some(current) = self
13347                .handle
13348                .snapshot()
13349                .sessions
13350                .iter()
13351                .find(|current| current.instance_id == instance_id)
13352            {
13353                if current.generation != session.generation {
13354                    let divergence = failure(
13355                        NodeFailureCode::StaleGeneration,
13356                        "spawn generation diverged",
13357                    );
13358                    return match self
13359                        .rollback_spawn(
13360                            &address,
13361                            Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13362                        )
13363                        .await
13364                    {
13365                        Ok(()) => {
13366                            if let Some(record_id) = record_id.as_ref() {
13367                                self.discard_record(record_id)?;
13368                            }
13369                            Err(divergence)
13370                        }
13371                        Err(recovery_error) => {
13372                            if let Some(record_id) = record_id.as_ref() {
13373                                self.mark_record_error(
13374                                    record_id,
13375                                    "spawn generation diverged and recovery failed",
13376                                )?;
13377                            }
13378                            Err(recovery_error)
13379                        }
13380                    };
13381                }
13382                if !matches!(current.status, gate4agent_types::SessionStatus::Registered) {
13383                    prepared_spawn.retain_launch_guards();
13384                    return Ok((address, runtime_policy));
13385                }
13386            }
13387            if Instant::now() >= commit_deadline {
13388                self.rollback_spawn(
13389                    &address,
13390                    Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13391                ).await.map_err(|error| {
13392                        failure(
13393                            error.code,
13394                            &format!(
13395                                "start did not commit and registration recovery failed: {}",
13396                                error.message
13397                            ),
13398                        )
13399                    })?;
13400                if let Some(record_id) = record_id.as_ref() {
13401                    self.discard_record(record_id)?;
13402                }
13403                return Err(failure(
13404                    if deadline.is_some() {
13405                        NodeFailureCode::SpawnDeadlineExceeded
13406                    } else {
13407                        NodeFailureCode::BackendBusy
13408                    },
13409                    "start did not commit before the bounded deadline; registration was removed",
13410                ));
13411            }
13412            sleep(Duration::from_millis(2)).await;
13413        }
13414    }
13415
13416    fn effective_spawn_runtime_policy(
13417        &self,
13418        runtime_policy: ProviderRuntimePolicy,
13419    ) -> ProviderRuntimePolicy {
13420        #[cfg(feature = "fixture")]
13421        let runtime_policy = if self.fixture_semantic_hook_policy {
13422            ProviderRuntimePolicy::new(true, true, true, true, false, true)
13423                .expect("monitoring Hook fixture policy is internally valid")
13424        } else {
13425            runtime_policy
13426        };
13427        runtime_policy
13428    }
13429
13430    fn verify_harness_mcp_before_start(
13431        &self,
13432        prepared: Option<&PreparedHarnessMcpSpawn>,
13433    ) -> Result<(), NodeFailure> {
13434        prepared
13435            .map(PreparedHarnessMcpSpawn::verify_helper_program)
13436            .transpose()
13437            .map(|_| ())
13438            .map_err(harness_mcp_failure)
13439    }
13440
13441    async fn rollback_spawn(
13442        &self,
13443        address: &SessionAddress,
13444        commit_timeout: Duration,
13445    ) -> Result<(), NodeFailure> {
13446        self.dispatch_bounded(
13447            ControlCommand::Remove {
13448                instance_id: address.session.instance_id,
13449            },
13450            commit_timeout,
13451        )
13452        .await?;
13453        self.wait_until_removed(address, commit_timeout).await?;
13454        if let Some(binding) = self.remove_binding(address) {
13455            SessionLifecycleHook::cleanup_session_materialization(self, address, &binding)?;
13456        }
13457        Ok(())
13458    }
13459
13460    async fn remove_session(&self, address: &SessionAddress) -> Result<(), NodeFailure> {
13461        let commit_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
13462        self.dispatch_bounded(
13463            ControlCommand::Remove {
13464                instance_id: address.session.instance_id,
13465            },
13466            commit_timeout,
13467        )
13468            .await?;
13469        self.wait_until_removed(address, commit_timeout).await?;
13470        let removed = self.remove_binding(address);
13471        if let Some(binding) = removed.as_ref() {
13472            SessionLifecycleHook::cleanup_session_materialization(self, address, binding)?;
13473        }
13474        if let Some(lease_id) = removed.and_then(|binding| binding.managed_worktree_lease_id) {
13475            let snapshot = self.managed_worktrees.lock()
13476                .unwrap_or_else(|poisoned| poisoned.into_inner())
13477                .release_session(
13478                    &lease_id,
13479                    self.incarnation_id,
13480                    address.session.instance_id,
13481                    address.session.generation,
13482                    unix_time_ms(),
13483                );
13484            if self.persist_state().is_err() {
13485                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
13486            }
13487            if let Some(snapshot) = snapshot {
13488                self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
13489            }
13490            let _ = self.cleanup_managed_worktree(&lease_id, false).await;
13491        }
13492        Ok(())
13493    }
13494
13495    /// Polls until `address`'s session leaves the engine's own session map,
13496    /// or the bounded deadline expires.
13497    ///
13498    /// A `Remove` sent while the session is not yet in a terminal status
13499    /// (e.g. still `Stopping`, mid-effect right after a `Stop`) is rejected
13500    /// once by the engine (`SessionStatus::allows_remove`) and
13501    /// then discarded -- nothing re-issues it on its own, so the removal
13502    /// that would satisfy this wait never arrives and the caller was
13503    /// guaranteed to time out even though the session settles microseconds
13504    /// later. So this re-dispatches on every poll tick: a session that
13505    /// settles mid-wait -- the common case right after a `Stop` -- still
13506    /// gets removed inside the same bounded deadline.
13507    ///
13508    /// It re-dispatches only once the status will ACCEPT the command. A
13509    /// rejected re-dispatch is not the harmless no-op this loop first
13510    /// assumed: every rejection publishes a `ControlEventKind::Command
13511    /// Rejected` to every subscriber and writes a WARN line. Ungated, one
13512    /// ordinary teardown produced 241 of them in 1.6 seconds -- the whole
13513    /// `Stopping` window at one known-doomed dispatch per 2ms tick, fanned
13514    /// out to the harness and every attached client. Gated, the same
13515    /// teardown sends exactly one `Remove`, at the first tick the engine
13516    /// can act on it.
13517    ///
13518    /// The status is read from the snapshot this loop already takes, so
13519    /// the gate costs nothing extra. It races -- the status can move
13520    /// between the read and the dispatch -- and that is fine: a rare lost
13521    /// race is one rejection, and the next tick retries.
13522    ///
13523    /// Re-dispatch is separately rate-limited to
13524    /// [`REMOVE_REDISPATCH_INTERVAL_MS`], because the status gate alone
13525    /// leaves a second burst behind it. Once the session settles, every
13526    /// 2ms tick sent another `Remove` while the accepted one was still in
13527    /// flight, and each of those landed on an instance the engine had
13528    /// already dropped -- `agent instance … is not registered`, observed
13529    /// live as a tail of rejections immediately after the `session
13530    /// removed` line. The presence poll stays at 2ms so the wait still
13531    /// exits promptly; only the sending slows down.
13532    ///
13533    /// Rate-limited rather than once-only on purpose: the status gate
13534    /// covers the status precondition, but `Gate4AgentEngine::remove` has
13535    /// others (a pending operation, an in-flight capability probe or
13536    /// history request) that clear on their own. A single shot would turn
13537    /// those into a guaranteed `BackendBusy` timeout; at 10Hz they still
13538    /// get their retries, just twenty of them across the deadline instead
13539    /// of a thousand.
13540    async fn wait_until_removed(
13541        &self,
13542        address: &SessionAddress,
13543        commit_timeout: Duration,
13544    ) -> Result<(), NodeFailure> {
13545        let deadline = Instant::now() + commit_timeout;
13546        let mut next_dispatch = Instant::now();
13547        loop {
13548            let snapshot = self.handle.snapshot();
13549            let status = snapshot
13550                .sessions
13551                .iter()
13552                .find(|session| session.instance_id == address.session.instance_id)
13553                .map(|session| session.status.clone());
13554            let Some(status) = status else {
13555                return Ok(());
13556            };
13557            let now = Instant::now();
13558            if now >= deadline {
13559                return Err(failure(
13560                    NodeFailureCode::BackendBusy,
13561                    "remove did not commit before the bounded deadline; session binding was retained",
13562                ));
13563            }
13564            if status.allows_remove() && now >= next_dispatch {
13565                let _ = self.dispatch(ControlCommand::Remove {
13566                    instance_id: address.session.instance_id,
13567                });
13568                next_dispatch = now + Duration::from_millis(REMOVE_REDISPATCH_INTERVAL_MS);
13569            }
13570            sleep(Duration::from_millis(2)).await;
13571        }
13572    }
13573
13574    fn resync(&self, after_sequence: u64) -> NodeResponse {
13575        let history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
13576        let event_sequence = history.last_sequence;
13577        let oldest_available_sequence = history.replay_floor_sequence;
13578        let events = history
13579            .events
13580            .iter()
13581            .filter(|event| event.sequence > after_sequence)
13582            .cloned()
13583            .collect();
13584        drop(history);
13585        NodeResponse::Resync {
13586            event_sequence,
13587            oldest_available_sequence,
13588            snapshot: self.snapshot(),
13589            events,
13590        }
13591    }
13592}
13593
13594/// Coarse label for one `AgentStreamChunkKindV1`, used only by
13595/// `NodeShared::publish_agent_stream_chunk`'s own debug log -- mirrors
13596/// `hatchery-harness-service`'s `agent_stream_event_kind_label` (same
13597/// "name what crossed this boundary" reasoning, one layer upstream of it).
13598fn agent_stream_chunk_kind_label(kind: &AgentStreamChunkKindV1) -> &'static str {
13599    match kind {
13600        AgentStreamChunkKindV1::Text { .. } => "text",
13601        AgentStreamChunkKindV1::Thinking { .. } => "thinking",
13602        AgentStreamChunkKindV1::InteractionPrompt { .. } => "interaction-prompt",
13603        AgentStreamChunkKindV1::ModeCatalog { .. } => "mode-catalog",
13604        AgentStreamChunkKindV1::ConfigOptions { .. } => "config-options",
13605        AgentStreamChunkKindV1::ModelCatalog { .. } => "model-catalog",
13606        AgentStreamChunkKindV1::Blocked { .. } => "blocked",
13607    }
13608}
13609
13610/// Serves this wire on the local endpoint, and -- when `call_home` names a
13611/// relay -- over an outbound connection to it as well.
13612///
13613/// The two live in one future rather than a fourth arm of `run`'s own
13614/// `select!` because they are one concern: this is where the node's wire
13615/// is served, and the direction a given connection was opened in does not
13616/// change what happens on it. It also means the existing graceful drain
13617/// below covers both without being duplicated.
13618///
13619/// Either side finishing ends the pair, which is correct: the listener
13620/// only returns on shutdown or a bind failure, and `call_home_loop` only
13621/// returns on shutdown -- a failed relay connection is retried inside it,
13622/// never propagated out.
13623async fn accept_connections(
13624    endpoint: &str,
13625    call_home: Option<std::net::SocketAddr>,
13626    identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13627    shared: Arc<NodeShared>,
13628) -> Result<(), NodeServerError> {
13629    let mut connections = JoinSet::new();
13630    let result = match call_home {
13631        None => accept_connections_inner(endpoint, shared, &mut connections).await,
13632        Some(relay) => {
13633            let listener =
13634                accept_connections_inner(endpoint, Arc::clone(&shared), &mut connections);
13635            let dialler = call_home_loop(relay, identity, Arc::clone(&shared));
13636            tokio::pin!(listener);
13637            tokio::pin!(dialler);
13638            tokio::select! {
13639                result = &mut listener => result,
13640                result = &mut dialler => result,
13641            }
13642        }
13643    };
13644    let graceful_deadline = Instant::now() + Duration::from_millis(CONNECTION_SHUTDOWN_GRACE_MS);
13645    while !connections.is_empty() {
13646        let remaining = graceful_deadline.saturating_duration_since(Instant::now());
13647        if remaining.is_zero()
13648            || timeout(remaining, connections.join_next()).await.is_err()
13649        {
13650            break;
13651        }
13652    }
13653    connections.abort_all();
13654    while connections.join_next().await.is_some() {}
13655    result
13656}
13657
13658/// Backoff ladder for a relay that will not answer, capped rather than
13659/// unbounded: a node whose relay is down is not broken and should keep
13660/// trying forever, but it should not spend a core doing it.
13661const CALL_HOME_BACKOFFS_MS: [u64; 5] = [500, 1_000, 2_000, 4_000, 8_000];
13662
13663/// Dials the relay, serves this wire over the connection, and does it
13664/// again when the connection ends.
13665///
13666/// The node is the SERVER on the socket it just opened. That inversion is
13667/// the whole point: `serve_connection` is the same function the local
13668/// listener feeds, and it neither knows nor cares which end placed the
13669/// call. The one thing this direction adds is the preface -- the relay
13670/// accepted a socket and cannot know whose it is, so the node says its
13671/// name before the handshake it would otherwise begin with.
13672///
13673/// Never returns an error. A relay that is down, refusing, or speaking a
13674/// different protocol is a condition to retry, not a reason to take the
13675/// node's local endpoint down with it; the only way out is shutdown.
13676async fn call_home_loop(
13677    relay: std::net::SocketAddr,
13678    identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13679    shared: Arc<NodeShared>,
13680) -> Result<(), NodeServerError> {
13681    let mut failures = 0_usize;
13682    loop {
13683        if shared.shutdown.load(Ordering::Acquire) {
13684            return Ok(());
13685        }
13686        let permit = tokio::select! {
13687            permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13688                permit.map_err(|_| NodeServerError::PreauthClosed)?
13689            }
13690            _ = shared.shutdown_notify.notified() => return Ok(()),
13691        };
13692        match dial_relay_and_serve(relay, identity.as_deref(), Arc::clone(&shared), permit).await {
13693            Ok(()) => {
13694                // A clean end is the relay hanging up, which it does on
13695                // its own restart. Reconnect immediately rather than
13696                // serving the backoff a peer that is plainly there.
13697                failures = 0;
13698                tracing::info!(node_id = %shared.node_id, %relay, "call-home connection closed; redialling");
13699            }
13700            Err(error) => {
13701                failures = failures.saturating_add(1);
13702                tracing::warn!(
13703                    node_id = %shared.node_id,
13704                    %relay,
13705                    cause = %error,
13706                    attempt = failures,
13707                    "call-home connection failed",
13708                );
13709                let backoff = call_home_backoff(failures);
13710                tokio::select! {
13711                    _ = sleep(backoff) => {}
13712                    _ = shared.shutdown_notify.notified() => return Ok(()),
13713                }
13714            }
13715        }
13716    }
13717}
13718
13719/// Idle time before the first keepalive probe on a call-home socket.
13720const CALL_HOME_KEEPALIVE_IDLE: Duration = Duration::from_secs(15);
13721/// Interval between keepalive probes once the link has gone quiet.
13722const CALL_HOME_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(5);
13723
13724fn call_home_keepalive() -> socket2::TcpKeepalive {
13725    let keepalive = socket2::TcpKeepalive::new()
13726        .with_time(CALL_HOME_KEEPALIVE_IDLE)
13727        .with_interval(CALL_HOME_KEEPALIVE_INTERVAL);
13728    #[cfg(not(windows))]
13729    let keepalive = keepalive.with_retries(3);
13730    keepalive
13731}
13732
13733fn enable_call_home_keepalive(stream: &tokio::net::TcpStream) -> std::io::Result<()> {
13734    socket2::SockRef::from(stream).set_tcp_keepalive(&call_home_keepalive())
13735}
13736
13737/// Same keepalive profile for any node->relay socket (restream uplink).
13738pub(crate) fn enable_tcp_keepalive(stream: &std::net::TcpStream) -> std::io::Result<()> {
13739    socket2::SockRef::from(stream).set_tcp_keepalive(&call_home_keepalive())
13740}
13741
13742/// Backoff for the `attempt`-th consecutive failure (1-based), capped.
13743fn call_home_backoff(attempt: usize) -> Duration {
13744    Duration::from_millis(
13745        CALL_HOME_BACKOFFS_MS[attempt.saturating_sub(1).min(CALL_HOME_BACKOFFS_MS.len() - 1)],
13746    )
13747}
13748
13749
13750/// Dial the call-home relay. Tunnel addresses go through the in-process
13751/// WireGuard dialer when the userspace session is up.
13752async fn dial_call_home(relay: std::net::SocketAddr) -> Result<tokio::net::TcpStream, NodeServerError> {
13753    #[cfg(feature = "wireguard")]
13754    {
13755        let std_stream = tokio::task::spawn_blocking(move || {
13756            gate4agent_node_core::kit_wireguard::tcp_connect(relay)
13757        })
13758        .await
13759        .map_err(|e| NodeServerError::CallHomeTransport(std::io::Error::other(e.to_string())))?
13760        .map_err(NodeServerError::CallHomeTransport)?;
13761        std_stream
13762            .set_nonblocking(true)
13763            .map_err(NodeServerError::CallHomeTransport)?;
13764        return tokio::net::TcpStream::from_std(std_stream).map_err(NodeServerError::CallHomeTransport);
13765    }
13766    #[cfg(not(feature = "wireguard"))]
13767    {
13768        tokio::net::TcpStream::connect(relay)
13769            .await
13770            .map_err(NodeServerError::CallHomeTransport)
13771    }
13772}
13773
13774/// One call-home attempt: connect, name this node, then serve.
13775async fn dial_relay_and_serve(
13776    relay: std::net::SocketAddr,
13777    identity: Option<&hatchery_enroll::NodeIdentity>,
13778    shared: Arc<NodeShared>,
13779    preauth_permit: OwnedSemaphorePermit,
13780) -> Result<(), NodeServerError> {
13781    let mut stream = dial_call_home(relay).await?;
13782    // Nagle off for the same reason the rest of this wire wants it off:
13783    // the traffic is small request/response frames, and coalescing them
13784    // adds latency to every one.
13785    let _ = stream.set_nodelay(true);
13786    // A relay that vanishes without a FIN/RST reaching us (tunnel drop,
13787    // relay-side socket kill whose RST was lost) would otherwise leave this
13788    // socket ESTABLISHED forever: the node mostly reads, so nothing ever
13789    // fails and the reconnect loop never runs. Kernel keepalive turns that
13790    // half-open link into a read error within a bounded time.
13791    if let Err(error) = enable_call_home_keepalive(&stream) {
13792        tracing::warn!(cause = %error, "could not enable TCP keepalive on the call-home socket");
13793    }
13794    gate4agent_node_wire::write_call_home_announce(&mut stream, &shared.node_id)
13795        .await
13796        .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13797    tracing::info!(node_id = %shared.node_id, %relay, "announced this node to the relay");
13798    let mut session_token = None;
13799    if let Some(identity) = identity {
13800        // An enrolled relay answers the announce with a signed-call-home challenge; a
13801        // token-mode relay sends its wire hello instead. Peek to tell them apart
13802        // without consuming either.
13803        let mut magic = [0_u8; 4];
13804        let peeked = timeout(Duration::from_secs(10), stream.peek(&mut magic))
13805            .await
13806            .map_err(|_| NodeServerError::Handshake("relay sent nothing after the announce".to_owned()))?
13807            .map_err(NodeServerError::CallHomeTransport)?;
13808        if peeked == 4 && &magic == hatchery_enroll::CHALLENGE_MAGIC {
13809            let mut frame = [0_u8; hatchery_enroll::CHALLENGE_LEN];
13810            timeout(Duration::from_secs(5), tokio::io::AsyncReadExt::read_exact(&mut stream, &mut frame))
13811                .await
13812                .map_err(|_| NodeServerError::Handshake("relay challenge timed out".to_owned()))?
13813                .map_err(NodeServerError::CallHomeTransport)?;
13814            let (answer, token) = identity
13815                .answer_challenge(shared.node_id.as_str(), &frame)
13816                .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13817            tokio::io::AsyncWriteExt::write_all(&mut stream, &answer).await.map_err(NodeServerError::CallHomeTransport)?;
13818            tracing::info!(node_id = %shared.node_id, "answered the relay's signed call-home challenge");
13819            session_token = Some(token);
13820        }
13821    }
13822    serve_connection(stream, shared, session_token, preauth_permit).await
13823}
13824
13825async fn accept_connections_inner(
13826    endpoint: &str,
13827    shared: Arc<NodeShared>,
13828    connections: &mut JoinSet<Result<(), NodeServerError>>,
13829) -> Result<(), NodeServerError> {
13830    let mut listener = OwnerOnlyLocalListener::bind(endpoint).await?;
13831    loop {
13832        while connections.try_join_next().is_some() {}
13833        if shared.shutdown.load(Ordering::Acquire) {
13834            return Ok(());
13835        }
13836        let preauth_permit = tokio::select! {
13837            permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13838                permit.map_err(|_| NodeServerError::PreauthClosed)?
13839            }
13840            _ = shared.shutdown_notify.notified() => return Ok(()),
13841        };
13842        let server = tokio::select! {
13843            result = listener.accept() => result?,
13844            _ = shared.shutdown_notify.notified() => return Ok(()),
13845        };
13846        let connection_shared = Arc::clone(&shared);
13847        connections.spawn(async move {
13848            serve_connection(server, connection_shared, None, preauth_permit).await
13849        });
13850        if shared.shutdown.load(Ordering::Acquire) {
13851            return Ok(());
13852        }
13853    }
13854}
13855
13856/// Serves one connection, whichever end opened it.
13857///
13858/// Generic over the stream rather than typed to `LocalServerStream`
13859/// because a call-home connection arrives on a `TcpStream` the node itself
13860/// dialled. Nothing in the body ever depended on the concrete type -- it
13861/// reads and writes framed JSON and then `tokio::io::split`s -- so this is
13862/// the whole of what welding "who dials" to "who serves" was costing.
13863async fn serve_connection<S>(
13864    mut pipe: S,
13865    shared: Arc<NodeShared>,
13866    session_token: Option<String>,
13867    preauth_permit: OwnedSemaphorePermit,
13868) -> Result<(), NodeServerError>
13869where
13870    S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
13871{
13872    // A signed call-home connection authenticates with its own per-connection token.
13873    let auth_token: &[u8] = session_token.as_deref().map_or(shared.access_token.as_bytes(), str::as_bytes);
13874    let hello_frame = timeout(
13875        Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13876        read_json_frame_limited_body_timeout(
13877            &mut pipe,
13878            MAX_NODE_HELLO_FRAME_BYTES,
13879            Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13880        ),
13881    )
13882    .await
13883    .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13884    let ClientFrame::Hello(hello) = hello_frame else {
13885        return Err(NodeServerError::Handshake("first frame must be hello".to_owned()));
13886    };
13887    if hello.build_stamp != BUILD_STAMP {
13888        return Err(NodeServerError::Handshake(format!(
13889            "build stamp mismatch: local={BUILD_STAMP} remote={}",
13890            hello.build_stamp,
13891        )));
13892    }
13893    let compatibility = match hello.compatibility.as_ref() {
13894        Some(offer) => {
13895            let mut selected = node_compatibility_support(&shared)?
13896                .negotiate(offer)
13897                .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13898            project_negotiated_provider_ids(&mut selected);
13899            Some(selected)
13900        }
13901        None => None,
13902    };
13903    let server_nonce = random_nonce().map_err(NodeServerError::Authentication)?;
13904    let server_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13905        (Some(offer), Some(selected)) => negotiated_auth_proof(
13906            auth_token,
13907            AuthDirection::Server,
13908            hello.role,
13909            &hello.client_nonce,
13910            &server_nonce,
13911            offer,
13912            selected,
13913        ),
13914        (None, None) => auth_proof(
13915            auth_token,
13916            AuthDirection::Server,
13917            hello.role,
13918            &hello.client_nonce,
13919            &server_nonce,
13920        ),
13921        _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13922    }
13923    .map_err(NodeServerError::Authentication)?;
13924    write_json_frame_limited(
13925        &mut pipe,
13926        &ServerFrame::Challenge(ServerChallenge {
13927            build_stamp: BUILD_STAMP.to_owned(),
13928            server_nonce,
13929            server_proof,
13930            compatibility: compatibility.clone(),
13931        }),
13932        MAX_NODE_HELLO_FRAME_BYTES,
13933    )
13934    .await?;
13935    let authentication = timeout(
13936        Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13937        read_json_frame_limited_body_timeout(
13938            &mut pipe,
13939            MAX_NODE_HELLO_FRAME_BYTES,
13940            Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13941        ),
13942    )
13943    .await
13944    .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13945    let ClientFrame::Authenticate(authentication) = authentication else {
13946        return Err(NodeServerError::Handshake("second frame must authenticate the challenge".to_owned()));
13947    };
13948    let expected_client_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13949        (Some(offer), Some(selected)) => negotiated_auth_proof(
13950            auth_token,
13951            AuthDirection::Client,
13952            hello.role,
13953            &hello.client_nonce,
13954            &server_nonce,
13955            offer,
13956            selected,
13957        ),
13958        (None, None) => auth_proof(
13959            auth_token,
13960            AuthDirection::Client,
13961            hello.role,
13962            &hello.client_nonce,
13963            &server_nonce,
13964        ),
13965        _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13966    }
13967    .map_err(NodeServerError::Authentication)?;
13968    if !proofs_match(&authentication.client_proof, &expected_client_proof) {
13969        return Err(NodeServerError::Handshake("access denied".to_owned()));
13970    }
13971    let selected_capabilities = compatibility
13972        .as_ref()
13973        .map(|selected| selected.capabilities.clone())
13974        .unwrap_or_default();
13975    let include_provider_runtime_status = selected_capabilities.iter().any(|capability| {
13976        capability.as_str() == NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY
13977    });
13978    let include_open_provider_ids = selected_capabilities.iter().any(|capability| {
13979        capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
13980    });
13981    let include_terminal_frame_events = selected_capabilities.iter().any(|capability| {
13982        capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
13983    });
13984    let include_agent_stream_events = selected_capabilities.iter().any(|capability| {
13985        capability.as_str() == NODE_AGENT_STREAM_EVENTS_CAPABILITY
13986    });
13987    let include_spawn_profiles = selected_capabilities.iter().any(|capability| {
13988        capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
13989    });
13990    let include_managed_worktrees = selected_capabilities.iter().any(|capability| {
13991        capability.as_str() == NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY
13992    }) && selected_capabilities.iter().any(|capability| {
13993        capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
13994    });
13995    let include_child_environment_profiles = selected_capabilities.iter().any(|capability| {
13996        capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
13997    });
13998    let include_session_bundles = selected_capabilities.iter().any(|capability| {
13999        capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
14000    });
14001    let include_history_context_packs = selected_capabilities.iter().any(|capability| {
14002        capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
14003    });
14004    let include_agent_progress = selected_capabilities.iter().any(|capability| {
14005        capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
14006    });
14007    let include_session_task_correlation = selected_capabilities.iter().any(|capability| {
14008        capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
14009    });
14010    let include_harness_mcp_proxy = selected_capabilities.iter().any(|capability| {
14011        capability.as_str() == NODE_HARNESS_MCP_READ_PROXY_CAPABILITY
14012    });
14013    let authenticated_permit = Arc::clone(&shared.authenticated_slots)
14014        .try_acquire_owned()
14015        .map_err(|_| NodeServerError::AuthenticatedConnectionLimit)?;
14016    let connection_id = shared.next_connection_id.fetch_add(1, Ordering::AcqRel);
14017    let _release_guard = ControllerReleaseGuard {
14018        shared: Arc::clone(&shared),
14019        connection_id,
14020    };
14021    drop(preauth_permit);
14022    let _authenticated_permit = authenticated_permit;
14023    let mut event_rx = shared.event_tx.subscribe();
14024    let mut terminal_event_rx = terminal_event_subscription(
14025        &shared,
14026        include_terminal_frame_events,
14027    );
14028    let mut agent_stream_event_rx = agent_stream_event_subscription(
14029        &shared,
14030        include_agent_stream_events,
14031    );
14032    let mut harness_mcp_proxy_event_rx = harness_mcp_proxy_event_subscription(
14033        &shared,
14034        include_harness_mcp_proxy,
14035    );
14036    write_json_frame(
14037        &mut pipe,
14038        &ServerFrame::Hello(NodeHello {
14039            build_stamp: BUILD_STAMP.to_owned(),
14040            incarnation_id: shared.incarnation_id,
14041            connection_id,
14042            role: hello.role,
14043            event_sequence: shared.current_sequence(),
14044            controller: shared.controller_state(),
14045            snapshot: snapshot_for_wire(
14046                &shared,
14047                include_provider_runtime_status,
14048                include_open_provider_ids,
14049                include_spawn_profiles,
14050                include_managed_worktrees,
14051                include_child_environment_profiles,
14052                include_session_bundles,
14053                include_history_context_packs,
14054                include_agent_progress,
14055                include_session_task_correlation,
14056            ),
14057            compatibility,
14058        }),
14059    )
14060    .await?;
14061    let (mut reader, mut writer) = tokio::io::split(pipe);
14062    let (frame_tx, mut frame_rx) = mpsc::channel(16);
14063    let reader_task = tokio::spawn(async move {
14064        loop {
14065            let frame = read_json_frame_limited_body_timeout::<_, ClientFrame>(
14066                &mut reader,
14067                MAX_NODE_CLIENT_FRAME_BYTES,
14068                Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
14069            )
14070            .await;
14071            let terminal = frame.is_err();
14072            if frame_tx.send(frame).await.is_err() || terminal {
14073                break;
14074            }
14075        }
14076    });
14077    let _reader_abort = AbortTaskOnDrop(reader_task.abort_handle());
14078    let mut pending_events = Vec::new();
14079    let mut resync_required = false;
14080    let mut discard_events_through = 0_u64;
14081    loop {
14082        // One tick of the serve loop, whether or not it had anything to do.
14083        // Against `connection_events_sent` this separates "busy" from
14084        // "spinning": a loop turning over thousands of times a second while
14085        // sending nothing is not serving, it is burning a core.
14086        shared.connection_loop_iterations.fetch_add(1, Ordering::Relaxed);
14087        let durable_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
14088            .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
14089        for _ in 0..durable_drain_budget {
14090            match event_rx.try_recv() {
14091                Ok(event) => queue_connection_event(
14092                    &mut pending_events,
14093                    event,
14094                    discard_events_through,
14095                ),
14096                Err(broadcast::error::TryRecvError::Empty) => break,
14097                Err(broadcast::error::TryRecvError::Lagged(_)) => {
14098                    resync_required = true;
14099                    break;
14100                }
14101                Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14102            }
14103        }
14104        if let Some(receiver) = terminal_event_rx.as_mut() {
14105            match receiver.try_recv() {
14106                Ok(events) => queue_connection_event_batch(
14107                    &mut pending_events,
14108                    &events,
14109                    discard_events_through,
14110                ),
14111                Err(broadcast::error::TryRecvError::Empty) => {}
14112                Err(broadcast::error::TryRecvError::Lagged(_)) => {
14113                    resync_required = true;
14114                }
14115                Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14116            }
14117        }
14118        if let Some(receiver) = agent_stream_event_rx.as_mut() {
14119            // Loops up to the same remaining-room budget the durable
14120            // `event_rx` drain above uses, rather than one `try_recv` per
14121            // tick: each successful receive here is itself only ONE
14122            // `NodeEventEnvelope` batch (`publish_agent_stream_chunk` always
14123            // sends `vec![envelope]`, never more), so a single-shot drain
14124            // caps this connection's agent-stream throughput at one chunk
14125            // per outer-loop tick regardless of how many are already queued
14126            // -- exactly the kind of self-inflicted stall this crate's own
14127            // durable-event budget exists to avoid.
14128            let agent_stream_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
14129                .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
14130            for _ in 0..agent_stream_drain_budget {
14131                match receiver.try_recv() {
14132                    Ok(events) => queue_connection_event_batch(
14133                        &mut pending_events,
14134                        &events,
14135                        discard_events_through,
14136                    ),
14137                    Err(broadcast::error::TryRecvError::Empty) => break,
14138                    Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
14139                        tracing::warn!(
14140                            connection_id,
14141                            dropped,
14142                            "agent stream event subscription lagged; forcing a resync",
14143                        );
14144                        resync_required = true;
14145                        break;
14146                    }
14147                    Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14148                }
14149            }
14150        }
14151        if let Some(receiver) = harness_mcp_proxy_event_rx.as_mut() {
14152            // Mirrors the agent-stream drain immediately above: each
14153            // successful receive is one single-envelope batch
14154            // (`publish_transient` always sends `vec![envelope]`), so this
14155            // loops up to the same remaining-room budget rather than
14156            // stopping after one `try_recv`.
14157            let harness_mcp_proxy_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
14158                .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
14159            for _ in 0..harness_mcp_proxy_drain_budget {
14160                match receiver.try_recv() {
14161                    Ok(events) => queue_connection_event_batch(
14162                        &mut pending_events,
14163                        &events,
14164                        discard_events_through,
14165                    ),
14166                    Err(broadcast::error::TryRecvError::Empty) => break,
14167                    Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
14168                        tracing::warn!(
14169                            connection_id,
14170                            dropped,
14171                            "harness MCP proxy event subscription lagged; forcing a resync",
14172                        );
14173                        resync_required = true;
14174                        break;
14175                    }
14176                    Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14177                }
14178            }
14179        }
14180        if resync_required {
14181            let event = resync_required_event(&shared);
14182            apply_connection_resync_watermark(
14183                &mut pending_events,
14184                &mut discard_events_through,
14185                event.sequence,
14186            );
14187            write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
14188            resync_required = false;
14189            continue;
14190        }
14191        if !pending_events.is_empty() {
14192            pending_events.sort_unstable_by_key(|event| event.sequence);
14193            let burst_len = connection_event_burst_len(pending_events.len());
14194            shared
14195                .connection_events_sent
14196                .fetch_add(burst_len as u64, Ordering::Relaxed);
14197            for event in pending_events.drain(..burst_len) {
14198                let event = if include_managed_worktrees {
14199                    Some(event)
14200                } else {
14201                    project_event_without_managed_worktrees(event)
14202                };
14203                let event = event.and_then(|event| if include_open_provider_ids {
14204                    Some(event)
14205                } else {
14206                    project_event_legacy_provider_ids(&shared, event)
14207                });
14208                let event = event.map(|event| if include_child_environment_profiles {
14209                    event
14210                } else {
14211                    project_event_without_child_environment_profile(event)
14212                });
14213                let event = event.map(|event| if include_session_bundles {
14214                    event
14215                } else {
14216                    project_event_without_session_bundle(event)
14217                });
14218                let event = event.map(|event| {
14219                    project_event_history_for_wire(event, include_history_context_packs)
14220                });
14221                let event = event.map(|event| if include_history_context_packs {
14222                    event
14223                } else {
14224                    project_event_without_context_pack(event)
14225                });
14226                let event = event.map(|event| if include_session_task_correlation {
14227                    event
14228                } else {
14229                    project_event_without_session_task_binding(event)
14230                });
14231                let exact_harness_controller = hello.role == ClientRole::Operator
14232                    && shared.controller_state().is_some_and(|controller| {
14233                        controller.connection_id == connection_id
14234                    });
14235                let event = event.and_then(|event| {
14236                    if include_harness_mcp_proxy && exact_harness_controller {
14237                        Some(event)
14238                    } else {
14239                        project_event_without_harness_mcp_proxy(event)
14240                    }
14241                });
14242                if let Some(event) = event {
14243                    write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
14244                }
14245            }
14246        }
14247        tokio::select! {
14248            biased;
14249            frame = frame_rx.recv() => {
14250                let frame = match frame {
14251                    Some(frame) => frame,
14252                    None => break,
14253                };
14254                let frame = match frame {
14255                    Ok(frame) => frame,
14256                    Err(FrameError::Io(error)) if matches!(error.kind(), io::ErrorKind::BrokenPipe | io::ErrorKind::UnexpectedEof) => break,
14257                    Err(error) => return Err(error.into()),
14258                };
14259                let ClientFrame::Request(request) = frame else {
14260                    return Err(NodeServerError::Handshake("hello may only be sent once".to_owned()));
14261                };
14262                #[cfg(feature = "fixture")]
14263                let probe_spawn_managed_worktree_v2 = matches!(
14264                    &request.request,
14265                    NodeRequest::SpawnManagedWorktreeV2 { .. }
14266                );
14267                let requires_open_provider_ids =
14268                    request_requires_open_provider_ids(&shared, &request.request);
14269                let requires_child_environment_profile =
14270                    request_requires_child_environment_profile(&shared, &request.request);
14271                let requires_session_bundle =
14272                    request_requires_session_bundle(&shared, &request.request);
14273                let requires_history_context_pack =
14274                    request_requires_history_context_pack(&shared, &request.request);
14275                let mut reply = if requires_open_provider_ids && !include_open_provider_ids {
14276                    ResponseEnvelope {
14277                        request_id: request.request_id,
14278                        result: Err(failure(
14279                            NodeFailureCode::UnsupportedCapability,
14280                            "open provider IDs were not negotiated",
14281                        )),
14282                    }
14283                } else if requires_child_environment_profile
14284                    && !include_child_environment_profiles
14285                {
14286                    ResponseEnvelope {
14287                        request_id: request.request_id,
14288                        result: Err(failure(
14289                            NodeFailureCode::UnsupportedCapability,
14290                            "child environment profiles were not negotiated",
14291                        )),
14292                    }
14293                } else if requires_session_bundle && !include_session_bundles {
14294                    ResponseEnvelope {
14295                        request_id: request.request_id,
14296                        result: Err(failure(
14297                            NodeFailureCode::UnsupportedCapability,
14298                            "session bundles were not negotiated",
14299                        )),
14300                    }
14301                } else if requires_history_context_pack && !include_history_context_packs {
14302                    ResponseEnvelope {
14303                        request_id: request.request_id,
14304                        result: Err(failure(
14305                            NodeFailureCode::UnsupportedCapability,
14306                            "history context packs were not negotiated",
14307                        )),
14308                    }
14309                } else if request_uses_unnegotiated_capability(
14310                    &request.request,
14311                    &selected_capabilities,
14312                )
14313                {
14314                    ResponseEnvelope {
14315                        request_id: request.request_id,
14316                        result: Err(failure(
14317                            NodeFailureCode::UnsupportedCapability,
14318                            "request capability was not negotiated",
14319                        )),
14320                    }
14321                } else {
14322                    process_request(&shared, connection_id, hello.role, request).await
14323                };
14324                #[cfg(feature = "fixture")]
14325                if probe_spawn_managed_worktree_v2 {
14326                    shared
14327                        .fixture_spawn_managed_worktree_v2_failure_probe
14328                        .record(reply.result.as_ref().err().map(|failure| failure.code));
14329                }
14330                if !include_provider_runtime_status {
14331                    clear_response_provider_runtime_status(&mut reply);
14332                }
14333                if !include_managed_worktrees {
14334                    project_response_without_managed_worktrees(&mut reply);
14335                }
14336                if !include_open_provider_ids {
14337                    project_response_legacy_provider_ids(&shared, &mut reply);
14338                }
14339                if !include_child_environment_profiles {
14340                    project_response_without_child_environment_profile(&mut reply);
14341                }
14342                if !include_session_bundles {
14343                    project_response_without_session_bundle(&mut reply);
14344                }
14345                project_response_history_for_wire(
14346                    &mut reply,
14347                    include_history_context_packs,
14348                );
14349                if !include_history_context_packs {
14350                    project_response_without_context_pack(&mut reply);
14351                }
14352                if !include_agent_progress {
14353                    project_response_without_agent_progress(&mut reply);
14354                }
14355                if !include_session_task_correlation {
14356                    project_response_without_session_task_binding(&mut reply);
14357                }
14358                write_json_frame(&mut writer, &ServerFrame::Reply(reply)).await?;
14359            }
14360            _ = tokio::task::yield_now(), if !pending_events.is_empty() => {}
14361            event = event_rx.recv() => {
14362                match event {
14363                    Ok(event) => queue_connection_event(
14364                        &mut pending_events,
14365                        event,
14366                        discard_events_through,
14367                    ),
14368                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14369                    Err(broadcast::error::RecvError::Closed) => break,
14370                }
14371            }
14372            events = receive_terminal_event_batch(&mut terminal_event_rx) => {
14373                match events {
14374                    Ok(events) => queue_connection_event_batch(
14375                        &mut pending_events,
14376                        &events,
14377                        discard_events_through,
14378                    ),
14379                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14380                    Err(broadcast::error::RecvError::Closed) => break,
14381                }
14382            }
14383            events = receive_terminal_event_batch(&mut agent_stream_event_rx) => {
14384                match events {
14385                    Ok(events) => queue_connection_event_batch(
14386                        &mut pending_events,
14387                        &events,
14388                        discard_events_through,
14389                    ),
14390                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14391                    Err(broadcast::error::RecvError::Closed) => break,
14392                }
14393            }
14394            events = receive_terminal_event_batch(&mut harness_mcp_proxy_event_rx) => {
14395                match events {
14396                    Ok(events) => queue_connection_event_batch(
14397                        &mut pending_events,
14398                        &events,
14399                        discard_events_through,
14400                    ),
14401                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14402                    Err(broadcast::error::RecvError::Closed) => break,
14403                }
14404            }
14405            _ = shared.shutdown_notify.notified() => break,
14406        }
14407    }
14408    Ok(())
14409}
14410
14411async fn receive_terminal_event_batch(
14412    receiver: &mut Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>>,
14413) -> Result<Arc<Vec<NodeEventEnvelope>>, broadcast::error::RecvError> {
14414    match receiver {
14415        Some(receiver) => receiver.recv().await,
14416        None => std::future::pending().await,
14417    }
14418}
14419
14420fn terminal_event_subscription(
14421    shared: &NodeShared,
14422    enabled: bool,
14423) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14424    enabled.then(|| shared.terminal_event_tx.subscribe())
14425}
14426
14427/// Mirrors `terminal_event_subscription` exactly, over the agent stream's
14428/// own channel -- see `NodeShared::agent_stream_event_tx`'s own doc for why
14429/// this needs a dedicated channel rather than a flag on the terminal one.
14430fn agent_stream_event_subscription(
14431    shared: &NodeShared,
14432    enabled: bool,
14433) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14434    enabled.then(|| shared.agent_stream_event_tx.subscribe())
14435}
14436
14437/// Mirrors `agent_stream_event_subscription` exactly, over the harness MCP
14438/// proxy's own channel -- see `NodeShared::harness_mcp_proxy_event_tx`'s
14439/// own doc for why this needs a dedicated channel rather than a ride on
14440/// the durable `event_tx`.
14441fn harness_mcp_proxy_event_subscription(
14442    shared: &NodeShared,
14443    enabled: bool,
14444) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14445    enabled.then(|| shared.harness_mcp_proxy_event_tx.subscribe())
14446}
14447
14448fn queue_connection_event(
14449    pending: &mut Vec<NodeEventEnvelope>,
14450    event: NodeEventEnvelope,
14451    discard_through: u64,
14452) {
14453    if event.sequence <= discard_through
14454        && !event.event.requires_harness_mcp_proxy_capability()
14455    {
14456        return;
14457    }
14458    if let NodeEvent::TerminalFrame { address, .. } = &event.event {
14459        if let Some(index) = pending.iter().position(|current| {
14460            matches!(
14461                &current.event,
14462                NodeEvent::TerminalFrame {
14463                    address: current_address,
14464                    ..
14465                } if current_address == address
14466            )
14467        }) {
14468            pending.remove(index);
14469        }
14470    }
14471    pending.push(event);
14472}
14473
14474fn project_event_without_harness_mcp_proxy(
14475    envelope: NodeEventEnvelope,
14476) -> Option<NodeEventEnvelope> {
14477    (!envelope.event.requires_harness_mcp_proxy_capability()).then_some(envelope)
14478}
14479
14480fn queue_connection_event_batch(
14481    pending: &mut Vec<NodeEventEnvelope>,
14482    events: &[NodeEventEnvelope],
14483    discard_through: u64,
14484) {
14485    for event in events {
14486        queue_connection_event(pending, event.clone(), discard_through);
14487    }
14488}
14489
14490fn apply_connection_resync_watermark(
14491    pending: &mut Vec<NodeEventEnvelope>,
14492    discard_through: &mut u64,
14493    marker_sequence: u64,
14494) {
14495    *discard_through = (*discard_through).max(marker_sequence);
14496    pending.retain(|event| event.sequence > *discard_through);
14497}
14498
14499fn connection_event_burst_len(pending_len: usize) -> usize {
14500    pending_len.min(NODE_CONNECTION_EVENT_BURST_MAX)
14501}
14502
14503fn resync_required_event(shared: &NodeShared) -> NodeEventEnvelope {
14504    let history = shared
14505        .history
14506        .lock()
14507        .unwrap_or_else(|poisoned| poisoned.into_inner());
14508    let sequence = history.last_sequence;
14509    let oldest_available_sequence = history.replay_floor_sequence;
14510    NodeEventEnvelope {
14511        sequence,
14512        event: NodeEvent::ResyncRequired {
14513            oldest_available_sequence,
14514        },
14515    }
14516}
14517
14518fn node_compatibility_support(
14519    shared: &NodeShared,
14520) -> Result<NodeCompatibilitySupport, NodeServerError> {
14521    let mut support = node_compatibility_support_for_manifest(
14522        &shared.provider_contracts,
14523        &shared.provider_adapter_contracts,
14524    )?;
14525    if shared
14526        .delivery_store
14527        .lock()
14528        .unwrap_or_else(|poisoned| poisoned.into_inner())
14529        .is_some()
14530    {
14531        support.capabilities.push(
14532            CapabilityId::new(NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY)
14533                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14534        );
14535    }
14536    if shared.harness_mcp_registry.is_some() {
14537        support.capabilities.push(
14538            CapabilityId::new(NODE_HARNESS_MCP_READ_PROXY_CAPABILITY)
14539                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14540        );
14541    }
14542    Ok(support)
14543}
14544
14545fn node_compatibility_support_for_manifest(
14546    provider_contracts: &[ProviderContractSupport],
14547    provider_adapter_contracts: &[ProviderAdapterContractSupport],
14548) -> Result<NodeCompatibilitySupport, NodeServerError> {
14549    Ok(NodeCompatibilitySupport {
14550        build_stamp: BUILD_STAMP.to_owned(),
14551        capabilities: baseline_capabilities()?,
14552        host: platform::host_descriptor().map_err(NodeServerError::Handshake)?,
14553        path_semantics: platform::path_semantics(),
14554        local_transport: platform::local_transport(),
14555        state_schema: StateSchemaSupport {
14556            versions: ProtocolRange::new(NODE_STATE_SCHEMA_V1, NODE_STATE_SCHEMA_V10)
14557                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14558        },
14559        provider_contracts: provider_contracts.to_vec(),
14560        provider_adapter_contracts: provider_adapter_contracts.to_vec(),
14561    })
14562}
14563
14564fn baseline_capabilities() -> Result<Vec<CapabilityId>, NodeServerError> {
14565    let capabilities = [
14566        NODE_COMPATIBILITY_METADATA_CAPABILITY,
14567        CAPABILITY_HOST_DIRECTORY_BROWSE_V1,
14568        NODE_REPOSITORY_PATH_CAPABILITY,
14569        NODE_WORKSPACE_FILE_READ_CAPABILITY,
14570        NODE_WORKSPACE_FILE_WRITE_CAPABILITY,
14571        NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY,
14572        NODE_GIT_READ_CAPABILITY,
14573        NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY,
14574        NODE_PROVIDER_ID_OPEN_CAPABILITY,
14575        NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
14576        NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY,
14577        NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
14578        NODE_AGENT_STREAM_EVENTS_CAPABILITY,
14579        NODE_ACP_CONTROL_CAPABILITY,
14580        NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
14581        NODE_SPAWN_PROFILE_REVISION_CAPABILITY,
14582        NODE_WORKTREE_SELECTION_CAPABILITY,
14583        NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
14584        NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
14585        NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
14586        NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
14587        NODE_HISTORY_CONTEXT_PACK_CAPABILITY,
14588        NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
14589        NODE_NATIVE_SESSION_CATALOG_CAPABILITY,
14590        NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
14591        NODE_NATIVE_SESSION_INDEX_CAPABILITY,
14592        NODE_NATIVE_SESSION_PREVIEW_CAPABILITY,
14593        NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
14594        NODE_SESSION_TASK_CORRELATION_CAPABILITY,
14595    ]
14596        .into_iter()
14597        .map(|capability| {
14598            CapabilityId::new(capability)
14599                .map_err(|error| NodeServerError::Handshake(error.to_string()))
14600        })
14601        .collect::<Result<Vec<_>, _>>()?;
14602    #[cfg(windows)]
14603    let capabilities = {
14604        let mut capabilities = capabilities;
14605        capabilities.push(
14606            CapabilityId::new(NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY)
14607                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14608        );
14609        capabilities
14610    };
14611    Ok(capabilities)
14612}
14613
14614fn request_uses_unnegotiated_capability(
14615    request: &NodeRequest,
14616    selected_capabilities: &[CapabilityId],
14617) -> bool {
14618    let selected = |required: &str| {
14619        selected_capabilities
14620            .iter()
14621            .any(|capability| capability.as_str() == required)
14622    };
14623    request
14624        .required_capability()
14625        .is_some_and(|required| !selected(required))
14626        || (request.requires_worktree_selection_capability()
14627            && !selected(NODE_WORKTREE_SELECTION_CAPABILITY))
14628        || (request.requires_spawn_spec_defaults_overrides_capability()
14629            && !selected(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY))
14630        || (request.requires_spawn_profile_revision_capability()
14631            && !selected(NODE_SPAWN_PROFILE_REVISION_CAPABILITY))
14632        || (request.requires_child_environment_profile_capability()
14633            && !selected(NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY))
14634        || (request.requires_session_bundle_materialization_capability()
14635            && !selected(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY))
14636        || (request.requires_history_context_pack_capability()
14637            && !selected(NODE_HISTORY_CONTEXT_PACK_CAPABILITY))
14638}
14639
14640fn snapshot_for_wire(
14641    shared: &NodeShared,
14642    include_provider_runtime_status: bool,
14643    include_open_provider_ids: bool,
14644    include_spawn_profiles: bool,
14645    include_managed_worktrees: bool,
14646    include_child_environment_profiles: bool,
14647    include_session_bundles: bool,
14648    include_history_context_packs: bool,
14649    include_agent_progress: bool,
14650    include_session_task_correlation: bool,
14651) -> NodeSnapshot {
14652    let mut snapshot = shared.snapshot();
14653    if !include_provider_runtime_status {
14654        snapshot.provider_runtime_statuses.clear();
14655    }
14656    if !include_open_provider_ids {
14657        project_snapshot_legacy_provider_ids(&mut snapshot);
14658    }
14659    if !include_managed_worktrees {
14660        snapshot.managed_worktrees.clear();
14661        for workspace in &mut snapshot.workspaces {
14662            workspace.managed_worktree_profiles = None;
14663        }
14664    }
14665    if let Some(inventory) = snapshot.launch_inventory.as_mut() {
14666        if !include_spawn_profiles {
14667            inventory.spawn_profiles = None;
14668        }
14669        if !include_session_bundles {
14670            inventory.bundles = None;
14671        }
14672        if inventory.spawn_profiles.is_none()
14673            && inventory.bundles.is_none()
14674            && inventory.network_allowlists.is_none()
14675        {
14676            snapshot.launch_inventory = None;
14677        }
14678    }
14679    if !include_child_environment_profiles {
14680        clear_snapshot_child_environment_profiles(&mut snapshot);
14681    }
14682    if !include_session_bundles {
14683        clear_snapshot_session_bundles(&mut snapshot);
14684    }
14685    project_snapshot_history_for_wire(&mut snapshot, include_history_context_packs);
14686    if !include_history_context_packs {
14687        clear_snapshot_context_packs(&mut snapshot);
14688    }
14689    if !include_agent_progress {
14690        snapshot.agent_progress.clear();
14691    }
14692    if !include_session_task_correlation {
14693        clear_snapshot_session_task_bindings(&mut snapshot);
14694    }
14695    snapshot
14696}
14697
14698fn clear_snapshot_session_task_bindings(snapshot: &mut NodeSnapshot) {
14699    for record in &mut snapshot.session_records {
14700        record.task_binding = None;
14701    }
14702}
14703
14704fn project_event_without_session_task_binding(
14705    mut envelope: NodeEventEnvelope,
14706) -> NodeEventEnvelope {
14707    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14708        record.task_binding = None;
14709    }
14710    envelope
14711}
14712
14713fn project_response_without_session_task_binding(reply: &mut ResponseEnvelope) {
14714    let Ok(response) = reply.result.as_mut() else { return };
14715    match response {
14716        NodeResponse::Snapshot { snapshot, .. } => {
14717            clear_snapshot_session_task_bindings(snapshot);
14718        }
14719        NodeResponse::Resync { snapshot, events, .. } => {
14720            clear_snapshot_session_task_bindings(snapshot);
14721            for event in events {
14722                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14723                    record.task_binding = None;
14724                }
14725            }
14726        }
14727        NodeResponse::SessionRecordUpdated { record }
14728        | NodeResponse::ProviderSessionIndexed { record }
14729        | NodeResponse::NativeSessionIndexed { record, .. }
14730        | NodeResponse::SessionRecordResumed { record, .. } => {
14731            record.task_binding = None;
14732        }
14733        _ => {}
14734    }
14735}
14736
14737fn project_response_without_agent_progress(reply: &mut ResponseEnvelope) {
14738    let Ok(response) = reply.result.as_mut() else { return };
14739    match response {
14740        NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
14741            snapshot.agent_progress.clear();
14742        }
14743        _ => {}
14744    }
14745}
14746
14747fn project_event_without_managed_worktrees(
14748    mut envelope: NodeEventEnvelope,
14749) -> Option<NodeEventEnvelope> {
14750    if matches!(
14751        envelope.event,
14752        NodeEvent::ManagedWorktreeUpserted { .. }
14753            | NodeEvent::ManagedWorktreeRemoved { .. }
14754    ) {
14755        return None;
14756    }
14757    if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
14758        workspace.managed_worktree_profiles = None;
14759    }
14760    Some(envelope)
14761}
14762
14763fn project_response_without_managed_worktrees(reply: &mut ResponseEnvelope) {
14764    if matches!(
14765        reply.result,
14766        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { .. })
14767            | Ok(NodeResponse::ManagedWorktreeCleanup { .. })
14768    ) {
14769        reply.result = Err(failure(
14770            NodeFailureCode::UnsupportedCapability,
14771            "managed worktree capability was not negotiated",
14772        ));
14773        return;
14774    }
14775    let Ok(response) = reply.result.as_mut() else { return };
14776    match response {
14777        NodeResponse::Snapshot { snapshot, .. } => {
14778            snapshot.managed_worktrees.clear();
14779            for workspace in &mut snapshot.workspaces {
14780                workspace.managed_worktree_profiles = None;
14781            }
14782        }
14783        NodeResponse::Resync { snapshot, events, .. } => {
14784            snapshot.managed_worktrees.clear();
14785            for workspace in &mut snapshot.workspaces {
14786                workspace.managed_worktree_profiles = None;
14787            }
14788            *events = std::mem::take(events)
14789                .into_iter()
14790                .filter_map(project_event_without_managed_worktrees)
14791                .collect();
14792        }
14793        NodeResponse::WorkspaceRegistered { workspace }
14794        | NodeResponse::StandaloneWorkspaceCreated { workspace }
14795        | NodeResponse::WorktreeCreated { workspace, .. } => {
14796            workspace.managed_worktree_profiles = None;
14797        }
14798        NodeResponse::WorkspaceInspected { inspection } => {
14799            inspection.git.managed_worktree = None;
14800        }
14801        _ => {}
14802    }
14803}
14804
14805fn project_negotiated_provider_ids(compatibility: &mut NegotiatedNodeCompatibility) {
14806    let includes_open_provider_ids = compatibility.capabilities.iter().any(|capability| {
14807        capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
14808    });
14809    if includes_open_provider_ids {
14810        return;
14811    }
14812    compatibility
14813        .provider_contracts
14814        .retain(|contract| provider_id_is_legacy(&contract.provider));
14815    compatibility
14816        .provider_adapter_contracts
14817        .retain(|contract| provider_id_is_legacy(&contract.provider));
14818}
14819
14820fn request_requires_open_provider_ids(shared: &NodeShared, request: &NodeRequest) -> bool {
14821    if let Some(spec) = match request {
14822        NodeRequest::SpawnSpec { spec }
14823        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14824        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14825        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14826        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14827        _ => None,
14828    } {
14829        return shared
14830            .resolve_spawn_spec(spec)
14831            .is_ok_and(|resolved| !provider_id_is_legacy(&resolved.provider));
14832    }
14833    request_requires_open_provider_ids_with(
14834        request,
14835        |session| shared.validate_address(session).ok(),
14836        |record_id| shared.record(record_id).ok().map(|record| record.provider),
14837    )
14838}
14839
14840fn request_requires_child_environment_profile(
14841    shared: &NodeShared,
14842    request: &NodeRequest,
14843) -> bool {
14844    if let Some(spec) = match request {
14845        NodeRequest::SpawnSpec { spec }
14846        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14847        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14848        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14849        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14850        _ => None,
14851    } {
14852        return shared
14853            .resolve_spawn_spec(spec)
14854            .is_ok_and(|resolved| resolved.environment_profile_id.is_some());
14855    }
14856    match request {
14857        NodeRequest::Resume { session, .. }
14858        | NodeRequest::Prompt { session, .. }
14859        | NodeRequest::Paste { session, .. }
14860        | NodeRequest::Input { session, .. }
14861        | NodeRequest::TerminalBytes { session, .. }
14862        | NodeRequest::TerminalControl { session, .. }
14863        | NodeRequest::Resize { session, .. }
14864        | NodeRequest::Interrupt { session }
14865        | NodeRequest::Stop { session, .. }
14866        | NodeRequest::Remove { session }
14867        | NodeRequest::ResolveInteraction { session, .. }
14868        | NodeRequest::SetSessionMode { session, .. }
14869        | NodeRequest::SetSessionConfigOption { session, .. }
14870        | NodeRequest::SetSessionModel { session, .. }
14871        | NodeRequest::DiscoverHistory { session, .. }
14872        | NodeRequest::LoadHistory { session, .. }
14873        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14874        | NodeRequest::ExportContextPack { session } => shared
14875            .session_bindings
14876            .lock()
14877            .unwrap_or_else(|poisoned| poisoned.into_inner())
14878            .get(&session.session.instance_id)
14879            .filter(|binding| {
14880                binding.workspace_id == session.workspace_id
14881                    && binding.generation == session.session.generation
14882            })
14883            .map_or(true, |binding| binding.environment_profile.is_some()),
14884        NodeRequest::RenameSessionRecord { record_id, .. }
14885        | NodeRequest::SetSessionTask { record_id, .. }
14886        | NodeRequest::ResumeSessionRecord { record_id, .. }
14887        | NodeRequest::ForgetSessionRecord { record_id } => shared
14888            .record(record_id)
14889            .map_or(true, |record| record.environment_profile.is_some()),
14890        NodeRequest::ArmHarnessMcpReservation { .. }
14891        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14892        | NodeRequest::ActivateHarnessMcpReservation { .. }
14893        | NodeRequest::AbortHarnessMcpReservation { .. }
14894        | NodeRequest::PutHarnessMcpReplyChunk { .. }
14895        | NodeRequest::RejectHarnessMcpCall { .. }
14896        | NodeRequest::Snapshot
14897        | NodeRequest::Resync { .. }
14898        | NodeRequest::BeginDeliveryStage { .. }
14899        | NodeRequest::PutDeliveryBlobChunk { .. }
14900        | NodeRequest::CommitDeliveryStage { .. }
14901        | NodeRequest::AbortDeliveryStage { .. }
14902        | NodeRequest::BrowseHostDirectories { .. }
14903        | NodeRequest::InspectWorkspace { .. }
14904        | NodeRequest::ReadWorkspaceFile { .. }
14905        | NodeRequest::WriteWorkspaceFile { .. }
14906        | NodeRequest::CreateWorkspaceFile { .. }
14907        | NodeRequest::CreateWorkspaceDirectory { .. }
14908        | NodeRequest::ReadGitHistory { .. }
14909        | NodeRequest::ReadGitDiff { .. }
14910        | NodeRequest::AcquireController { .. }
14911        | NodeRequest::ReleaseController
14912        | NodeRequest::Spawn { .. }
14913        | NodeRequest::SpawnWithOptions { .. }
14914        | NodeRequest::SpawnSpec { .. }
14915        | NodeRequest::RegisterWorkspace { .. }
14916        | NodeRequest::CreateStandaloneWorkspace { .. }
14917        | NodeRequest::UnregisterWorkspace { .. }
14918        | NodeRequest::CreateWorktree { .. }
14919        | NodeRequest::RemoveWorktree { .. }
14920        | NodeRequest::SpawnManagedWorktree { .. }
14921        | NodeRequest::SpawnManagedWorktreeV2 { .. }
14922        | NodeRequest::CleanupManagedWorktree { .. }
14923        | NodeRequest::IndexProviderSession { .. }
14924        | NodeRequest::IndexNativeSession { .. }
14925        | NodeRequest::CatalogNativeSessions { .. }
14926        | NodeRequest::PageNativeSessions { .. }
14927        | NodeRequest::PreviewNativeSession { .. }
14928        | NodeRequest::PreviewSessionRecord { .. }
14929        | NodeRequest::ForgetContextPack { .. }
14930        | NodeRequest::ResolveDurableContextPack { .. }
14931        | NodeRequest::ReadContextPack { .. }
14932        | NodeRequest::Shutdown => false,
14933    }
14934}
14935
14936fn request_requires_session_bundle(shared: &NodeShared, request: &NodeRequest) -> bool {
14937    if let Some(spec) = match request {
14938        NodeRequest::SpawnSpec { spec }
14939        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14940        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14941        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14942        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14943        _ => None,
14944    } {
14945        return shared
14946            .resolve_spawn_spec(spec)
14947            .is_ok_and(|resolved| resolved.bundle_id.is_some());
14948    }
14949    match request {
14950        NodeRequest::Resume { session, .. }
14951        | NodeRequest::Prompt { session, .. }
14952        | NodeRequest::Paste { session, .. }
14953        | NodeRequest::Input { session, .. }
14954        | NodeRequest::TerminalBytes { session, .. }
14955        | NodeRequest::TerminalControl { session, .. }
14956        | NodeRequest::Resize { session, .. }
14957        | NodeRequest::Interrupt { session }
14958        | NodeRequest::Stop { session, .. }
14959        | NodeRequest::Remove { session }
14960        | NodeRequest::ResolveInteraction { session, .. }
14961        | NodeRequest::SetSessionMode { session, .. }
14962        | NodeRequest::SetSessionConfigOption { session, .. }
14963        | NodeRequest::SetSessionModel { session, .. }
14964        | NodeRequest::DiscoverHistory { session, .. }
14965        | NodeRequest::LoadHistory { session, .. }
14966        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14967        | NodeRequest::ExportContextPack { session } => shared
14968            .session_bindings
14969            .lock()
14970            .unwrap_or_else(|poisoned| poisoned.into_inner())
14971            .get(&session.session.instance_id)
14972            .filter(|binding| {
14973                binding.workspace_id == session.workspace_id
14974                    && binding.generation == session.session.generation
14975            })
14976            .map_or(true, |binding| binding.bundle.is_some()),
14977        NodeRequest::RenameSessionRecord { record_id, .. }
14978        | NodeRequest::SetSessionTask { record_id, .. }
14979        | NodeRequest::ResumeSessionRecord { record_id, .. }
14980        | NodeRequest::ForgetSessionRecord { record_id } => shared
14981            .record(record_id)
14982            .map_or(true, |record| record.bundle.is_some()),
14983        NodeRequest::ArmHarnessMcpReservation { .. }
14984        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14985        | NodeRequest::ActivateHarnessMcpReservation { .. }
14986        | NodeRequest::AbortHarnessMcpReservation { .. }
14987        | NodeRequest::PutHarnessMcpReplyChunk { .. }
14988        | NodeRequest::RejectHarnessMcpCall { .. }
14989        | NodeRequest::Snapshot
14990        | NodeRequest::Resync { .. }
14991        | NodeRequest::BeginDeliveryStage { .. }
14992        | NodeRequest::PutDeliveryBlobChunk { .. }
14993        | NodeRequest::CommitDeliveryStage { .. }
14994        | NodeRequest::AbortDeliveryStage { .. }
14995        | NodeRequest::BrowseHostDirectories { .. }
14996        | NodeRequest::InspectWorkspace { .. }
14997        | NodeRequest::ReadWorkspaceFile { .. }
14998        | NodeRequest::WriteWorkspaceFile { .. }
14999        | NodeRequest::CreateWorkspaceFile { .. }
15000        | NodeRequest::CreateWorkspaceDirectory { .. }
15001        | NodeRequest::ReadGitHistory { .. }
15002        | NodeRequest::ReadGitDiff { .. }
15003        | NodeRequest::AcquireController { .. }
15004        | NodeRequest::ReleaseController
15005        | NodeRequest::Spawn { .. }
15006        | NodeRequest::SpawnWithOptions { .. }
15007        | NodeRequest::SpawnSpec { .. }
15008        | NodeRequest::RegisterWorkspace { .. }
15009        | NodeRequest::CreateStandaloneWorkspace { .. }
15010        | NodeRequest::UnregisterWorkspace { .. }
15011        | NodeRequest::CreateWorktree { .. }
15012        | NodeRequest::RemoveWorktree { .. }
15013        | NodeRequest::SpawnManagedWorktree { .. }
15014        | NodeRequest::SpawnManagedWorktreeV2 { .. }
15015        | NodeRequest::CleanupManagedWorktree { .. }
15016        | NodeRequest::IndexProviderSession { .. }
15017        | NodeRequest::IndexNativeSession { .. }
15018        | NodeRequest::CatalogNativeSessions { .. }
15019        | NodeRequest::PageNativeSessions { .. }
15020        | NodeRequest::PreviewNativeSession { .. }
15021        | NodeRequest::PreviewSessionRecord { .. }
15022        | NodeRequest::ForgetContextPack { .. }
15023        | NodeRequest::ResolveDurableContextPack { .. }
15024        | NodeRequest::ReadContextPack { .. }
15025        | NodeRequest::Shutdown => false,
15026    }
15027}
15028
15029fn request_requires_history_context_pack(shared: &NodeShared, request: &NodeRequest) -> bool {
15030    if request.requires_history_context_pack_capability() {
15031        return true;
15032    }
15033    if let Some(spec) = match request {
15034        NodeRequest::SpawnSpec { spec }
15035        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
15036        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
15037        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
15038        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
15039        _ => None,
15040    } {
15041        return shared
15042            .resolve_spawn_spec(spec)
15043            .map_or(true, |resolved| resolved.context_id.is_some());
15044    }
15045    match request {
15046        NodeRequest::Resume { session, .. }
15047        | NodeRequest::Prompt { session, .. }
15048        | NodeRequest::Paste { session, .. }
15049        | NodeRequest::Input { session, .. }
15050        | NodeRequest::TerminalBytes { session, .. }
15051        | NodeRequest::TerminalControl { session, .. }
15052        | NodeRequest::Resize { session, .. }
15053        | NodeRequest::Interrupt { session }
15054        | NodeRequest::Stop { session, .. }
15055        | NodeRequest::Remove { session }
15056        | NodeRequest::ResolveInteraction { session, .. }
15057        | NodeRequest::SetSessionMode { session, .. }
15058        | NodeRequest::SetSessionConfigOption { session, .. }
15059        | NodeRequest::SetSessionModel { session, .. } => shared
15060            .session_bindings
15061            .lock()
15062            .unwrap_or_else(|poisoned| poisoned.into_inner())
15063            .get(&session.session.instance_id)
15064            .filter(|binding| {
15065                binding.workspace_id == session.workspace_id
15066                    && binding.generation == session.session.generation
15067            })
15068            .map_or(true, |binding| binding.context.is_some()),
15069        NodeRequest::RenameSessionRecord { record_id, .. }
15070        | NodeRequest::SetSessionTask { record_id, .. }
15071        | NodeRequest::ResumeSessionRecord { record_id, .. }
15072        | NodeRequest::ForgetSessionRecord { record_id } => shared
15073            .record(record_id)
15074            .map_or(true, |record| record.context.is_some()),
15075        NodeRequest::ArmHarnessMcpReservation { .. }
15076        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
15077        | NodeRequest::ActivateHarnessMcpReservation { .. }
15078        | NodeRequest::AbortHarnessMcpReservation { .. }
15079        | NodeRequest::PutHarnessMcpReplyChunk { .. }
15080        | NodeRequest::RejectHarnessMcpCall { .. }
15081        | NodeRequest::Snapshot
15082        | NodeRequest::Resync { .. }
15083        | NodeRequest::BeginDeliveryStage { .. }
15084        | NodeRequest::PutDeliveryBlobChunk { .. }
15085        | NodeRequest::CommitDeliveryStage { .. }
15086        | NodeRequest::AbortDeliveryStage { .. }
15087        | NodeRequest::BrowseHostDirectories { .. }
15088        | NodeRequest::InspectWorkspace { .. }
15089        | NodeRequest::ReadWorkspaceFile { .. }
15090        | NodeRequest::WriteWorkspaceFile { .. }
15091        | NodeRequest::CreateWorkspaceFile { .. }
15092        | NodeRequest::CreateWorkspaceDirectory { .. }
15093        | NodeRequest::ReadGitHistory { .. }
15094        | NodeRequest::ReadGitDiff { .. }
15095        | NodeRequest::AcquireController { .. }
15096        | NodeRequest::ReleaseController
15097        | NodeRequest::Spawn { .. }
15098        | NodeRequest::SpawnWithOptions { .. }
15099        | NodeRequest::SpawnSpec { .. }
15100        | NodeRequest::RegisterWorkspace { .. }
15101        | NodeRequest::CreateStandaloneWorkspace { .. }
15102        | NodeRequest::UnregisterWorkspace { .. }
15103        | NodeRequest::CreateWorktree { .. }
15104        | NodeRequest::RemoveWorktree { .. }
15105        | NodeRequest::SpawnManagedWorktree { .. }
15106        | NodeRequest::SpawnManagedWorktreeV2 { .. }
15107        | NodeRequest::CleanupManagedWorktree { .. }
15108        | NodeRequest::IndexProviderSession { .. }
15109        | NodeRequest::IndexNativeSession { .. }
15110        | NodeRequest::CatalogNativeSessions { .. }
15111        | NodeRequest::PageNativeSessions { .. }
15112        | NodeRequest::PreviewNativeSession { .. }
15113        | NodeRequest::PreviewSessionRecord { .. }
15114        | NodeRequest::DiscoverHistory { .. }
15115        | NodeRequest::LoadHistory { .. }
15116        | NodeRequest::ExportContextPackForSessionRecord { .. }
15117        | NodeRequest::ExportContextPack { .. }
15118        | NodeRequest::ForgetContextPack { .. }
15119        | NodeRequest::ResolveDurableContextPack { .. }
15120        | NodeRequest::ReadContextPack { .. }
15121        | NodeRequest::Shutdown => false,
15122    }
15123}
15124
15125fn request_requires_open_provider_ids_with(
15126    request: &NodeRequest,
15127    provider_for_session: impl Fn(&SessionAddress) -> Option<AgentId>,
15128    provider_for_record: impl Fn(&SessionRecordId) -> Option<AgentId>,
15129) -> bool {
15130    match request {
15131        NodeRequest::Spawn { provider, .. } | NodeRequest::SpawnWithOptions { provider, .. } => !provider_id_is_legacy(provider),
15132        NodeRequest::IndexProviderSession { provider, .. } => !provider_id_is_legacy(provider),
15133        NodeRequest::CatalogNativeSessions { route, .. }
15134        | NodeRequest::PageNativeSessions { route, .. } => {
15135            !provider_id_is_legacy(&route.provider)
15136        }
15137        NodeRequest::PreviewNativeSession { selection, .. }
15138        | NodeRequest::IndexNativeSession { selection, .. } => {
15139            !provider_id_is_legacy(&selection.route.provider)
15140        }
15141        NodeRequest::SpawnSpec { .. }
15142        | NodeRequest::SpawnSpecWithHarnessMcp { .. } => false,
15143        NodeRequest::SpawnManagedWorktree { .. }
15144        | NodeRequest::SpawnManagedWorktreeV2 { .. } => false,
15145        NodeRequest::Resume { session, .. }
15146        | NodeRequest::Prompt { session, .. }
15147        | NodeRequest::Paste { session, .. }
15148        | NodeRequest::Input { session, .. }
15149        | NodeRequest::TerminalBytes { session, .. }
15150        | NodeRequest::TerminalControl { session, .. }
15151        | NodeRequest::Resize { session, .. }
15152        | NodeRequest::Interrupt { session }
15153        | NodeRequest::Stop { session, .. }
15154        | NodeRequest::Remove { session }
15155        | NodeRequest::ResolveInteraction { session, .. }
15156        | NodeRequest::SetSessionMode { session, .. }
15157        | NodeRequest::SetSessionConfigOption { session, .. }
15158        | NodeRequest::SetSessionModel { session, .. }
15159        | NodeRequest::DiscoverHistory { session, .. }
15160        | NodeRequest::LoadHistory { session, .. }
15161        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
15162        | NodeRequest::ExportContextPack { session } => provider_for_session(session)
15163            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
15164        NodeRequest::RenameSessionRecord { record_id, .. }
15165        | NodeRequest::SetSessionTask { record_id, .. }
15166        | NodeRequest::ResumeSessionRecord { record_id, .. }
15167        | NodeRequest::ForgetSessionRecord { record_id } => provider_for_record(record_id)
15168            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
15169        NodeRequest::PreviewSessionRecord { record_id, .. } => provider_for_record(record_id)
15170            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
15171        NodeRequest::ArmHarnessMcpReservation { .. }
15172        | NodeRequest::ActivateHarnessMcpReservation { .. }
15173        | NodeRequest::AbortHarnessMcpReservation { .. }
15174        | NodeRequest::PutHarnessMcpReplyChunk { .. }
15175        | NodeRequest::RejectHarnessMcpCall { .. }
15176        | NodeRequest::Snapshot
15177        | NodeRequest::Resync { .. }
15178        | NodeRequest::BeginDeliveryStage { .. }
15179        | NodeRequest::PutDeliveryBlobChunk { .. }
15180        | NodeRequest::CommitDeliveryStage { .. }
15181        | NodeRequest::AbortDeliveryStage { .. }
15182        | NodeRequest::BrowseHostDirectories { .. }
15183        | NodeRequest::InspectWorkspace { .. }
15184        | NodeRequest::ReadWorkspaceFile { .. }
15185        | NodeRequest::WriteWorkspaceFile { .. }
15186        | NodeRequest::CreateWorkspaceFile { .. }
15187        | NodeRequest::CreateWorkspaceDirectory { .. }
15188        | NodeRequest::ReadGitHistory { .. }
15189        | NodeRequest::ReadGitDiff { .. }
15190        | NodeRequest::AcquireController { .. }
15191        | NodeRequest::ReleaseController
15192        | NodeRequest::RegisterWorkspace { .. }
15193        | NodeRequest::CreateStandaloneWorkspace { .. }
15194        | NodeRequest::UnregisterWorkspace { .. }
15195        | NodeRequest::CreateWorktree { .. }
15196        | NodeRequest::RemoveWorktree { .. }
15197        | NodeRequest::CleanupManagedWorktree { .. }
15198        | NodeRequest::Shutdown => false,
15199        NodeRequest::ForgetContextPack { .. } => true,
15200        // No session/record to cheaply resolve a provider from here either;
15201        // the resolved receipt can carry an open-id provider, so require the
15202        // same capability as ForgetContextPack unconditionally. `ReadContextPack`
15203        // is the same digest-keyed lookup one step further into the same
15204        // catalog, so it inherits the same conservative answer.
15205        NodeRequest::ResolveDurableContextPack { .. }
15206        | NodeRequest::ReadContextPack { .. } => true,
15207    }
15208}
15209
15210fn project_snapshot_legacy_provider_ids(snapshot: &mut NodeSnapshot) {
15211    snapshot.enabled_providers.retain(provider_id_is_legacy);
15212    snapshot.provider_runtime_statuses = ProviderRuntimeStatuses::new(
15213        snapshot
15214            .provider_runtime_statuses
15215            .as_slice()
15216            .iter()
15217            .filter(|status| provider_id_is_legacy(status.provider()))
15218            .cloned(),
15219    )
15220    .expect("a filtered provider runtime projection remains valid");
15221    snapshot
15222        .session_records
15223        .retain(|record| provider_id_is_legacy(&record.provider));
15224    for workspace in &mut snapshot.workspaces {
15225        workspace
15226            .sessions
15227            .retain(|session| provider_id_is_legacy(&session.agent_id));
15228    }
15229}
15230
15231fn clear_snapshot_child_environment_profiles(snapshot: &mut NodeSnapshot) {
15232    for record in &mut snapshot.session_records {
15233        record.environment_profile = None;
15234    }
15235    if let Some(profiles) = snapshot
15236        .launch_inventory
15237        .as_mut()
15238        .and_then(|inventory| inventory.spawn_profiles.as_mut())
15239    {
15240        for profile in profiles {
15241            profile.environment_profile = None;
15242        }
15243    }
15244}
15245
15246fn project_event_without_child_environment_profile(
15247    mut envelope: NodeEventEnvelope,
15248) -> NodeEventEnvelope {
15249    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15250        record.environment_profile = None;
15251    }
15252    envelope
15253}
15254
15255fn project_response_without_child_environment_profile(reply: &mut ResponseEnvelope) {
15256    let contains_environment_profile = match reply.result.as_ref() {
15257        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
15258            receipt.environment_profile.is_some()
15259        }
15260        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15261            receipt.spawn.environment_profile.is_some()
15262        }
15263        _ => false,
15264    };
15265    if contains_environment_profile {
15266        reply.result = Err(failure(
15267            NodeFailureCode::UnsupportedCapability,
15268            "child environment profiles were not negotiated",
15269        ));
15270        return;
15271    }
15272    let Ok(response) = reply.result.as_mut() else {
15273        return;
15274    };
15275    match response {
15276        NodeResponse::Snapshot { snapshot, .. } => {
15277            clear_snapshot_child_environment_profiles(snapshot);
15278        }
15279        NodeResponse::Resync { snapshot, events, .. } => {
15280            clear_snapshot_child_environment_profiles(snapshot);
15281            for event in events {
15282                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15283                    record.environment_profile = None;
15284                }
15285            }
15286        }
15287        NodeResponse::SessionRecordUpdated { record }
15288        | NodeResponse::ProviderSessionIndexed { record }
15289        | NodeResponse::NativeSessionIndexed { record, .. }
15290        | NodeResponse::SessionRecordResumed { record, .. } => {
15291            record.environment_profile = None;
15292        }
15293        NodeResponse::Armed { .. }
15294        | NodeResponse::Spawned { .. }
15295        | NodeResponse::Activated { .. }
15296        | NodeResponse::Aborted { .. }
15297        | NodeResponse::ReplyChunkAccepted { .. }
15298        | NodeResponse::CallRejected { .. }
15299        | NodeResponse::DeliveryStageBegun { .. }
15300        | NodeResponse::DeliveryBlobChunkAccepted { .. }
15301        | NodeResponse::DeliveryCommitted { .. }
15302        | NodeResponse::DeliveryStageAborted { .. }
15303        | NodeResponse::WorkspaceInspected { .. }
15304        | NodeResponse::HostDirectoriesBrowsed { .. }
15305        | NodeResponse::WorkspaceFileRead { .. }
15306        | NodeResponse::WorkspaceFileWritten { .. }
15307        | NodeResponse::WorkspaceFileCreated { .. }
15308        | NodeResponse::WorkspaceDirectoryCreated { .. }
15309        | NodeResponse::GitHistoryRead { .. }
15310        | NodeResponse::GitDiffRead { .. }
15311        | NodeResponse::Controller { .. }
15312        | NodeResponse::SpawnAccepted { .. }
15313        | NodeResponse::SpawnSpecAccepted { .. }
15314        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15315        | NodeResponse::ManagedWorktreeCleanup { .. }
15316        | NodeResponse::NativeSessionsCataloged { .. }
15317        | NodeResponse::NativeSessionsPaged { .. }
15318        | NodeResponse::NativeSessionPreviewed { .. }
15319        | NodeResponse::SessionRecordPreviewed { .. }
15320        | NodeResponse::HistoryDiscovered { .. }
15321        | NodeResponse::HistoryLoaded { .. }
15322        | NodeResponse::ContextPackForSessionRecordExported { .. }
15323        | NodeResponse::ContextPackExported { .. }
15324        | NodeResponse::ContextPackForgotten { .. }
15325        | NodeResponse::DurableContextPackResolved { .. }
15326        | NodeResponse::ContextPackBytesRead { .. }
15327        | NodeResponse::SessionRecordForgotten { .. }
15328        | NodeResponse::WorkspaceRegistered { .. }
15329        | NodeResponse::StandaloneWorkspaceCreated { .. }
15330        | NodeResponse::WorkspaceUnregistered { .. }
15331        | NodeResponse::WorktreeCreated { .. }
15332        | NodeResponse::WorktreeRemoved { .. }
15333        | NodeResponse::Accepted
15334        | NodeResponse::ShuttingDown => {}
15335    }
15336}
15337
15338fn clear_snapshot_session_bundles(snapshot: &mut NodeSnapshot) {
15339    for record in &mut snapshot.session_records {
15340        record.bundle = None;
15341    }
15342}
15343
15344fn project_event_without_session_bundle(
15345    mut envelope: NodeEventEnvelope,
15346) -> NodeEventEnvelope {
15347    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15348        record.bundle = None;
15349    }
15350    envelope
15351}
15352
15353fn project_response_without_session_bundle(reply: &mut ResponseEnvelope) {
15354    let contains_bundle = match reply.result.as_ref() {
15355        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.bundle.is_some(),
15356        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15357            receipt.spawn.bundle.is_some()
15358        }
15359        _ => false,
15360    };
15361    if contains_bundle {
15362        reply.result = Err(failure(
15363            NodeFailureCode::UnsupportedCapability,
15364            "session bundles were not negotiated",
15365        ));
15366        return;
15367    }
15368    let Ok(response) = reply.result.as_mut() else {
15369        return;
15370    };
15371    match response {
15372        NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_session_bundles(snapshot),
15373        NodeResponse::Resync { snapshot, events, .. } => {
15374            clear_snapshot_session_bundles(snapshot);
15375            for event in events {
15376                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15377                    record.bundle = None;
15378                }
15379            }
15380        }
15381        NodeResponse::SessionRecordUpdated { record }
15382        | NodeResponse::ProviderSessionIndexed { record }
15383        | NodeResponse::NativeSessionIndexed { record, .. }
15384        | NodeResponse::SessionRecordResumed { record, .. } => record.bundle = None,
15385        NodeResponse::Armed { .. }
15386        | NodeResponse::Spawned { .. }
15387        | NodeResponse::Activated { .. }
15388        | NodeResponse::Aborted { .. }
15389        | NodeResponse::ReplyChunkAccepted { .. }
15390        | NodeResponse::CallRejected { .. }
15391        | NodeResponse::DeliveryStageBegun { .. }
15392        | NodeResponse::DeliveryBlobChunkAccepted { .. }
15393        | NodeResponse::DeliveryCommitted { .. }
15394        | NodeResponse::DeliveryStageAborted { .. }
15395        | NodeResponse::WorkspaceInspected { .. }
15396        | NodeResponse::HostDirectoriesBrowsed { .. }
15397        | NodeResponse::WorkspaceFileRead { .. }
15398        | NodeResponse::WorkspaceFileWritten { .. }
15399        | NodeResponse::WorkspaceFileCreated { .. }
15400        | NodeResponse::WorkspaceDirectoryCreated { .. }
15401        | NodeResponse::GitHistoryRead { .. }
15402        | NodeResponse::GitDiffRead { .. }
15403        | NodeResponse::Controller { .. }
15404        | NodeResponse::SpawnAccepted { .. }
15405        | NodeResponse::SpawnSpecAccepted { .. }
15406        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15407        | NodeResponse::ManagedWorktreeCleanup { .. }
15408        | NodeResponse::NativeSessionsCataloged { .. }
15409        | NodeResponse::NativeSessionsPaged { .. }
15410        | NodeResponse::NativeSessionPreviewed { .. }
15411        | NodeResponse::SessionRecordPreviewed { .. }
15412        | NodeResponse::HistoryDiscovered { .. }
15413        | NodeResponse::HistoryLoaded { .. }
15414        | NodeResponse::ContextPackForSessionRecordExported { .. }
15415        | NodeResponse::ContextPackExported { .. }
15416        | NodeResponse::ContextPackForgotten { .. }
15417        | NodeResponse::DurableContextPackResolved { .. }
15418        | NodeResponse::ContextPackBytesRead { .. }
15419        | NodeResponse::SessionRecordForgotten { .. }
15420        | NodeResponse::WorkspaceRegistered { .. }
15421        | NodeResponse::StandaloneWorkspaceCreated { .. }
15422        | NodeResponse::WorkspaceUnregistered { .. }
15423        | NodeResponse::WorktreeCreated { .. }
15424        | NodeResponse::WorktreeRemoved { .. }
15425        | NodeResponse::Accepted
15426        | NodeResponse::ShuttingDown => {}
15427    }
15428}
15429
15430fn project_snapshot_history_for_wire(
15431    snapshot: &mut NodeSnapshot,
15432    include_history_metadata: bool,
15433) {
15434    for workspace in &mut snapshot.workspaces {
15435        for session in &mut workspace.sessions {
15436            if include_history_metadata {
15437                session.history.loaded = None;
15438            } else {
15439                session.history = Default::default();
15440            }
15441        }
15442    }
15443}
15444
15445fn project_event_history_for_wire(
15446    mut envelope: NodeEventEnvelope,
15447    include_history_metadata: bool,
15448) -> NodeEventEnvelope {
15449    if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
15450        for session in &mut workspace.sessions {
15451            if include_history_metadata {
15452                session.history.loaded = None;
15453            } else {
15454                session.history = Default::default();
15455            }
15456        }
15457    }
15458    envelope
15459}
15460
15461fn project_response_history_for_wire(
15462    reply: &mut ResponseEnvelope,
15463    include_history_metadata: bool,
15464) {
15465    let Ok(response) = reply.result.as_mut() else {
15466        return;
15467    };
15468    match response {
15469        NodeResponse::Snapshot { snapshot, .. } => {
15470            project_snapshot_history_for_wire(snapshot, include_history_metadata);
15471        }
15472        NodeResponse::Resync {
15473            snapshot, events, ..
15474        } => {
15475            project_snapshot_history_for_wire(snapshot, include_history_metadata);
15476            for event in events {
15477                *event = project_event_history_for_wire(
15478                    event.clone(),
15479                    include_history_metadata,
15480                );
15481            }
15482        }
15483        NodeResponse::WorkspaceRegistered { workspace }
15484        | NodeResponse::StandaloneWorkspaceCreated { workspace }
15485        | NodeResponse::WorktreeCreated { workspace, .. } => {
15486            for session in &mut workspace.sessions {
15487                if include_history_metadata {
15488                    session.history.loaded = None;
15489                } else {
15490                    session.history = Default::default();
15491                }
15492            }
15493        }
15494        _ => {}
15495    }
15496}
15497
15498fn clear_snapshot_context_packs(snapshot: &mut NodeSnapshot) {
15499    for record in &mut snapshot.session_records {
15500        record.context_id = None;
15501        record.context = None;
15502        record.exported_context = None;
15503    }
15504}
15505
15506fn project_event_without_context_pack(
15507    mut envelope: NodeEventEnvelope,
15508) -> NodeEventEnvelope {
15509    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15510        record.context_id = None;
15511        record.context = None;
15512        record.exported_context = None;
15513    }
15514    envelope
15515}
15516
15517fn project_response_without_context_pack(reply: &mut ResponseEnvelope) {
15518    let contains_context = match reply.result.as_ref() {
15519        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.context.is_some(),
15520        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15521            receipt.spawn.context.is_some()
15522        }
15523        Ok(NodeResponse::HistoryDiscovered { .. })
15524        | Ok(NodeResponse::HistoryLoaded { .. })
15525        | Ok(NodeResponse::ContextPackForSessionRecordExported { .. })
15526        | Ok(NodeResponse::ContextPackExported { .. })
15527        | Ok(NodeResponse::ContextPackForgotten { .. })
15528        | Ok(NodeResponse::DurableContextPackResolved { .. }) => true,
15529        _ => false,
15530    };
15531    if contains_context {
15532        reply.result = Err(failure(
15533            NodeFailureCode::UnsupportedCapability,
15534            "history context packs were not negotiated",
15535        ));
15536        return;
15537    }
15538    let Ok(response) = reply.result.as_mut() else {
15539        return;
15540    };
15541    match response {
15542        NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_context_packs(snapshot),
15543        NodeResponse::Resync {
15544            snapshot, events, ..
15545        } => {
15546            clear_snapshot_context_packs(snapshot);
15547            for event in events {
15548                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15549                    record.context_id = None;
15550                    record.context = None;
15551                    record.exported_context = None;
15552                }
15553            }
15554        }
15555        NodeResponse::SessionRecordUpdated { record }
15556        | NodeResponse::ProviderSessionIndexed { record }
15557        | NodeResponse::NativeSessionIndexed { record, .. }
15558        | NodeResponse::SessionRecordResumed { record, .. } => {
15559            record.context_id = None;
15560            record.context = None;
15561            record.exported_context = None;
15562        }
15563        _ => {}
15564    }
15565}
15566
15567fn project_response_legacy_provider_ids(shared: &NodeShared, reply: &mut ResponseEnvelope) {
15568    let contains_open_record = match reply.result.as_ref() {
15569        Ok(NodeResponse::SessionRecordUpdated { record })
15570        | Ok(NodeResponse::ProviderSessionIndexed { record })
15571        | Ok(NodeResponse::SessionRecordResumed { record, .. }) => {
15572            !provider_id_is_legacy(&record.provider)
15573        }
15574        Ok(NodeResponse::NativeSessionIndexed { selection, record }) => {
15575            !provider_id_is_legacy(&selection.route.provider)
15576                || !provider_id_is_legacy(&record.provider)
15577        }
15578        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
15579            !provider_id_is_legacy(&receipt.provider)
15580        }
15581        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15582            !provider_id_is_legacy(&receipt.spawn.provider)
15583        }
15584        Ok(NodeResponse::NativeSessionsCataloged { route, .. })
15585        | Ok(NodeResponse::NativeSessionsPaged { route, .. }) => {
15586            !provider_id_is_legacy(&route.provider)
15587        }
15588        Ok(NodeResponse::NativeSessionPreviewed { selection, .. }) => {
15589            !provider_id_is_legacy(&selection.route.provider)
15590        }
15591        _ => false,
15592    };
15593    if contains_open_record {
15594        reply.result = Err(failure(
15595            NodeFailureCode::UnsupportedCapability,
15596            "open provider IDs were not negotiated",
15597        ));
15598        return;
15599    }
15600    let Ok(response) = reply.result.as_mut() else {
15601        return;
15602    };
15603    match response {
15604        NodeResponse::Snapshot { snapshot, .. } => {
15605            project_snapshot_legacy_provider_ids(snapshot);
15606        }
15607        NodeResponse::Resync { snapshot, events, .. } => {
15608            project_snapshot_legacy_provider_ids(snapshot);
15609            *events = std::mem::take(events)
15610                .into_iter()
15611                .filter_map(|event| project_event_legacy_provider_ids(shared, event))
15612                .collect();
15613        }
15614        NodeResponse::WorkspaceRegistered { workspace }
15615        | NodeResponse::StandaloneWorkspaceCreated { workspace }
15616        | NodeResponse::WorktreeCreated { workspace, .. } => {
15617            workspace
15618                .sessions
15619                .retain(|session| provider_id_is_legacy(&session.agent_id));
15620        }
15621        NodeResponse::Armed { .. }
15622        | NodeResponse::Spawned { .. }
15623        | NodeResponse::Activated { .. }
15624        | NodeResponse::Aborted { .. }
15625        | NodeResponse::ReplyChunkAccepted { .. }
15626        | NodeResponse::CallRejected { .. }
15627        | NodeResponse::DeliveryStageBegun { .. }
15628        | NodeResponse::DeliveryBlobChunkAccepted { .. }
15629        | NodeResponse::DeliveryCommitted { .. }
15630        | NodeResponse::DeliveryStageAborted { .. }
15631        | NodeResponse::WorkspaceInspected { .. }
15632        | NodeResponse::HostDirectoriesBrowsed { .. }
15633        | NodeResponse::WorkspaceFileRead { .. }
15634        | NodeResponse::WorkspaceFileWritten { .. }
15635        | NodeResponse::WorkspaceFileCreated { .. }
15636        | NodeResponse::WorkspaceDirectoryCreated { .. }
15637        | NodeResponse::GitHistoryRead { .. }
15638        | NodeResponse::GitDiffRead { .. }
15639        | NodeResponse::Controller { .. }
15640        | NodeResponse::SpawnAccepted { .. }
15641        | NodeResponse::SpawnSpecAccepted { .. }
15642        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15643        | NodeResponse::ManagedWorktreeCleanup { .. }
15644        | NodeResponse::NativeSessionsCataloged { .. }
15645        | NodeResponse::NativeSessionsPaged { .. }
15646        | NodeResponse::NativeSessionPreviewed { .. }
15647        | NodeResponse::SessionRecordPreviewed { .. }
15648        | NodeResponse::HistoryDiscovered { .. }
15649        | NodeResponse::HistoryLoaded { .. }
15650        | NodeResponse::ContextPackForSessionRecordExported { .. }
15651        | NodeResponse::ContextPackExported { .. }
15652        | NodeResponse::ContextPackForgotten { .. }
15653        | NodeResponse::DurableContextPackResolved { .. }
15654        | NodeResponse::ContextPackBytesRead { .. }
15655        | NodeResponse::SessionRecordUpdated { .. }
15656        | NodeResponse::ProviderSessionIndexed { .. }
15657        | NodeResponse::NativeSessionIndexed { .. }
15658        | NodeResponse::SessionRecordResumed { .. }
15659        | NodeResponse::SessionRecordForgotten { .. }
15660        | NodeResponse::WorkspaceUnregistered { .. }
15661        | NodeResponse::WorktreeRemoved { .. }
15662        | NodeResponse::Accepted
15663        | NodeResponse::ShuttingDown => {}
15664    }
15665}
15666
15667fn project_event_legacy_provider_ids(
15668    shared: &NodeShared,
15669    mut envelope: NodeEventEnvelope,
15670) -> Option<NodeEventEnvelope> {
15671    let include = match &mut envelope.event {
15672        NodeEvent::HarnessMcpReadCall { .. } => true,
15673        NodeEvent::Control { address, .. }
15674        | NodeEvent::AgentStream { address, .. }
15675        | NodeEvent::TerminalFrame { address, .. } => shared
15676            .validate_address(address)
15677            .is_ok_and(|provider| provider_id_is_legacy(&provider)),
15678        NodeEvent::WorkspaceAdded { workspace } => {
15679            workspace
15680                .sessions
15681                .retain(|session| provider_id_is_legacy(&session.agent_id));
15682            true
15683        }
15684        NodeEvent::SessionRecordUpserted { record } => {
15685            provider_id_is_legacy(&record.provider)
15686        }
15687        NodeEvent::SessionRecordHistorySummarized { .. } => false,
15688        NodeEvent::SessionRecordRemoved { .. } => shared
15689            .history
15690            .lock()
15691            .unwrap_or_else(|poisoned| poisoned.into_inner())
15692            .removed_record_providers
15693            .get(&envelope.sequence)
15694            .is_some_and(provider_id_is_legacy),
15695        NodeEvent::ControllerChanged { .. }
15696        | NodeEvent::ManagedWorktreeUpserted { .. }
15697        | NodeEvent::ManagedWorktreeRemoved { .. }
15698        | NodeEvent::WorkspaceRemoved { .. }
15699        | NodeEvent::ResyncRequired { .. } => true,
15700    };
15701    include.then_some(envelope)
15702}
15703
15704fn clear_response_provider_runtime_status(reply: &mut ResponseEnvelope) {
15705    let Ok(response) = reply.result.as_mut() else {
15706        return;
15707    };
15708    match response {
15709        NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
15710            snapshot.provider_runtime_statuses.clear();
15711        }
15712        NodeResponse::Armed { .. }
15713        | NodeResponse::Spawned { .. }
15714        | NodeResponse::Activated { .. }
15715        | NodeResponse::Aborted { .. }
15716        | NodeResponse::ReplyChunkAccepted { .. }
15717        | NodeResponse::CallRejected { .. }
15718        | NodeResponse::DeliveryStageBegun { .. }
15719        | NodeResponse::DeliveryBlobChunkAccepted { .. }
15720        | NodeResponse::DeliveryCommitted { .. }
15721        | NodeResponse::DeliveryStageAborted { .. }
15722        | NodeResponse::WorkspaceInspected { .. }
15723        | NodeResponse::HostDirectoriesBrowsed { .. }
15724        | NodeResponse::WorkspaceFileRead { .. }
15725        | NodeResponse::WorkspaceFileWritten { .. }
15726        | NodeResponse::WorkspaceFileCreated { .. }
15727        | NodeResponse::WorkspaceDirectoryCreated { .. }
15728        | NodeResponse::GitHistoryRead { .. }
15729        | NodeResponse::GitDiffRead { .. }
15730        | NodeResponse::Controller { .. }
15731        | NodeResponse::SpawnAccepted { .. }
15732        | NodeResponse::SpawnSpecAccepted { .. }
15733        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15734        | NodeResponse::ManagedWorktreeCleanup { .. }
15735        | NodeResponse::NativeSessionsCataloged { .. }
15736        | NodeResponse::NativeSessionsPaged { .. }
15737        | NodeResponse::NativeSessionPreviewed { .. }
15738        | NodeResponse::SessionRecordPreviewed { .. }
15739        | NodeResponse::HistoryDiscovered { .. }
15740        | NodeResponse::HistoryLoaded { .. }
15741        | NodeResponse::ContextPackForSessionRecordExported { .. }
15742        | NodeResponse::ContextPackExported { .. }
15743        | NodeResponse::ContextPackForgotten { .. }
15744        | NodeResponse::DurableContextPackResolved { .. }
15745        | NodeResponse::ContextPackBytesRead { .. }
15746        | NodeResponse::SessionRecordUpdated { .. }
15747        | NodeResponse::ProviderSessionIndexed { .. }
15748        | NodeResponse::NativeSessionIndexed { .. }
15749        | NodeResponse::SessionRecordResumed { .. }
15750        | NodeResponse::SessionRecordForgotten { .. }
15751        | NodeResponse::WorkspaceRegistered { .. }
15752        | NodeResponse::StandaloneWorkspaceCreated { .. }
15753        | NodeResponse::WorkspaceUnregistered { .. }
15754        | NodeResponse::WorktreeCreated { .. }
15755        | NodeResponse::WorktreeRemoved { .. }
15756        | NodeResponse::Accepted
15757        | NodeResponse::ShuttingDown => {}
15758    }
15759}
15760
15761struct ControllerReleaseGuard {
15762    shared: Arc<NodeShared>,
15763    connection_id: u64,
15764}
15765
15766impl Drop for ControllerReleaseGuard {
15767    fn drop(&mut self) {
15768        self.shared.release_controller(self.connection_id);
15769    }
15770}
15771
15772struct AbortTaskOnDrop(AbortHandle);
15773
15774impl Drop for AbortTaskOnDrop {
15775    fn drop(&mut self) {
15776        self.0.abort();
15777    }
15778}
15779
15780/// Reads an unsigned env-config override, following the pattern of every
15781/// other bounded knob in this module: missing/unparsable/zero falls back to
15782/// `default`, and the resolved value is always clamped to `max` so a bad
15783/// override cannot reopen a bound this budget exists to enforce.
15784fn env_config_bounded(name: &str, default: u64, max: u64) -> u64 {
15785    std::env::var(name)
15786        .ok()
15787        .and_then(|value| value.parse::<u64>().ok())
15788        .filter(|value| *value > 0)
15789        .unwrap_or(default)
15790        .min(max)
15791}
15792
15793fn workspace_inspection_time_budget_ms() -> u64 {
15794    env_config_bounded(
15795        "GATE4AGENT_NODE_WORKSPACE_INSPECTION_BUDGET_MS",
15796        WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT,
15797        WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX,
15798    )
15799}
15800
15801fn workspace_inspection_entry_cap() -> usize {
15802    env_config_bounded(
15803        "GATE4AGENT_NODE_WORKSPACE_INSPECTION_ENTRY_CAP",
15804        WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT as u64,
15805        WORKSPACE_INSPECTION_ENTRY_CAP_MAX as u64,
15806    ) as usize
15807}
15808
15809/// Tracks the shared walk-side budget across the whole recursive walk: a
15810/// wall-clock deadline (shared with the git phase that runs after the walk)
15811/// and a cap on directory entries visited, whether or not they were pushed
15812/// to the response. Cheap to check per entry — one `Instant::now()` and one
15813/// integer compare.
15814struct WorkspaceWalkBudget {
15815    deadline: Instant,
15816    entry_cap: usize,
15817    entries_visited: u64,
15818    time_budget_exceeded: bool,
15819    entry_cap_exceeded: bool,
15820}
15821
15822impl WorkspaceWalkBudget {
15823    fn new(deadline: Instant, entry_cap: usize) -> Self {
15824        Self {
15825            deadline,
15826            entry_cap,
15827            entries_visited: 0,
15828            time_budget_exceeded: false,
15829            entry_cap_exceeded: false,
15830        }
15831    }
15832
15833    fn is_exhausted(&self) -> bool {
15834        self.time_budget_exceeded || self.entry_cap_exceeded
15835    }
15836
15837    /// Records one visited directory entry (pushed to the response or not)
15838    /// and reports whether the walk must stop now.
15839    fn record_visit(&mut self) -> bool {
15840        self.entries_visited += 1;
15841        if self.entries_visited as usize >= self.entry_cap {
15842            self.entry_cap_exceeded = true;
15843        }
15844        if Instant::now() >= self.deadline {
15845            self.time_budget_exceeded = true;
15846        }
15847        self.is_exhausted()
15848    }
15849}
15850
15851fn collect_workspace_entries(
15852    root: &Path,
15853    deadline: Instant,
15854    entry_cap: usize,
15855) -> (Vec<WorkspaceEntry>, bool, WorkspaceWalkBudget) {
15856    let mut entries = Vec::new();
15857    let mut truncated = false;
15858    let mut budget = WorkspaceWalkBudget::new(deadline, entry_cap);
15859    walk_workspace_directory(root, None, 0, &mut entries, &mut truncated, &mut budget);
15860    // DFS pre-order emits `x/child` between prefix-named siblings `x` and
15861    // `x-y` ('-' sorts below '/'), while the bounded operator projections
15862    // require strict global byte order over relative paths.
15863    entries.sort_by(|left, right| left.relative_path.as_utf8().cmp(&right.relative_path.as_utf8()));
15864    (entries, truncated, budget)
15865}
15866
15867/// Builds the additive `WorkspaceInspectionTruncationV1` for an
15868/// `inspect_workspace` response — `None` once the walk and git phase both
15869/// completed inside their shared budget.
15870fn workspace_inspection_truncation(
15871    walk_budget: &WorkspaceWalkBudget,
15872    git_time_budget_exceeded: bool,
15873    elapsed: Duration,
15874) -> Option<WorkspaceInspectionTruncationV1> {
15875    if !walk_budget.time_budget_exceeded
15876        && !walk_budget.entry_cap_exceeded
15877        && !git_time_budget_exceeded
15878    {
15879        return None;
15880    }
15881    Some(WorkspaceInspectionTruncationV1 {
15882        walk_time_budget_exceeded: walk_budget.time_budget_exceeded,
15883        walk_entry_cap_exceeded: walk_budget.entry_cap_exceeded,
15884        git_time_budget_exceeded,
15885        entries_visited: walk_budget.entries_visited,
15886        elapsed_ms: u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX),
15887    })
15888}
15889
15890fn walk_workspace_directory(
15891    directory: &Path,
15892    relative_directory: Option<&RepositoryPath>,
15893    depth: usize,
15894    entries: &mut Vec<WorkspaceEntry>,
15895    truncated: &mut bool,
15896    budget: &mut WorkspaceWalkBudget,
15897) {
15898    if budget.is_exhausted() {
15899        *truncated = true;
15900        return;
15901    }
15902    let read_dir = match std::fs::read_dir(directory) {
15903        Ok(read_dir) => read_dir,
15904        Err(_) => {
15905            *truncated = true;
15906            return;
15907        }
15908    };
15909    let mut children = Vec::new();
15910    for child in read_dir {
15911        match child {
15912            Ok(child) => children.push(child),
15913            Err(_) => *truncated = true,
15914        }
15915    }
15916    children.sort_by_key(|child| child.file_name());
15917    for child in children {
15918        if budget.record_visit() {
15919            *truncated = true;
15920            return;
15921        }
15922        if entries.len() >= WORKSPACE_TREE_MAX_ENTRIES {
15923            *truncated = true;
15924            return;
15925        }
15926        let file_type = match child.file_type() {
15927            Ok(file_type) => file_type,
15928            Err(_) => {
15929                *truncated = true;
15930                continue;
15931            }
15932        };
15933        if file_type.is_symlink() {
15934            continue;
15935        }
15936        let name = match child.file_name().into_string() {
15937            Ok(name) => name,
15938            Err(_) => {
15939                *truncated = true;
15940                continue;
15941            }
15942        };
15943        if file_type.is_dir() && is_skipped_workspace_directory(&name, &child.path()) {
15944            continue;
15945        }
15946        let relative_path = match relative_directory {
15947            None => windows_repository_path(name),
15948            Some(parent) => parent
15949                .as_utf8()
15950                .and_then(|parent| windows_repository_path(format!("{parent}/{name}"))),
15951        };
15952        let Some(relative_path) = relative_path else {
15953            *truncated = true;
15954            continue;
15955        };
15956        let kind = if file_type.is_dir() {
15957            WorkspaceEntryKind::Directory
15958        } else if file_type.is_file() {
15959            WorkspaceEntryKind::File
15960        } else {
15961            continue;
15962        };
15963        entries.push(WorkspaceEntry {
15964            relative_path: relative_path.clone(),
15965            kind,
15966        });
15967        if file_type.is_dir() {
15968            if depth + 1 < WORKSPACE_TREE_MAX_DEPTH {
15969                walk_workspace_directory(
15970                    &child.path(),
15971                    Some(&relative_path),
15972                    depth + 1,
15973                    entries,
15974                    truncated,
15975                    budget,
15976                );
15977            } else {
15978                *truncated = true;
15979            }
15980        }
15981    }
15982}
15983
15984fn windows_repository_path(value: String) -> Option<RepositoryPath> {
15985    if value.contains('\\') {
15986        return None;
15987    }
15988    RepositoryPath::utf8(value).ok()
15989}
15990
15991fn windows_repository_path_from_bytes(value: &[u8]) -> Option<RepositoryPath> {
15992    windows_repository_path(std::str::from_utf8(value).ok()?.to_owned())
15993}
15994
15995/// Excludes a directory from the workspace tree walk. The three literal
15996/// names are the common convention-based cases; the `CACHEDIR.TAG` check
15997/// beneath them catches every build-output directory regardless of name
15998/// (cargo writes `CACHEDIR.TAG` into every `--target-dir` it creates, so
15999/// this covers `target-a2`, `target-a2-tui`, and any future renamed build
16000/// directory without maintaining a name heuristic list). The name check
16001/// runs first and returns early so a directory rejected by name never pays
16002/// for the extra `CACHEDIR.TAG` stat.
16003fn is_skipped_workspace_directory(name: &str, path: &Path) -> bool {
16004    if name.eq_ignore_ascii_case(".git")
16005        || name.eq_ignore_ascii_case("target")
16006        || name.eq_ignore_ascii_case("node_modules")
16007    {
16008        return true;
16009    }
16010    path.join("CACHEDIR.TAG").is_file()
16011}
16012
16013fn context_pack_source_status_is_usable(status: &SessionStatus) -> bool {
16014    matches!(
16015        status,
16016        SessionStatus::Running | SessionStatus::Exited { exit_code: Some(0) }
16017    )
16018}
16019
16020#[derive(Clone, Debug, Eq, PartialEq)]
16021enum ContextPackRepositoryHead {
16022    NotRepository,
16023    Unborn,
16024    Commit(String),
16025}
16026
16027#[derive(Clone, Debug, Eq, PartialEq)]
16028struct ContextPackRepositoryObservation {
16029    head: ContextPackRepositoryHead,
16030    git: GitSnapshot,
16031    files: Vec<ContextPackRepositoryFileSource>,
16032}
16033
16034fn stable_context_pack_repository(
16035    first: ContextPackRepositoryObservation,
16036    second: ContextPackRepositoryObservation,
16037) -> Result<ContextPackRepository, NodeFailure> {
16038    if first != second {
16039        return Err(failure(
16040            NodeFailureCode::ContextPackMaterializationFailed,
16041            "context repository changed during bounded capture",
16042        ));
16043    }
16044    Ok(ContextPackRepository::from_sources(
16045        &second.git,
16046        second.files,
16047    ))
16048}
16049
16050/// Appends a "skipped: budget exhausted" diagnostic and returns `true` once
16051/// (and only once) the shared walk+git `deadline` has already passed. Every
16052/// remaining phase in `inspect_git_workspace` checks this immediately
16053/// before issuing its own git call, so a budget spent by the walk (or by an
16054/// earlier git call) skips the rest of the chain instead of paying for it
16055/// anyway.
16056fn git_budget_exhausted(snapshot: &mut GitSnapshot, deadline: Instant, stage: &str) -> bool {
16057    if Instant::now() < deadline {
16058        return false;
16059    }
16060    append_git_diagnostic(
16061        snapshot,
16062        &format!("{stage} skipped: workspace inspection time budget exhausted"),
16063    );
16064    true
16065}
16066
16067/// Deadline-aware sibling of `run_git_bounded`, used only by
16068/// `inspect_git_workspace`'s six-call chain: caps the per-call timeout to
16069/// whatever remains of the shared `deadline` (never more than
16070/// `GIT_COMMAND_TIMEOUT_MS`), so a slow walk phase that already spent most
16071/// of the budget cannot still hand a full per-call timeout to every git
16072/// probe behind it. `inspect_git_workspace` already guards each call site
16073/// with `git_budget_exhausted`, so `deadline` is guaranteed not to have
16074/// passed yet here.
16075async fn run_git_bounded_with_deadline(
16076    root: &str,
16077    arguments: &[&str],
16078    output_limit: usize,
16079    deadline: Instant,
16080) -> io::Result<GitCommandOutput> {
16081    let remaining = deadline.checked_duration_since(Instant::now());
16082    let timeout_ms = remaining
16083        .and_then(|remaining| u64::try_from(remaining.as_millis()).ok())
16084        .unwrap_or(0)
16085        .clamp(1, GIT_COMMAND_TIMEOUT_MS);
16086    run_git_read_bounded(root, arguments, output_limit, timeout_ms).await
16087}
16088
16089/// Runs the bounded six-call git probe chain used by `inspect_workspace`.
16090/// `deadline` is the same absolute instant the preceding directory walk was
16091/// bounded by, so time already spent walking counts against what remains
16092/// for git enrichment. Returns the resulting `GitSnapshot` plus whether the
16093/// shared budget (not an individual per-call timeout) is what cut the git
16094/// phase short.
16095async fn inspect_git_workspace(root: &str, deadline: Instant) -> (GitSnapshot, bool) {
16096    let mut snapshot = GitSnapshot {
16097        is_repository: false,
16098        branch: None,
16099        status: Vec::new(),
16100        recent_commits: Vec::new(),
16101        worktrees: Vec::new(),
16102        managed_worktree: None,
16103        truncated: false,
16104        diagnostic: None,
16105    };
16106    if git_budget_exhausted(&mut snapshot, deadline, "git repository probe") {
16107        return (snapshot, true);
16108    }
16109    let repository = match run_git_bounded_with_deadline(
16110        root,
16111        &["rev-parse", "--is-inside-work-tree"],
16112        4 * 1_024,
16113        deadline,
16114    )
16115    .await
16116    {
16117        Ok(output) => output,
16118        Err(error) => {
16119            append_git_diagnostic(
16120                &mut snapshot,
16121                &format!("git inspection unavailable: {error}"),
16122            );
16123            return (snapshot, false);
16124        }
16125    };
16126    snapshot.truncated |= repository.truncated;
16127    if repository.timed_out {
16128        append_git_diagnostic(&mut snapshot, "git repository probe timed out");
16129        return (snapshot, false);
16130    }
16131    if !repository.success
16132        || String::from_utf8_lossy(&repository.stdout).trim() != "true"
16133    {
16134        let stderr = String::from_utf8_lossy(&repository.stderr);
16135        if !stderr.to_ascii_lowercase().contains("not a git repository") {
16136            append_git_diagnostic(&mut snapshot, stderr.trim());
16137        }
16138        return (snapshot, false);
16139    }
16140    snapshot.is_repository = true;
16141
16142    if git_budget_exhausted(&mut snapshot, deadline, "git branch query") {
16143        return (snapshot, true);
16144    }
16145    match run_git_bounded_with_deadline(root, &["branch", "--show-current"], 4 * 1_024, deadline).await {
16146        Ok(output) => {
16147            snapshot.truncated |= output.truncated;
16148            if output.timed_out {
16149                append_git_diagnostic(&mut snapshot, "git branch query timed out");
16150            } else if output.success {
16151                let branch = String::from_utf8_lossy(&output.stdout).trim().to_owned();
16152                if !branch.is_empty() {
16153                    snapshot.branch = Some(truncate_git_field(
16154                        &branch,
16155                        MAX_REPOSITORY_PATH_BYTES,
16156                        &mut snapshot.truncated,
16157                    ));
16158                }
16159            }
16160        }
16161        Err(error) => append_git_diagnostic(
16162            &mut snapshot,
16163            &format!("git branch query failed: {error}"),
16164        ),
16165    }
16166    if snapshot.branch.is_none() && !git_budget_exhausted(&mut snapshot, deadline, "git detached-HEAD probe") {
16167        if let Ok(output) = run_git_bounded_with_deadline(root, &["rev-parse", "--short", "HEAD"], 4 * 1_024, deadline).await {
16168            snapshot.truncated |= output.truncated;
16169            if output.success && !output.timed_out {
16170                let head = String::from_utf8_lossy(&output.stdout).trim().to_owned();
16171                if !head.is_empty() {
16172                    snapshot.branch = Some(format!("detached:{head}"));
16173                }
16174            }
16175        }
16176    }
16177
16178    if git_budget_exhausted(&mut snapshot, deadline, "git status") {
16179        return (snapshot, true);
16180    }
16181    match run_git_bounded_with_deadline(
16182        root,
16183        &[
16184            "status",
16185            "--porcelain=v1",
16186            "-z",
16187            "--renames",
16188            "--untracked-files=normal",
16189            "--",
16190            ".",
16191        ],
16192        GIT_OUTPUT_MAX_BYTES,
16193        deadline,
16194    )
16195    .await
16196    {
16197        Ok(output) => {
16198            snapshot.truncated |= output.truncated;
16199            if output.timed_out {
16200                append_git_diagnostic(&mut snapshot, "git status timed out");
16201            } else if output.success {
16202                parse_git_status(&output.stdout, &mut snapshot);
16203            } else {
16204                append_git_diagnostic(
16205                    &mut snapshot,
16206                    String::from_utf8_lossy(&output.stderr).trim(),
16207                );
16208            }
16209        }
16210        Err(error) => append_git_diagnostic(
16211            &mut snapshot,
16212            &format!("git status failed: {error}"),
16213        ),
16214    }
16215
16216    if git_budget_exhausted(&mut snapshot, deadline, "git log") {
16217        return (snapshot, true);
16218    }
16219    let log_limit = (GIT_COMMIT_MAX_ENTRIES + 1).to_string();
16220    match run_git_bounded_with_deadline(
16221        root,
16222        &["log", "-n", &log_limit, "--pretty=format:%H%x1f%s", "--", "."],
16223        16 * 1_024,
16224        deadline,
16225    )
16226    .await
16227    {
16228        Ok(output) => {
16229            snapshot.truncated |= output.truncated;
16230            if output.timed_out {
16231                append_git_diagnostic(&mut snapshot, "git log timed out");
16232            } else if output.success {
16233                parse_git_commits(&output.stdout, &mut snapshot);
16234            } else {
16235                let stderr = String::from_utf8_lossy(&output.stderr);
16236                if !stderr.to_ascii_lowercase().contains("does not have any commits") {
16237                    append_git_diagnostic(&mut snapshot, stderr.trim());
16238                }
16239            }
16240        }
16241        Err(error) => append_git_diagnostic(
16242            &mut snapshot,
16243            &format!("git log failed: {error}"),
16244        ),
16245    }
16246
16247    if git_budget_exhausted(&mut snapshot, deadline, "git worktree list") {
16248        return (snapshot, true);
16249    }
16250    match list_git_worktrees_with_deadline(root, deadline).await {
16251        Ok(worktrees) => {
16252            snapshot.worktrees = worktrees.into_iter().map(protocol_worktree).collect()
16253        }
16254        Err(error) => append_git_diagnostic(
16255            &mut snapshot,
16256            &format!("git worktree list failed: {}", error.message),
16257        ),
16258    }
16259    (snapshot, false)
16260}
16261
16262fn parse_git_status(output: &[u8], snapshot: &mut GitSnapshot) {
16263    let mut remaining = output;
16264    while !remaining.is_empty() {
16265        if snapshot.status.len() >= GIT_STATUS_MAX_ENTRIES {
16266            snapshot.truncated = true;
16267            break;
16268        }
16269        let Some(record_end) = remaining.iter().position(|byte| *byte == 0) else {
16270            snapshot.truncated = true;
16271            break;
16272        };
16273        let record = &remaining[..record_end];
16274        remaining = &remaining[record_end + 1..];
16275        let has_previous_path = record
16276            .get(..2)
16277            .is_some_and(|status| status.iter().any(|byte| matches!(*byte, b'R' | b'C')));
16278        let previous_path_bytes = if has_previous_path {
16279            let Some(previous_end) = remaining.iter().position(|byte| *byte == 0) else {
16280                snapshot.truncated = true;
16281                break;
16282            };
16283            let previous = &remaining[..previous_end];
16284            remaining = &remaining[previous_end + 1..];
16285            Some(previous)
16286        } else {
16287            None
16288        };
16289        if record.len() < 4
16290            || record[2] != b' '
16291            || !is_git_status_code(record[0])
16292            || !is_git_status_code(record[1])
16293        {
16294            snapshot.truncated = true;
16295            continue;
16296        }
16297        let Some(path) = windows_repository_path_from_bytes(&record[3..]) else {
16298            snapshot.truncated = true;
16299            continue;
16300        };
16301        let previous_path = match previous_path_bytes {
16302            Some(previous) => {
16303                let Some(previous) = windows_repository_path_from_bytes(previous) else {
16304                    snapshot.truncated = true;
16305                    continue;
16306                };
16307                Some(previous)
16308            }
16309            None => None,
16310        };
16311        snapshot.status.push(GitStatusEntry {
16312            index_status: (record[0] as char).to_string(),
16313            worktree_status: (record[1] as char).to_string(),
16314            path,
16315            previous_path,
16316        });
16317    }
16318}
16319
16320fn is_git_status_code(byte: u8) -> bool {
16321    matches!(byte, b' ' | b'M' | b'T' | b'A' | b'D' | b'R' | b'C' | b'U' | b'?' | b'!')
16322}
16323
16324fn parse_git_commits(output: &[u8], snapshot: &mut GitSnapshot) {
16325    for line in String::from_utf8_lossy(output).lines() {
16326        let Some((id, summary)) = line.split_once('\u{1f}') else {
16327            continue;
16328        };
16329        if snapshot.recent_commits.len() >= GIT_COMMIT_MAX_ENTRIES {
16330            snapshot.truncated = true;
16331            break;
16332        }
16333        let id = truncate_git_field(id, 64, &mut snapshot.truncated);
16334        let summary = truncate_git_field(summary, 512, &mut snapshot.truncated);
16335        snapshot.recent_commits.push(GitCommitSummary { id, summary });
16336    }
16337}
16338
16339fn truncate_git_field(value: &str, max_bytes: usize, truncated: &mut bool) -> String {
16340    if value.len() <= max_bytes {
16341        return value.to_owned();
16342    }
16343    *truncated = true;
16344    let mut boundary = max_bytes;
16345    while boundary > 0 && !value.is_char_boundary(boundary) {
16346        boundary -= 1;
16347    }
16348    value[..boundary].to_owned()
16349}
16350
16351fn append_git_diagnostic(snapshot: &mut GitSnapshot, message: &str) {
16352    if message.is_empty() {
16353        return;
16354    }
16355    let normalized = message
16356        .chars()
16357        .map(|character| if character.is_control() { ' ' } else { character })
16358        .collect::<String>();
16359    let mut diagnostic = snapshot.diagnostic.take().unwrap_or_default();
16360    if !diagnostic.is_empty() {
16361        diagnostic.push_str("; ");
16362    }
16363    diagnostic.push_str(normalized.trim());
16364    snapshot.diagnostic = Some(truncate_git_field(
16365        &diagnostic,
16366        GIT_DIAGNOSTIC_MAX_BYTES,
16367        &mut snapshot.truncated,
16368    ));
16369}
16370
16371async fn run_git_bounded(
16372    root: &str,
16373    arguments: &[&str],
16374    output_limit: usize,
16375) -> io::Result<GitCommandOutput> {
16376    run_git_read_bounded(root, arguments, output_limit, GIT_COMMAND_TIMEOUT_MS).await
16377}
16378
16379async fn read_git_history_bounded(
16380    root: &str,
16381    path: Option<&RepositoryPath>,
16382    before: Option<&GitObjectId>,
16383    limit: u16,
16384) -> Result<GitHistoryPage, NodeFailure> {
16385    let path = path
16386        .map(|path| {
16387            path.as_utf8().ok_or_else(|| {
16388                failure(
16389                    NodeFailureCode::InvalidRepositoryPath,
16390                    "git history path must use UTF-8",
16391                )
16392            })
16393        })
16394        .transpose()?
16395        .unwrap_or(".");
16396    let count = (usize::from(limit) + 1).to_string();
16397    let start = before
16398        .map(|revision| format!("{}^", revision.as_str()))
16399        .unwrap_or_else(|| "HEAD".to_owned());
16400    let arguments = [
16401        "--no-pager",
16402        "log",
16403        "--no-decorate",
16404        "--date=iso-strict",
16405        "--pretty=format:%H%x1f%P%x1f%s%x1f%an%x1f%ae%x1f%aI%x1f%cn%x1f%ce%x1f%cI%x1e",
16406        "-n",
16407        count.as_str(),
16408        start.as_str(),
16409        "--",
16410        path,
16411    ];
16412    let output = run_git_bounded(root, &arguments, 256 * 1_024)
16413        .await
16414        .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git history command failed"))?;
16415    if output.timed_out {
16416        return Err(failure(NodeFailureCode::GitReadTimedOut, "git history exceeded its bounded deadline"));
16417    }
16418    if !output.success {
16419        return Err(failure(NodeFailureCode::GitReadFailed, "git history could not be read"));
16420    }
16421    let mut commits = parse_git_history(&output.stdout);
16422    let has_more = commits.len() > usize::from(limit) || output.truncated;
16423    commits.truncate(usize::from(limit));
16424    let next_before = has_more.then(|| commits.last().map(|commit| commit.id.clone())).flatten();
16425    Ok(GitHistoryPage { commits, next_before, truncated: output.truncated })
16426}
16427
16428fn parse_git_history(output: &[u8]) -> Vec<GitCommitDetails> {
16429    String::from_utf8_lossy(output)
16430        .split('\u{1e}')
16431        .filter_map(|record| {
16432            let fields = record.trim_matches(['\r', '\n']).split('\u{1f}').collect::<Vec<_>>();
16433            if fields.len() != 9 {
16434                return None;
16435            }
16436            let id = GitObjectId::new(fields[0].to_owned()).ok()?;
16437            let parents = if fields[1].is_empty() {
16438                Vec::new()
16439            } else {
16440                fields[1]
16441                    .split(' ')
16442                    .map(|value| GitObjectId::new(value.to_owned()))
16443                    .collect::<Result<Vec<_>, _>>()
16444                    .ok()?
16445            };
16446            Some(GitCommitDetails {
16447                id,
16448                parents,
16449                subject: bounded_git_text(fields[2], 512),
16450                author_name: bounded_git_text(fields[3], 256),
16451                author_email: bounded_git_text(fields[4], 320),
16452                authored_at: bounded_git_text(fields[5], 64),
16453                committer_name: bounded_git_text(fields[6], 256),
16454                committer_email: bounded_git_text(fields[7], 320),
16455                committed_at: bounded_git_text(fields[8], 64),
16456                signature_status: GitSignatureStatus::NoSignature,
16457                signer: None,
16458            })
16459        })
16460        .take(usize::from(MAX_GIT_HISTORY_COMMITS) + 1)
16461        .collect()
16462}
16463
16464fn bounded_git_text(value: &str, max_bytes: usize) -> String {
16465    let normalized = value
16466        .chars()
16467        .map(|character| if character.is_control() { ' ' } else { character })
16468        .collect::<String>();
16469    if normalized.len() <= max_bytes {
16470        return normalized;
16471    }
16472    let mut boundary = max_bytes;
16473    while boundary > 0 && !normalized.is_char_boundary(boundary) {
16474        boundary -= 1;
16475    }
16476    normalized[..boundary].to_owned()
16477}
16478
16479async fn read_git_diff_bounded(root: &str, request: GitDiffRequest) -> Result<GitDiff, NodeFailure> {
16480    let path = request.path.as_ref().map(|path| {
16481        path.as_utf8()
16482            .ok_or_else(|| failure(NodeFailureCode::InvalidRepositoryPath, "git diff path must use UTF-8"))
16483    }).transpose()?;
16484    let mut arguments = match &request.mode {
16485        GitDiffMode::Working => vec!["--no-pager", "-c", "diff.external=", "diff", "--no-ext-diff", "--no-textconv"],
16486        GitDiffMode::Staged => vec!["--no-pager", "-c", "diff.external=", "diff", "--cached", "--no-ext-diff", "--no-textconv"],
16487        GitDiffMode::Commit { .. } => vec!["--no-pager", "-c", "diff.external=", "show", "--format=fuller", "--no-ext-diff", "--no-textconv"],
16488    };
16489    if let GitDiffMode::Commit { revision } = &request.mode {
16490        arguments.push(revision.as_str());
16491    }
16492    arguments.push("--");
16493    if let Some(path) = path {
16494        arguments.push(path);
16495    }
16496    let output = run_git_bounded(root, &arguments, MAX_GIT_DIFF_BYTES)
16497        .await
16498        .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git diff command failed"))?;
16499    if output.timed_out {
16500        return Err(failure(NodeFailureCode::GitReadTimedOut, "git diff exceeded its bounded deadline"));
16501    }
16502    if !output.success {
16503        return Err(failure(NodeFailureCode::GitReadFailed, "git diff could not be read"));
16504    }
16505    Ok(GitDiff {
16506        mode: request.mode,
16507        path: request.path,
16508        text: String::from_utf8_lossy(&output.stdout).into_owned(),
16509        truncated: output.truncated,
16510    })
16511}
16512
16513async fn process_request(shared: &NodeShared, connection_id: u64, role: ClientRole, envelope: RequestEnvelope) -> ResponseEnvelope {
16514    let result = process_request_inner(shared, connection_id, role, envelope.request).await;
16515    ResponseEnvelope { request_id: envelope.request_id, result }
16516}
16517
16518async fn process_request_inner(shared: &NodeShared, connection_id: u64, role: ClientRole, request: NodeRequest) -> Result<NodeResponse, NodeFailure> {
16519    if !request.harness_mcp_contract_is_valid_at(unix_time_ms()) {
16520        return Err(failure(NodeFailureCode::InvalidRequest, "harness MCP request is invalid or stale"));
16521    }
16522    let read_only = matches!(
16523        &request,
16524        NodeRequest::Snapshot
16525            | NodeRequest::Resync { .. }
16526            | NodeRequest::BrowseHostDirectories { .. }
16527            | NodeRequest::InspectWorkspace { .. }
16528            | NodeRequest::ReadWorkspaceFile { .. }
16529            | NodeRequest::ReadGitHistory { .. }
16530            | NodeRequest::ReadGitDiff { .. }
16531            | NodeRequest::CatalogNativeSessions { .. }
16532            | NodeRequest::PageNativeSessions { .. }
16533            | NodeRequest::PreviewNativeSession { .. }
16534            | NodeRequest::PreviewSessionRecord { .. }
16535    );
16536    let _mutation_guard = if read_only {
16537        None
16538    } else {
16539        let guard = shared.mutation_gate.lock().await;
16540        if shared.shutdown.load(Ordering::Acquire) {
16541            return Err(failure(NodeFailureCode::ShuttingDown, "node shutdown has begun"));
16542        }
16543        Some(guard)
16544    };
16545    match request {
16546        NodeRequest::Snapshot => Ok(NodeResponse::Snapshot {
16547            event_sequence: shared.current_sequence(),
16548            controller: shared.controller_state(),
16549            snapshot: shared.snapshot(),
16550        }),
16551        NodeRequest::Resync { after_sequence } => Ok(shared.resync(after_sequence)),
16552        NodeRequest::ArmHarnessMcpReservation {
16553            reservation_id,
16554            activation_digest,
16555            spawn_spec,
16556            launch,
16557            expires_at_unix_ms,
16558        } => {
16559            shared.require_controller(connection_id, role)?;
16560            let registry = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16561                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16562            })?;
16563            let expires_at_unix_ms = registry
16564                .arm(reservation_id.clone(), activation_digest.clone(), spawn_spec, launch, expires_at_unix_ms)
16565                .await
16566                .map_err(harness_mcp_failure)?;
16567            Ok(NodeResponse::Armed { reservation_id, activation_digest, expires_at_unix_ms })
16568        }
16569        NodeRequest::SpawnSpecWithHarnessMcp {
16570            reservation_id,
16571            activation_digest,
16572            spec,
16573            deadline_unix_ms,
16574        } => {
16575            shared.require_controller(connection_id, role)?;
16576            let receipt = shared
16577                .spawn_from_spec_with_harness_mcp(
16578                    reservation_id.clone(), activation_digest.clone(), spec, deadline_unix_ms,
16579                )
16580                .await?;
16581            Ok(NodeResponse::Spawned { reservation_id, activation_digest, receipt })
16582        }
16583        NodeRequest::ActivateHarnessMcpReservation {
16584            reservation_id,
16585            activation_digest,
16586            record_id,
16587            session,
16588        } => {
16589            shared.require_controller(connection_id, role)?;
16590            let provider_root_pid = shared.handle.snapshot().sessions.iter().find(|current| {
16591                current.instance_id == session.session.instance_id
16592                    && current.generation == session.session.generation
16593            }).and_then(|current| current.process_id).ok_or_else(|| {
16594                failure(NodeFailureCode::BindingMismatch, "provider root process is unavailable")
16595            })?;
16596            shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16597                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16598            })?.activate(
16599                &reservation_id, &activation_digest, &record_id, &session, provider_root_pid,
16600            ).map_err(harness_mcp_failure)?;
16601            Ok(NodeResponse::Activated { reservation_id, activation_digest, record_id, session })
16602        }
16603        NodeRequest::AbortHarnessMcpReservation { reservation_id, activation_digest } => {
16604            shared.require_controller(connection_id, role)?;
16605            let instance_id = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16606                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16607            })?.abort(&reservation_id, &activation_digest).map_err(harness_mcp_failure)?;
16608            if let Some(instance_id) = instance_id {
16609                SessionLifecycleHook::on_harness_reservation_aborted(&*shared, instance_id);
16610            }
16611            Ok(NodeResponse::Aborted { reservation_id, activation_digest })
16612        }
16613        NodeRequest::PutHarnessMcpReplyChunk {
16614            reservation_id, activation_digest, record_id, session, call_id,
16615            offset, final_chunk, chunk_hex,
16616        } => {
16617            shared.require_controller(connection_id, role)?;
16618            let (next_offset, completed) = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16619                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16620            })?.put_reply_chunk(
16621                &reservation_id, &activation_digest, &record_id, &session, &call_id,
16622                offset, final_chunk, &chunk_hex,
16623            ).map_err(harness_mcp_failure)?;
16624            Ok(NodeResponse::ReplyChunkAccepted {
16625                reservation_id, activation_digest, record_id, session, call_id,
16626                next_offset, completed,
16627            })
16628        }
16629        NodeRequest::RejectHarnessMcpCall {
16630            reservation_id, activation_digest, record_id, session, call_id, reason,
16631        } => {
16632            shared.require_controller(connection_id, role)?;
16633            shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16634                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16635            })?.reject_call(
16636                &reservation_id, &activation_digest, &record_id, &session, &call_id, reason,
16637            ).map_err(harness_mcp_failure)?;
16638            Ok(NodeResponse::CallRejected {
16639                reservation_id, activation_digest, record_id, session, call_id,
16640            })
16641        }
16642        NodeRequest::BeginDeliveryStage { manifest } => {
16643            shared.require_controller(connection_id, role)?;
16644            shared.begin_delivery_stage(manifest)
16645        }
16646        NodeRequest::PutDeliveryBlobChunk {
16647            stage_id,
16648            blob_digest,
16649            offset,
16650            chunk_hex,
16651        } => {
16652            shared.require_controller(connection_id, role)?;
16653            shared.put_delivery_blob_chunk(stage_id, blob_digest, offset, chunk_hex)
16654        }
16655        NodeRequest::CommitDeliveryStage { stage_id } => {
16656            shared.require_controller(connection_id, role)?;
16657            let receipt = shared.commit_delivery_stage(stage_id)?;
16658            Ok(NodeResponse::DeliveryCommitted { receipt })
16659        }
16660        NodeRequest::AbortDeliveryStage { stage_id } => {
16661            shared.require_controller(connection_id, role)?;
16662            shared.abort_delivery_stage(&stage_id)?;
16663            Ok(NodeResponse::DeliveryStageAborted { stage_id })
16664        }
16665        NodeRequest::BrowseHostDirectories { directory, after } => {
16666            let listing = shared.browse_host_directories(directory, after).await?;
16667            Ok(NodeResponse::HostDirectoriesBrowsed { listing })
16668        }
16669        NodeRequest::InspectWorkspace { workspace_id } => {
16670            let inspection = shared.inspect_workspace(workspace_id).await?;
16671            Ok(NodeResponse::WorkspaceInspected { inspection })
16672        }
16673        NodeRequest::ReadWorkspaceFile { workspace_id, path } => {
16674            let file = shared.read_workspace_file(workspace_id, path).await?;
16675            Ok(NodeResponse::WorkspaceFileRead { file })
16676        }
16677        NodeRequest::WriteWorkspaceFile { workspace_id, path, expected_revision, text } => {
16678            shared.require_controller(connection_id, role)?;
16679            let file = shared
16680                .write_workspace_file(workspace_id, path, expected_revision, text)
16681                .await?;
16682            Ok(NodeResponse::WorkspaceFileWritten { file })
16683        }
16684        NodeRequest::CreateWorkspaceFile { workspace_id, path } => {
16685            shared.require_controller(connection_id, role)?;
16686            let file = shared.create_workspace_file(workspace_id, path).await?;
16687            Ok(NodeResponse::WorkspaceFileCreated { file })
16688        }
16689        NodeRequest::CreateWorkspaceDirectory { workspace_id, path } => {
16690            shared.require_controller(connection_id, role)?;
16691            let entry = shared
16692                .create_workspace_directory(workspace_id.clone(), path)
16693                .await?;
16694            Ok(NodeResponse::WorkspaceDirectoryCreated { workspace_id, entry })
16695        }
16696        NodeRequest::ReadGitHistory { workspace_id, path, before, limit } => {
16697            if role != ClientRole::Operator {
16698                return Err(failure(NodeFailureCode::ObserverReadOnly, "git history requires an operator connection"));
16699            }
16700            if !(1..=MAX_GIT_HISTORY_COMMITS).contains(&limit) {
16701                return Err(failure(NodeFailureCode::InvalidRequest, "git history limit is invalid"));
16702            }
16703            let page = shared
16704                .read_git_history(workspace_id.clone(), path, before, limit)
16705                .await?;
16706            Ok(NodeResponse::GitHistoryRead { workspace_id, page })
16707        }
16708        NodeRequest::ReadGitDiff { workspace_id, request } => {
16709            if role != ClientRole::Operator {
16710                return Err(failure(NodeFailureCode::ObserverReadOnly, "git diff requires an operator connection"));
16711            }
16712            let diff = shared.read_git_diff(workspace_id.clone(), request).await?;
16713            Ok(NodeResponse::GitDiffRead { workspace_id, diff })
16714        }
16715        NodeRequest::CatalogNativeSessions { route, limit } => {
16716            if role != ClientRole::Operator {
16717                return Err(failure(
16718                    NodeFailureCode::ObserverReadOnly,
16719                    "native session catalog requires an operator connection",
16720                ));
16721            }
16722            if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit) {
16723                return Err(failure(
16724                    NodeFailureCode::InvalidRequest,
16725                    "native session catalog limit is invalid",
16726                ));
16727            }
16728            let (entries, summary) = shared
16729                .catalog_native_sessions(route.clone(), limit)
16730                .await?;
16731            Ok(NodeResponse::NativeSessionsCataloged {
16732                route,
16733                entries,
16734                summary: Some(summary),
16735            })
16736        }
16737        NodeRequest::PageNativeSessions {
16738            route,
16739            window,
16740            catalog_revision,
16741            recent_cutoff_unix_ms,
16742            after_selection_id,
16743            limit,
16744        } => {
16745            if role != ClientRole::Operator {
16746                return Err(failure(
16747                    NodeFailureCode::ObserverReadOnly,
16748                    "native session catalog paging requires an operator connection",
16749                ));
16750            }
16751            if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit)
16752                || after_selection_id
16753                    .as_deref()
16754                    .is_some_and(|cursor| validate_candidate_id(cursor).is_err())
16755            {
16756                return Err(failure(
16757                    NodeFailureCode::InvalidRequest,
16758                    "native session catalog page request is invalid",
16759                ));
16760            }
16761            let page = shared
16762                .page_native_sessions(
16763                    route.clone(),
16764                    window,
16765                    catalog_revision,
16766                    recent_cutoff_unix_ms,
16767                    after_selection_id,
16768                    limit,
16769                )
16770                .await?;
16771            Ok(NodeResponse::NativeSessionsPaged {
16772                route,
16773                page,
16774            })
16775        }
16776        NodeRequest::PreviewNativeSession {
16777            selection,
16778            message_limit,
16779        } => {
16780            if role != ClientRole::Operator {
16781                return Err(failure(
16782                    NodeFailureCode::ObserverReadOnly,
16783                    "native session preview requires an operator connection",
16784                ));
16785            }
16786            if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16787                .contains(&message_limit)
16788                || selection.validate().is_err()
16789            {
16790                return Err(failure(
16791                    NodeFailureCode::InvalidRequest,
16792                    "native session preview request is invalid",
16793                ));
16794            }
16795            let preview = shared
16796                .preview_native_session(selection.clone(), message_limit)
16797                .await?;
16798            Ok(NodeResponse::NativeSessionPreviewed {
16799                selection,
16800                preview,
16801            })
16802        }
16803        NodeRequest::IndexNativeSession {
16804            selection,
16805            display_name,
16806        } => {
16807            shared.require_controller(connection_id, role)?;
16808            let record = shared
16809                .index_native_session(selection.clone(), display_name)
16810                .await?;
16811            Ok(NodeResponse::NativeSessionIndexed { selection, record })
16812        }
16813        NodeRequest::PreviewSessionRecord {
16814            record_id,
16815            message_limit,
16816        } => {
16817            if role != ClientRole::Operator {
16818                return Err(failure(
16819                    NodeFailureCode::ObserverReadOnly,
16820                    "native session preview requires an operator connection",
16821                ));
16822            }
16823            if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16824                .contains(&message_limit)
16825            {
16826                return Err(failure(
16827                    NodeFailureCode::InvalidRequest,
16828                    "native session preview request is invalid",
16829                ));
16830            }
16831            let record = shared.record(&record_id)?;
16832            let working_directory = shared.workspace_root(&record.workspace_id)?;
16833            if !platform::roots_equal(
16834                &working_directory,
16835                windows_path_text(&record.canonical_root),
16836            ) {
16837                return Err(failure(
16838                    NodeFailureCode::SessionRecordConflict,
16839                    "managed session workspace root changed; refusing to preview another directory",
16840                ));
16841            }
16842            let identity = record.provider_session.clone().ok_or_else(|| {
16843                failure(
16844                    NodeFailureCode::BackendOperationFailed,
16845                    "native session preview is unavailable for this session record",
16846                )
16847            })?;
16848            if identity.key != ProviderSessionKey::SessionId {
16849                return Err(failure(
16850                    NodeFailureCode::BackendOperationFailed,
16851                    "native session preview is unavailable for this session identity",
16852                ));
16853            }
16854            let preview = shared
16855                .preview_native_session_by_session_id(
16856                    record.workspace_id.clone(), record.provider.clone(), identity.id.clone(), message_limit,
16857                )
16858                .await?;
16859            if preview.session_id != identity.id {
16860                return Err(failure(
16861                    NodeFailureCode::SessionRecordConflict,
16862                    "native session preview identity changed while loading",
16863                ));
16864            }
16865            shared.revalidate_session_record_preview(&record, &identity)?;
16866            let preview: gate4agent_types::SessionRecordPreview = preview.into();
16867            shared.publish(NodeEvent::SessionRecordHistorySummarized {
16868                record_id: record_id.clone(),
16869                summary: SessionHistorySummaryV1::from(&preview),
16870            });
16871            Ok(NodeResponse::SessionRecordPreviewed {
16872                record_id,
16873                preview,
16874            })
16875        }
16876        NodeRequest::AcquireController { lease_ms } => {
16877            let controller = shared.acquire_controller(connection_id, role, lease_ms)?;
16878            Ok(NodeResponse::Controller { controller: Some(controller) })
16879        }
16880        NodeRequest::ReleaseController => {
16881            shared.release_controller(connection_id);
16882            Ok(NodeResponse::Controller { controller: shared.controller_state() })
16883        }
16884        NodeRequest::RegisterWorkspace { workspace_id, root } => {
16885            shared.require_controller(connection_id, role)?;
16886            let root = require_windows_path(root)?;
16887            shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16888            let workspace = shared.register_workspace(workspace_id, root).await?;
16889            Ok(NodeResponse::WorkspaceRegistered { workspace })
16890        }
16891        NodeRequest::CreateStandaloneWorkspace {
16892            workspace_id,
16893            root,
16894            initial_branch,
16895        } => {
16896            shared.require_controller(connection_id, role)?;
16897            let root = require_windows_path(root)?;
16898            shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16899            let workspace = shared
16900                .create_standalone_workspace(workspace_id, root, initial_branch)
16901                .await?;
16902            Ok(NodeResponse::StandaloneWorkspaceCreated { workspace })
16903        }
16904        NodeRequest::UnregisterWorkspace { workspace_id } => {
16905            shared.require_controller(connection_id, role)?;
16906            shared.reject_managed_reservation(Some(&workspace_id), None)?;
16907            shared.unregister_workspace(&workspace_id)?;
16908            Ok(NodeResponse::WorkspaceUnregistered { workspace_id })
16909        }
16910        NodeRequest::CreateWorktree {
16911            source_workspace_id,
16912            workspace_id,
16913            target_root,
16914            branch,
16915            base,
16916        } => {
16917            shared.require_controller(connection_id, role)?;
16918            let target_root = require_windows_path(target_root)?;
16919            shared.reject_managed_reservation(Some(&workspace_id), Some(&target_root))?;
16920            let (worktree, workspace) = shared
16921                .create_worktree(
16922                    source_workspace_id,
16923                    workspace_id,
16924                    target_root,
16925                    branch,
16926                    base,
16927                )
16928                .await?;
16929            Ok(NodeResponse::WorktreeCreated { worktree, workspace })
16930        }
16931        NodeRequest::RemoveWorktree {
16932            source_workspace_id,
16933            target_root,
16934        } => {
16935            shared.require_controller(connection_id, role)?;
16936            let native_target_root = require_windows_path(target_root.clone())?;
16937            shared.reject_managed_reservation(None, Some(&native_target_root))?;
16938            let workspace_id = shared
16939                .remove_worktree(source_workspace_id, native_target_root)
16940                .await?;
16941            Ok(NodeResponse::WorktreeRemoved {
16942                target_root,
16943                workspace_id,
16944            })
16945        }
16946        NodeRequest::Spawn { workspace_id, provider, mode, terminal_size, initial_prompt } => {
16947            shared.require_controller(connection_id, role)?;
16948            if !terminal_size.is_valid() {
16949                return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
16950            }
16951            if let Some(prompt) = initial_prompt.as_deref() {
16952                validate_node_text("spawn initial prompt", prompt)?;
16953            }
16954            match shared
16955                .spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt)
16956                .await
16957            {
16958                Ok(session) => {
16959                    shared.mark_spawn_provisional(session.clone());
16960                    Ok(NodeResponse::SpawnAccepted { session })
16961                }
16962                Err(err) => {
16963                    tracing::warn!(
16964                        %provider,
16965                        %workspace_id,
16966                        ?mode,
16967                        code = ?err.code,
16968                        message = %err.message,
16969                        "session spawn refused"
16970                    );
16971                    Err(err)
16972                }
16973            }
16974        }
16975        NodeRequest::SpawnWithOptions { workspace_id, provider, mode, terminal_size, initial_prompt, options } => {
16976            shared.require_controller(connection_id, role)?;
16977            if !terminal_size.is_valid() {
16978                return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
16979            }
16980            if let Some(prompt) = initial_prompt.as_deref() {
16981                validate_node_text("spawn initial prompt", prompt)?;
16982            }
16983            let plan = backend_paths::plan_launch_options(
16984                provider.as_str(),
16985                mode,
16986                options.model.is_some(),
16987                options.effort.is_some(),
16988            )
16989            .map_err(|r| {
16990                tracing::warn!(%provider, reason = %r.message, "launch option path refused");
16991                failure(NodeFailureCode::InvalidRequest, "launch option path refused")
16992            })?;
16993            let selection = spawn_selection(&options)?;
16994            let nick_model = selection.as_ref().map(|s| s.model.clone());
16995            let pty_selection = match plan {
16996                backend_paths::LaunchOptionsPlan::PtyFlags => selection,
16997                _ => None,
16998            };
16999            let spawned = SPAWN_ORIGIN.scope(options.origin.clone(), async {
17000                SPAWN_PARENT.scope(options.parent_session_id.clone(), async {
17001                    SPAWN_NICK_MODEL.scope(nick_model, SPAWN_SESSION_OPTIONS
17002                        .scope(pty_selection, shared.spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt)))
17003                        .await
17004                }).await
17005            }).await;
17006            match spawned {
17007                Ok(session) => {
17008                    if plan == backend_paths::LaunchOptionsPlan::AcpAfterSpawn {
17009                        shared
17010                            .pending_acp_options
17011                            .lock()
17012                            .unwrap_or_else(|p| p.into_inner())
17013                            .insert(session.session.instance_id, (options.model.clone(), options.effort.clone()));
17014                    }
17015                    shared.mark_spawn_provisional(session.clone());
17016                    tracing::info!(%provider, model = ?options.model, effort = ?options.effort, "session spawned with launch options");
17017                    Ok(NodeResponse::SpawnAccepted { session })
17018                }
17019                Err(err) => {
17020                    tracing::warn!(%provider, %workspace_id, code = ?err.code, message = %err.message, "session spawn with options refused");
17021                    Err(err)
17022                }
17023            }
17024        }
17025        NodeRequest::SpawnSpec { spec } => {
17026            shared.require_controller(connection_id, role)?;
17027            let receipt = shared.spawn_from_spec(spec).await?;
17028            Ok(NodeResponse::SpawnSpecAccepted { receipt })
17029        }
17030        NodeRequest::SpawnManagedWorktree { request } => {
17031            shared.require_controller(connection_id, role)?;
17032            let receipt = shared.spawn_managed_worktree(request).await?;
17033            Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
17034        }
17035        NodeRequest::SpawnManagedWorktreeV2 { request } => {
17036            shared.require_controller(connection_id, role)?;
17037            let receipt = shared.spawn_managed_worktree_v2(request).await?;
17038            Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
17039        }
17040        NodeRequest::CleanupManagedWorktree { lease_id } => {
17041            shared.require_controller(connection_id, role)?;
17042            let lease = shared.cleanup_managed_worktree(&lease_id, true).await?;
17043            Ok(NodeResponse::ManagedWorktreeCleanup { lease })
17044        }
17045        NodeRequest::DiscoverHistory { session, limit } => {
17046            controlled_session(shared, connection_id, role, &session)?;
17047            let candidates = shared.discover_history(&session, limit).await?;
17048            Ok(NodeResponse::HistoryDiscovered {
17049                session,
17050                candidates,
17051            })
17052        }
17053        NodeRequest::LoadHistory {
17054            session,
17055            candidate_id,
17056        } => {
17057            controlled_session(shared, connection_id, role, &session)?;
17058            let loaded = shared
17059                .load_history(&session, candidate_id)
17060                .await?;
17061            Ok(NodeResponse::HistoryLoaded {
17062                session,
17063                session_id: loaded.session_id,
17064                message_count: loaded.message_count,
17065                completed_turn_count: loaded.completed_turn_count,
17066            })
17067        }
17068        NodeRequest::ExportContextPackForSessionRecord { record_id, session } => {
17069            shared.require_controller(connection_id, role)?;
17070            let context = shared
17071                .export_context_pack_for_session_record(&record_id, &session, false)
17072                .await?;
17073            Ok(NodeResponse::ContextPackForSessionRecordExported {
17074                record_id,
17075                session,
17076                context,
17077            })
17078        }
17079        NodeRequest::ExportContextPack { session } => {
17080            controlled_session(shared, connection_id, role, &session)?;
17081            let context = shared.export_context_pack(&session).await?;
17082            Ok(NodeResponse::ContextPackExported { context })
17083        }
17084        NodeRequest::ForgetContextPack { context_id } => {
17085            shared.require_controller(connection_id, role)?;
17086            shared.forget_context_pack(&context_id)?;
17087            Ok(NodeResponse::ContextPackForgotten { context_id })
17088        }
17089        NodeRequest::ResolveDurableContextPack { context_id } => {
17090            // Read-only, idempotent, no controller lease required: this lets
17091            // a caller preflight "is this durably exported pack still here"
17092            // without holding the Node's mutation controller.
17093            let context = shared
17094                .context_catalog
17095                .read()
17096                .unwrap_or_else(|poisoned| poisoned.into_inner())
17097                .get(&context_id)
17098                .map(|pack| pack.receipt().clone())
17099                .ok_or_else(|| {
17100                    failure(
17101                        NodeFailureCode::UnknownContextPack,
17102                        "context pack is not present",
17103                    )
17104                })?;
17105            Ok(NodeResponse::DurableContextPackResolved { context })
17106        }
17107        NodeRequest::ReadContextPack { digest } => {
17108            // Read-only fetch of the pack's own bytes, keyed by content digest
17109            // rather than the caller's `SpawnContextId`: same no-controller-lease
17110            // rationale as `ResolveDurableContextPack` above, one step further
17111            // down the same catalog.
17112            let (id, byte_len, bytes) = shared
17113                .context_catalog
17114                .read()
17115                .unwrap_or_else(|poisoned| poisoned.into_inner())
17116                .get_by_digest(&digest)
17117                .map(|pack| {
17118                    (pack.receipt().id.clone(), pack.receipt().byte_len, pack.bytes().to_vec())
17119                })
17120                .ok_or_else(|| {
17121                    failure(
17122                        NodeFailureCode::UnknownContextPack,
17123                        &format!("context pack {digest} not on this node"),
17124                    )
17125                })?;
17126            tracing::debug!(
17127                digest = digest.as_str(),
17128                byte_len,
17129                "read context pack bytes",
17130            );
17131            Ok(NodeResponse::ContextPackBytesRead {
17132                pack: ContextPackBytesRead {
17133                    digest,
17134                    id,
17135                    byte_len,
17136                    bytes,
17137                },
17138            })
17139        }
17140        NodeRequest::Resume { session, terminal_size, initial_prompt } => {
17141            let provider = controlled_session(shared, connection_id, role, &session)?;
17142            if let Some(prompt) = initial_prompt.as_deref() {
17143                validate_node_text("resume initial prompt", prompt)?;
17144            }
17145            let runtime_requirement = if initial_prompt.is_some() {
17146                ProviderRuntimeRequirement::ResumeWithPrompt
17147            } else {
17148                ProviderRuntimeRequirement::Resume
17149            };
17150            shared.require_session_runtime_policy(&session, runtime_requirement)?;
17151            let runtime_policy = shared
17152                .admit_provider_runtime(&provider, runtime_requirement)
17153                .await?;
17154            let working_directory = shared.bound_workspace_root(&session)?;
17155            let request = ResumeLaunchRequest {
17156                working_directory,
17157                terminal_size,
17158                initial_prompt,
17159            };
17160            request
17161                .validate()
17162                .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
17163            let command = shared.prepare_command(ControlCommand::Resume {
17164                instance_id: session.session.instance_id,
17165                target: ResumeTarget::CurrentProvider,
17166                runtime_policy,
17167                request,
17168            });
17169            shared.arm_resume(&session, command.id, runtime_policy)?;
17170            let dispatch = shared.dispatch_envelope(command);
17171            if let Err(error) = dispatch {
17172                shared.clear_armed_resume(&session);
17173                return Err(error);
17174            }
17175            Ok(NodeResponse::Accepted)
17176        }
17177        NodeRequest::RenameSessionRecord {
17178            record_id,
17179            display_name,
17180        } => {
17181            shared.require_controller(connection_id, role)?;
17182            let record = shared.rename_session_record(&record_id, display_name)?;
17183            Ok(NodeResponse::SessionRecordUpdated { record })
17184        }
17185        NodeRequest::SetSessionTask {
17186            record_id,
17187            expected_revision,
17188            target,
17189        } => {
17190            shared.require_controller(connection_id, role)?;
17191            let record = shared.set_session_task(&record_id, expected_revision, target)?;
17192            Ok(NodeResponse::SessionRecordUpdated { record })
17193        }
17194        NodeRequest::IndexProviderSession {
17195            workspace_id,
17196            provider,
17197            identity,
17198            display_name,
17199        } => {
17200            shared.require_controller(connection_id, role)?;
17201            let record = shared.index_provider_session(
17202                workspace_id,
17203                provider,
17204                identity,
17205                display_name,
17206            )?;
17207            Ok(NodeResponse::ProviderSessionIndexed { record })
17208        }
17209        NodeRequest::ResumeSessionRecord {
17210            record_id,
17211            terminal_size,
17212            initial_prompt,
17213        } => {
17214            shared.require_controller(connection_id, role)?;
17215            if let Some(prompt) = initial_prompt.as_deref() {
17216                validate_node_text("managed resume initial prompt", prompt)?;
17217            }
17218            let (record, session) = shared
17219                .resume_session_record(
17220                    &record_id,
17221                    terminal_size,
17222                    initial_prompt,
17223                )
17224                .await?;
17225            Ok(NodeResponse::SessionRecordResumed { record, session })
17226        }
17227        NodeRequest::ForgetSessionRecord { record_id } => {
17228            shared.require_controller(connection_id, role)?;
17229            shared.forget_session_record(&record_id).await?;
17230            Ok(NodeResponse::SessionRecordForgotten { record_id })
17231        }
17232        NodeRequest::Prompt { session, text } => {
17233            let agent_id = controlled_session(shared, connection_id, role, &session)?;
17234            validate_node_text("prompt", &text)?;
17235            shared.require_session_runtime_policy(
17236                &session,
17237                ProviderRuntimeRequirement::SemanticPrompt,
17238            )?;
17239            let framing = prompt_framing(&agent_id);
17240            shared
17241                .dispatch_input_bounded(
17242                    &session,
17243                    InputAction::SubmitPrompt(PromptPayload { text, framing }),
17244                )
17245                .await?;
17246            Ok(NodeResponse::Accepted)
17247        }
17248        NodeRequest::Paste { session, text } => {
17249            controlled_session(shared, connection_id, role, &session)?;
17250            validate_node_text("paste", &text)?;
17251            shared.require_session_runtime_policy(
17252                &session,
17253                ProviderRuntimeRequirement::SemanticPrompt,
17254            )?;
17255            shared
17256                .dispatch_input_bounded(
17257                    &session,
17258                    InputAction::InsertDraft(PromptPayload {
17259                        text,
17260                        framing: PromptFraming::BracketedPaste,
17261                    }),
17262                )
17263                .await?;
17264            Ok(NodeResponse::Accepted)
17265        }
17266        NodeRequest::Input { session, text } => {
17267            controlled_session(shared, connection_id, role, &session)?;
17268            validate_node_text("terminal input", &text)?;
17269            shared
17270                .dispatch_input_bounded(
17271                    &session,
17272                    InputAction::TerminalText(TerminalText { text }),
17273                )
17274                .await?;
17275            Ok(NodeResponse::Accepted)
17276        }
17277        NodeRequest::TerminalBytes { session, bytes } => {
17278            controlled_session(shared, connection_id, role, &session)?;
17279            shared.sessionize_spawn(session.session.instance_id);
17280            let action = terminal_bytes_action(bytes)?;
17281            shared
17282                .dispatch_input_bounded(&session, action)
17283                .await?;
17284            Ok(NodeResponse::Accepted)
17285        }
17286        NodeRequest::TerminalControl { session, control } => {
17287            controlled_session(shared, connection_id, role, &session)?;
17288            shared
17289                .dispatch_input_bounded(&session, InputAction::TerminalControl(control))
17290                .await?;
17291            Ok(NodeResponse::Accepted)
17292        }
17293        NodeRequest::Resize { session, size } => {
17294            controlled_session(shared, connection_id, role, &session)?;
17295            shared.sessionize_spawn(session.session.instance_id);
17296            if !size.is_valid() {
17297                return Err(failure(NodeFailureCode::InvalidRequest, "terminal size is invalid"));
17298            }
17299            shared.dispatch_resize_bounded(&session, size).await?;
17300            Ok(NodeResponse::Accepted)
17301        }
17302        NodeRequest::Interrupt { session } => {
17303            controlled_session(shared, connection_id, role, &session)?;
17304            shared
17305                .dispatch_input_bounded(
17306                    &session,
17307                    InputAction::TerminalControl(TerminalControl::Interrupt),
17308                )
17309                .await?;
17310            Ok(NodeResponse::Accepted)
17311        }
17312        NodeRequest::Stop { session, force } => {
17313            controlled_session(shared, connection_id, role, &session)?;
17314            shared.sessionize_spawn(session.session.instance_id);
17315            shared.dispatch(ControlCommand::Stop { instance_id: session.session.instance_id, force })?;
17316            shared.retire_session_grok_leader(session.session.instance_id);
17317            Ok(NodeResponse::Accepted)
17318        }
17319        NodeRequest::Remove { session } => {
17320            controlled_session(shared, connection_id, role, &session)?;
17321            shared.remove_session(&session).await?;
17322            Ok(NodeResponse::Accepted)
17323        }
17324        NodeRequest::ResolveInteraction { session, correlation_id, response } => {
17325            shared.require_controller(connection_id, role)?;
17326            let snapshot = shared.require_acp_session(&session)?;
17327            let interaction = snapshot
17328                .provider
17329                .interactions
17330                .iter()
17331                .find(|interaction| {
17332                    interaction_correlation(
17333                        session.session.instance_id,
17334                        session.session.generation,
17335                        interaction.id.0,
17336                    ) == correlation_id
17337                })
17338                .ok_or_else(|| failure(
17339                    NodeFailureCode::InvalidRequest,
17340                    "unknown interaction correlation",
17341                ))?;
17342            if interaction.status != ProviderInteractionStatus::Pending {
17343                return Err(failure(
17344                    NodeFailureCode::BackendOperationFailed,
17345                    "interaction has already been resolved",
17346                ));
17347            }
17348            response
17349                .validate_for(interaction.interaction_kind)
17350                .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
17351            let interaction_id = interaction.id;
17352            let response_kind = response.kind();
17353            let response_outcome = response.outcome();
17354            shared.dispatch(ControlCommand::ResolveInteraction {
17355                instance_id: session.session.instance_id,
17356                generation: session.session.generation,
17357                interaction_id,
17358                response,
17359            })?;
17360            // The one line that lets an operator compare what THIS node
17361            // recorded against what the provider itself reports doing --
17362            // measured live on a kimi run where our side resolved `true`
17363            // while the agent reported "rejected" and neither layer had a
17364            // line to line up against the other.
17365            tracing::info!(
17366                node_id = %shared.node_id,
17367                session = ?session.session,
17368                correlation_id = %correlation_id,
17369                interaction_id = interaction_id.0,
17370                response_kind = ?response_kind,
17371                outcome = ?response_outcome,
17372                "interaction resolved",
17373            );
17374            Ok(NodeResponse::Accepted)
17375        }
17376        NodeRequest::SetSessionMode { session, mode_id } => {
17377            shared.require_controller(connection_id, role)?;
17378            shared.require_acp_session(&session)?;
17379            shared.dispatch(ControlCommand::SetSessionMode {
17380                instance_id: session.session.instance_id,
17381                mode_id,
17382            })?;
17383            Ok(NodeResponse::Accepted)
17384        }
17385        NodeRequest::SetSessionConfigOption {
17386            session,
17387            option_id,
17388            value_json,
17389        } => {
17390            shared.require_controller(connection_id, role)?;
17391            shared.require_acp_session(&session)?;
17392            shared.dispatch(ControlCommand::SetSessionConfigOption {
17393                instance_id: session.session.instance_id,
17394                option_id,
17395                value_json,
17396            })?;
17397            Ok(NodeResponse::Accepted)
17398        }
17399        NodeRequest::SetSessionModel { session, model_id } => {
17400            shared.require_controller(connection_id, role)?;
17401            shared.require_acp_session(&session)?;
17402            shared.dispatch(ControlCommand::SetSessionModel {
17403                instance_id: session.session.instance_id,
17404                model_id,
17405            })?;
17406            Ok(NodeResponse::Accepted)
17407        }
17408        NodeRequest::Shutdown => {
17409            shared.require_controller(connection_id, role)?;
17410            shared.begin_shutdown_locked().await?;
17411            Ok(NodeResponse::ShuttingDown)
17412        }
17413    }
17414}
17415
17416fn controlled_session(shared: &NodeShared, connection_id: u64, role: ClientRole, session: &SessionAddress) -> Result<AgentId, NodeFailure> {
17417    shared.require_controller(connection_id, role)?;
17418    shared.validate_address(session)
17419}
17420
17421/// Selects the sole history candidate matching `provider_session_id` when
17422/// the managed record carries a verified identity, or the sole candidate in
17423/// the discovered set at all when it does not (see
17424/// `export_context_pack_for_session_record_inner`'s identity match). Either
17425/// way, more than one match refuses instead of guessing which one is exact.
17426fn select_history_candidate(
17427    candidates: &[HistoryCandidateSummary],
17428    provider_session_id: Option<&str>,
17429) -> Result<String, NodeFailure> {
17430    let mut matches = candidates.iter().filter(|candidate| match provider_session_id {
17431        Some(id) => candidate.session_id_hint == id,
17432        None => true,
17433    });
17434    let candidate = matches.next().ok_or_else(|| {
17435        failure(
17436            NodeFailureCode::SessionRecordConflict,
17437            if provider_session_id.is_some() {
17438                "no history candidate matches the managed provider session"
17439            } else {
17440                "no history candidate exists for the managed session"
17441            },
17442        )
17443    })?;
17444    if matches.next().is_some() {
17445        return Err(failure(
17446            NodeFailureCode::SessionRecordConflict,
17447            if provider_session_id.is_some() {
17448                "history identity is ambiguous for the managed provider session"
17449            } else {
17450                "history is ambiguous without a verified provider session identity"
17451            },
17452        ));
17453    }
17454    Ok(candidate.id.clone())
17455}
17456
17457fn resolved_spawn_profile_summary(
17458    profile: &SpawnProfileDefaults,
17459    environment_profiles: &BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>,
17460) -> Option<SpawnProfileSummary> {
17461    let environment_profile = match profile.environment_profile_id.as_ref() {
17462        None => None,
17463        Some(profile_id) => {
17464            let binding = environment_profiles.get(profile_id)?;
17465            if binding.provider != profile.provider
17466                || binding.native_profile_id(profile.mode).is_none()
17467            {
17468                return None;
17469            }
17470            Some(ResolvedEnvironmentProfileReceipt {
17471                profile_id: binding.id.clone(),
17472                profile_revision: binding.revision.clone(),
17473                network_allowlist: None,
17474                browser_profile_id: None,
17475            })
17476        }
17477    };
17478    Some(SpawnProfileSummary {
17479        id: profile.profile_id.clone(),
17480        revision: profile.revision.clone(),
17481        environment_profile,
17482    })
17483}
17484
17485/// True if `current` is an acceptable version of `expected` at export
17486/// revalidation/commit time.
17487///
17488/// When `allow_clean_detachment` is `false` (the on-demand,
17489/// `NodeRequest::ExportContextPackForSessionRecord` API path), `current` must
17490/// be byte-identical to `expected` and still actively bound to `session` —
17491/// the original, unchanged invariant.
17492///
17493/// When `true` (the reactive auto-export-at-exit path, `§2.1`), `current` may
17494/// ALSO be the exact Live -> Dormant transition that the same clean exit this
17495/// export reacts to produces on its own: `state` Live -> Dormant,
17496/// `active_session` `Some(session)` -> `None`, `updated_at_unix_ms`
17497/// advancing. Every other field — including `provider_session`, `context`,
17498/// `bundle`, `task_binding` — must still match exactly. A record that changed
17499/// in any other way (rebind, a different active session, an identity
17500/// conflict, capacity eviction) is rejected either way; this widens nothing
17501/// about which sessions are eligible, only how the record's own expected
17502/// detachment is tolerated mid-export.
17503fn session_record_export_target_matches(
17504    expected: &ManagedSessionRecord,
17505    current: &ManagedSessionRecord,
17506    session: &SessionAddress,
17507    allow_clean_detachment: bool,
17508) -> bool {
17509    if current == expected && current.active_session.as_ref() == Some(session) {
17510        return current.state == ManagedSessionState::Live;
17511    }
17512    allow_clean_detachment
17513        && current.state == ManagedSessionState::Dormant
17514        && current.active_session.is_none()
17515        && current.record_id == expected.record_id
17516        && current.display_name == expected.display_name
17517        && current.provider == expected.provider
17518        && current.mode == expected.mode
17519        && current.workspace_id == expected.workspace_id
17520        && current.canonical_root == expected.canonical_root
17521        && current.provider_session == expected.provider_session
17522        && current.environment_profile == expected.environment_profile
17523        && current.bundle == expected.bundle
17524        && current.context_id == expected.context_id
17525        && current.context == expected.context
17526        && current.exported_context == expected.exported_context
17527        && current.task_binding == expected.task_binding
17528        && current.created_at_unix_ms == expected.created_at_unix_ms
17529        && current.last_error == expected.last_error
17530}
17531
17532fn session_record_context_export_binding_is_exact(
17533    expected: &ManagedSessionRecord,
17534    current: &ManagedSessionRecord,
17535    identity: Option<&ProviderSessionIdentity>,
17536    session: &SessionAddress,
17537    binding: &SessionBinding,
17538    provider: &AgentId,
17539    current_root: &str,
17540    allow_clean_detachment: bool,
17541) -> bool {
17542    session_record_export_target_matches(expected, current, session, allow_clean_detachment)
17543        && current.provider_session.as_ref() == identity
17544        && current.workspace_id == session.workspace_id
17545        && provider == &current.provider
17546        && binding.workspace_id == session.workspace_id
17547        && binding.generation == session.session.generation
17548        && binding.record_id.as_ref() == Some(&current.record_id)
17549        && platform::roots_equal(
17550            current_root,
17551            windows_path_text(&current.canonical_root),
17552        )
17553}
17554
17555fn session_record_context_export_source_is_usable(
17556    record: &ManagedSessionRecord,
17557    status: &SessionStatus,
17558    allow_clean_detachment: bool,
17559) -> bool {
17560    (record.state == ManagedSessionState::Live
17561        || (allow_clean_detachment && record.state == ManagedSessionState::Dormant))
17562        && context_pack_source_status_is_usable(status)
17563}
17564
17565fn prompt_framing(_agent_id: &AgentId) -> PromptFraming {
17566    PromptFraming::BracketedPaste
17567}
17568
17569fn validate_node_text(field: &str, text: &str) -> Result<(), NodeFailure> {
17570    if text.len() > MAX_NODE_TEXT_BYTES {
17571        return Err(failure(
17572            NodeFailureCode::InvalidRequest,
17573            &format!(
17574                "{field} length {} exceeds the {MAX_NODE_TEXT_BYTES}-byte node limit",
17575                text.len(),
17576            ),
17577        ));
17578    }
17579    Ok(())
17580}
17581
17582fn validate_terminal_bytes(bytes: &[u8]) -> Result<(), NodeFailure> {
17583    if bytes.is_empty() || bytes.len() > MAX_NODE_TERMINAL_BYTES {
17584        return Err(failure(
17585            NodeFailureCode::InvalidRequest,
17586            &format!(
17587                "terminal byte sequence must contain 1..={MAX_NODE_TERMINAL_BYTES} bytes; received {}",
17588                bytes.len(),
17589            ),
17590        ));
17591    }
17592    Ok(())
17593}
17594
17595fn terminal_bytes_action(bytes: Vec<u8>) -> Result<InputAction, NodeFailure> {
17596    validate_terminal_bytes(&bytes)?;
17597    Ok(InputAction::TerminalBytes(bytes))
17598}
17599
17600fn validate_workspace_request_root(root: &str) -> Result<(), NodeFailure> {
17601    if root.is_empty()
17602        || root.len() > MAX_WORKSPACE_ROOT_BYTES
17603        || root.chars().any(char::is_control)
17604    {
17605        return Err(failure(
17606            NodeFailureCode::InvalidWorkspaceRoot,
17607            &format!(
17608                "workspace root must contain 1..={MAX_WORKSPACE_ROOT_BYTES} bytes and no control characters",
17609            ),
17610        ));
17611    }
17612    Ok(())
17613}
17614
17615fn canonical_standalone_workspace_candidate(
17616    workspace_id: &WorkspaceId,
17617    root: &str,
17618) -> Result<String, NodeServerError> {
17619    let path = Path::new(root);
17620    match std::fs::symlink_metadata(path) {
17621        Ok(_) => WorkspaceConfig::new(workspace_id.clone(), path)
17622            .map(|workspace| workspace.canonical_root),
17623        Err(error) if error.kind() == io::ErrorKind::NotFound => {
17624            if !path.is_absolute() {
17625                return Err(NodeServerError::InvalidWorkspaceRoot {
17626                    workspace_id: workspace_id.clone(),
17627                    path: root.to_owned(),
17628                    message: "path is not absolute".to_owned(),
17629                });
17630            }
17631            let name = path.file_name().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
17632                workspace_id: workspace_id.clone(),
17633                path: root.to_owned(),
17634                message: "path has no standalone directory name".to_owned(),
17635            })?;
17636            let parent = path.parent().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
17637                workspace_id: workspace_id.clone(),
17638                path: root.to_owned(),
17639                message: "path has no parent directory".to_owned(),
17640            })?;
17641            let canonical = std::fs::canonicalize(parent)
17642                .map(|parent| parent.join(name))
17643                .map_err(|error| NodeServerError::InvalidWorkspaceRoot {
17644                    workspace_id: workspace_id.clone(),
17645                    path: root.to_owned(),
17646                    message: error.to_string(),
17647                })?;
17648            let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
17649                NodeServerError::InvalidWorkspaceRoot {
17650                    workspace_id: workspace_id.clone(),
17651                    path: path.to_string_lossy().into_owned(),
17652                    message: "canonical path is not valid Unicode".to_owned(),
17653                }
17654            })?;
17655            let canonical_root = platform::normalize_canonical_root(canonical_root);
17656            validate_workspace_root(workspace_id, &canonical_root)?;
17657            Ok(canonical_root)
17658        }
17659        Err(error) => Err(NodeServerError::InvalidWorkspaceRoot {
17660            workspace_id: workspace_id.clone(),
17661            path: root.to_owned(),
17662            message: error.to_string(),
17663        }),
17664    }
17665}
17666
17667fn validate_git_revision(field: &str, revision: Option<&str>) -> Result<(), NodeFailure> {
17668    let Some(revision) = revision else {
17669        return Ok(());
17670    };
17671    if revision.is_empty()
17672        || revision.len() > MAX_REPOSITORY_PATH_BYTES
17673        || revision.chars().any(char::is_control)
17674    {
17675        return Err(failure(
17676            NodeFailureCode::InvalidRequest,
17677            &format!(
17678                "{field} must contain 1..={MAX_REPOSITORY_PATH_BYTES} bytes and no control characters",
17679            ),
17680        ));
17681    }
17682    Ok(())
17683}
17684
17685fn git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
17686    let code = match error.kind {
17687        GitWorktreeErrorKind::Invalid => NodeFailureCode::InvalidRequest,
17688        GitWorktreeErrorKind::NotRepository => NodeFailureCode::NotGitRepository,
17689        GitWorktreeErrorKind::Conflict => NodeFailureCode::WorktreeConflict,
17690        GitWorktreeErrorKind::Protected => NodeFailureCode::WorktreeProtected,
17691        GitWorktreeErrorKind::Dirty => NodeFailureCode::WorktreeDirty,
17692        GitWorktreeErrorKind::Locked => NodeFailureCode::WorktreeLocked,
17693        GitWorktreeErrorKind::Failed => NodeFailureCode::BackendOperationFailed,
17694    };
17695    failure(code, &error.message)
17696}
17697
17698fn standalone_workspace_failure(error: StandaloneWorkspaceError) -> NodeFailure {
17699    let code = match error.kind {
17700        StandaloneWorkspaceErrorKind::InvalidRoot => NodeFailureCode::InvalidWorkspaceRoot,
17701        StandaloneWorkspaceErrorKind::InvalidInitialBranch => NodeFailureCode::InvalidRequest,
17702        StandaloneWorkspaceErrorKind::GitInitializationFailed => {
17703            NodeFailureCode::BackendOperationFailed
17704        }
17705        StandaloneWorkspaceErrorKind::RecoveryRequired => {
17706            NodeFailureCode::StandaloneWorkspaceRecoveryRequired
17707        }
17708    };
17709    failure(code, error.message)
17710}
17711
17712fn managed_git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
17713    let (code, message) = match error.kind {
17714        GitWorktreeErrorKind::Invalid => (
17715            NodeFailureCode::InvalidRequest,
17716            "managed Git input is invalid",
17717        ),
17718        GitWorktreeErrorKind::NotRepository => (
17719            NodeFailureCode::NotGitRepository,
17720            "managed source is not a Git repository",
17721        ),
17722        GitWorktreeErrorKind::Conflict => (
17723            NodeFailureCode::WorktreeConflict,
17724            "managed Git identity conflicts with existing repository state",
17725        ),
17726        GitWorktreeErrorKind::Protected => (
17727            NodeFailureCode::ManagedWorktreeOwnershipConflict,
17728            "managed worktree ownership validation failed",
17729        ),
17730        GitWorktreeErrorKind::Dirty => (
17731            NodeFailureCode::WorktreeDirty,
17732            "managed worktree contains uncommitted changes",
17733        ),
17734        GitWorktreeErrorKind::Locked => (
17735            NodeFailureCode::WorktreeLocked,
17736            "managed worktree is locked",
17737        ),
17738        GitWorktreeErrorKind::Failed => (
17739            NodeFailureCode::BackendOperationFailed,
17740            "managed Git operation failed",
17741        ),
17742    };
17743    NodeFailure { code, message: message.to_owned() }
17744}
17745
17746fn managed_spawn_receipt(
17747    resolved: &ResolvedSpawnSpec,
17748    incarnation_id: NodeIncarnationId,
17749    session: SessionAddress,
17750    lease: ManagedWorktreeLeaseSnapshot,
17751    environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
17752    bundle: Option<ResolvedBundleReceipt>,
17753    context: Option<ResolvedContextPackReceipt>,
17754) -> ManagedWorktreeSpawnReceipt {
17755    let mut managed = resolved.clone();
17756    managed.target.worktree_id = Some(lease.workspace_id.clone());
17757    ManagedWorktreeSpawnReceipt {
17758        spawn: managed.receipt_with_materialization(
17759            incarnation_id,
17760            session,
17761            environment_profile,
17762            bundle,
17763            context,
17764        ),
17765        lease,
17766    }
17767}
17768
17769fn validate_selected_worktree(
17770    source_root: &str,
17771    selected_root: &str,
17772    worktrees: &[NativeGitWorktreeSnapshot],
17773) -> Result<(), NodeFailure> {
17774    if !worktrees
17775        .iter()
17776        .any(|worktree| worktree_paths_equal(&worktree.path, source_root) && !worktree.is_bare)
17777    {
17778        return Err(failure(
17779            NodeFailureCode::WorktreeProtected,
17780            "source workspace is not an authoritative non-bare Git worktree root",
17781        ));
17782    }
17783    if !worktrees.iter().any(|worktree| {
17784        worktree_paths_equal(&worktree.path, selected_root)
17785            && !worktree.is_main
17786            && !worktree.is_bare
17787    }) {
17788        return Err(failure(
17789            NodeFailureCode::WorktreeProtected,
17790            "selected workspace is not an eligible Git worktree in the source repository",
17791        ));
17792    }
17793    Ok(())
17794}
17795
17796fn workspace_file_failure(error: WorkspaceFileReadError) -> NodeFailure {
17797    let code = match error.kind() {
17798        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17799        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17800            NodeFailureCode::RepositoryPathUnsafe
17801        }
17802        WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17803        WorkspaceFileReadErrorKind::NotRegularFile => {
17804            NodeFailureCode::RepositoryFileNotRegular
17805        }
17806        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17807            NodeFailureCode::RepositoryFileReadFailed
17808        }
17809        WorkspaceFileReadErrorKind::RevisionConflict => {
17810            NodeFailureCode::RepositoryFileRevisionConflict
17811        }
17812        WorkspaceFileReadErrorKind::AlreadyExists
17813        | WorkspaceFileReadErrorKind::ParentNotFound
17814        | WorkspaceFileReadErrorKind::ParentNotDirectory
17815        | WorkspaceFileReadErrorKind::Canceled => {
17816            NodeFailureCode::RepositoryFileReadFailed
17817        }
17818    };
17819    failure(code, "repository file read failed")
17820}
17821
17822fn workspace_file_write_failure(error: WorkspaceFileReadError) -> NodeFailure {
17823    let code = match error.kind() {
17824        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17825        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17826            NodeFailureCode::RepositoryPathUnsafe
17827        }
17828        WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17829        WorkspaceFileReadErrorKind::NotRegularFile => {
17830            NodeFailureCode::RepositoryFileNotRegular
17831        }
17832        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17833            NodeFailureCode::RepositoryFileWriteFailed
17834        }
17835        WorkspaceFileReadErrorKind::RevisionConflict => {
17836            NodeFailureCode::RepositoryFileRevisionConflict
17837        }
17838        WorkspaceFileReadErrorKind::AlreadyExists
17839        | WorkspaceFileReadErrorKind::ParentNotFound
17840        | WorkspaceFileReadErrorKind::ParentNotDirectory
17841        | WorkspaceFileReadErrorKind::Canceled => {
17842            NodeFailureCode::RepositoryFileWriteFailed
17843        }
17844    };
17845    failure(code, "repository file write failed")
17846}
17847
17848async fn settle_workspace_entry_create<T>(
17849    mut task: tokio::task::JoinHandle<Result<T, WorkspaceFileReadError>>,
17850    commit_state: Arc<AtomicU8>,
17851    deadline: Duration,
17852    timeout_message: &'static str,
17853    task_failure_message: &'static str,
17854) -> Result<T, NodeFailure> {
17855    let joined = match timeout(deadline, &mut task).await {
17856        Ok(joined) => joined,
17857        Err(_) => {
17858            if commit_state
17859                .compare_exchange(
17860                    WORKSPACE_ENTRY_CREATE_PENDING,
17861                    WORKSPACE_ENTRY_CREATE_CANCELED,
17862                    Ordering::AcqRel,
17863                    Ordering::Acquire,
17864                )
17865                .is_ok()
17866            {
17867                return Err(failure(
17868                    NodeFailureCode::RepositoryEntryCreateTimedOut,
17869                    timeout_message,
17870                ));
17871            }
17872            debug_assert_eq!(
17873                commit_state.load(Ordering::Acquire),
17874                WORKSPACE_ENTRY_CREATE_COMMITTING,
17875            );
17876            task.await
17877        }
17878    };
17879    joined
17880        .map_err(|_| {
17881            failure(
17882                NodeFailureCode::RepositoryEntryCreateFailed,
17883                task_failure_message,
17884            )
17885        })?
17886        .map_err(workspace_entry_create_failure)
17887}
17888
17889fn workspace_entry_create_failure(error: WorkspaceFileReadError) -> NodeFailure {
17890    let code = match error.kind() {
17891        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17892        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17893            NodeFailureCode::RepositoryPathUnsafe
17894        }
17895        WorkspaceFileReadErrorKind::AlreadyExists => {
17896            NodeFailureCode::RepositoryEntryAlreadyExists
17897        }
17898        WorkspaceFileReadErrorKind::ParentNotFound | WorkspaceFileReadErrorKind::NotFound => {
17899            NodeFailureCode::RepositoryParentNotFound
17900        }
17901        WorkspaceFileReadErrorKind::ParentNotDirectory
17902        | WorkspaceFileReadErrorKind::NotRegularFile => {
17903            NodeFailureCode::RepositoryParentNotDirectory
17904        }
17905        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17906            NodeFailureCode::RepositoryEntryCreateFailed
17907        }
17908        WorkspaceFileReadErrorKind::RevisionConflict => {
17909            NodeFailureCode::RepositoryEntryCreateFailed
17910        }
17911        WorkspaceFileReadErrorKind::Canceled => {
17912            NodeFailureCode::RepositoryEntryCreateTimedOut
17913        }
17914    };
17915    failure(code, "repository entry create failed")
17916}
17917
17918fn workspace_file_revision(bytes: &[u8]) -> WorkspaceFileRevision {
17919    let value = digest(&SHA256, bytes)
17920        .as_ref()
17921        .iter()
17922        .map(|byte| format!("{byte:02x}"))
17923        .collect();
17924    WorkspaceFileRevision::new(value).expect("SHA-256 produces a valid workspace revision")
17925}
17926
17927fn managed_spawn_request_digest_v2(
17928    request: &ManagedWorktreeSpawnRequestV2,
17929) -> Result<String, NodeFailure> {
17930    let encoded = serde_json::to_vec(request).map_err(|_| {
17931        failure(
17932            NodeFailureCode::InvalidRequest,
17933            "managed worktree V2 request could not be canonicalized",
17934        )
17935    })?;
17936    let mut value = String::with_capacity(71);
17937    value.push_str("sha256:");
17938    for byte in digest(&SHA256, &encoded).as_ref() {
17939        use std::fmt::Write as _;
17940        write!(&mut value, "{byte:02x}").expect("writing to a String cannot fail");
17941    }
17942    Ok(value)
17943}
17944
17945fn native_session_preview_failure(error: NativeSessionPreviewError) -> NodeFailure {
17946    let code = match error {
17947        NativeSessionPreviewError::InvalidLimit => NodeFailureCode::InvalidRequest,
17948        NativeSessionPreviewError::WorkspaceUnavailable => NodeFailureCode::UnknownWorkspace,
17949        NativeSessionPreviewError::StaleCatalog => NodeFailureCode::StaleNativeSessionCatalog,
17950        NativeSessionPreviewError::UnsupportedProvider
17951        | NativeSessionPreviewError::PreviewUnavailable
17952        | NativeSessionPreviewError::SessionNotFound
17953        | NativeSessionPreviewError::AmbiguousSession => {
17954            NodeFailureCode::BackendOperationFailed
17955        }
17956    };
17957    failure(code, "native session preview failed")
17958}
17959
17960fn host_directory_failure(error: HostDirectoryBrowseError) -> NodeFailure {
17961    let code = match error.kind() {
17962        HostDirectoryBrowseErrorKind::Invalid => NodeFailureCode::HostDirectoryInvalid,
17963        HostDirectoryBrowseErrorKind::ReadFailed => NodeFailureCode::HostDirectoryReadFailed,
17964    };
17965    failure(code, "host directory browse failed")
17966}
17967
17968fn delivery_failure(error: DeliveryStoreError) -> NodeFailure {
17969    let code = match error {
17970        DeliveryStoreError::InvalidManifest => NodeFailureCode::DeliveryManifestInvalid,
17971        DeliveryStoreError::UnknownStage => NodeFailureCode::UnknownDeliveryStage,
17972        DeliveryStoreError::Capacity | DeliveryStoreError::StageConflict => {
17973            NodeFailureCode::DeliveryStageConflict
17974        }
17975        DeliveryStoreError::UnexpectedBlob => NodeFailureCode::DeliveryBlobUnexpected,
17976        DeliveryStoreError::ChunkOutOfOrder | DeliveryStoreError::ChunkOverflow => {
17977            NodeFailureCode::DeliveryChunkOutOfOrder
17978        }
17979        DeliveryStoreError::BlobDigestMismatch => {
17980            NodeFailureCode::DeliveryBlobDigestMismatch
17981        }
17982        DeliveryStoreError::BundleDigestMismatch => {
17983            NodeFailureCode::DeliveryBundleDigestMismatch
17984        }
17985        DeliveryStoreError::StageIncomplete => NodeFailureCode::DeliveryStageIncomplete,
17986        DeliveryStoreError::Unavailable
17987        | DeliveryStoreError::Corrupt
17988        | DeliveryStoreError::Storage(_) => NodeFailureCode::DeliveryStageStorageFailed,
17989    };
17990    failure(code, "delivery operation failed")
17991}
17992
17993fn validate_workspace_root(
17994    workspace_id: &WorkspaceId,
17995    canonical_root: &str,
17996) -> Result<(), NodeServerError> {
17997    #[cfg(windows)]
17998    if canonical_root.starts_with(r"\\") {
17999        return Err(NodeServerError::InvalidWorkspaceRoot {
18000            workspace_id: workspace_id.clone(),
18001            path: canonical_root.to_owned(),
18002            message: "UNC workspace roots are unsupported by Windows PTY providers".to_owned(),
18003        });
18004    }
18005    if !platform::workspace_root_supported(canonical_root)
18006        || canonical_root.len() > WORKING_DIRECTORY_MAX_BYTES
18007    {
18008        return Err(NodeServerError::InvalidWorkspaceRoot {
18009            workspace_id: workspace_id.clone(),
18010            path: canonical_root.to_owned(),
18011            message: "canonical path must be absolute, bounded, and free of control characters"
18012                .to_owned(),
18013        });
18014    }
18015    Ok(())
18016}
18017
18018fn failure(code: NodeFailureCode, _message: &str) -> NodeFailure {
18019    NodeFailure {
18020        code,
18021        message: node_failure_category(code).to_owned(),
18022    }
18023}
18024
18025fn harness_mcp_failure(error: HarnessMcpProxyError) -> NodeFailure {
18026    let code = match error {
18027        HarnessMcpProxyError::Unavailable => NodeFailureCode::HarnessMcpUnavailable,
18028        HarnessMcpProxyError::NotFound => NodeFailureCode::ReservationNotFound,
18029        HarnessMcpProxyError::Conflict => NodeFailureCode::ReservationConflict,
18030        HarnessMcpProxyError::Expired => NodeFailureCode::ReservationExpired,
18031        HarnessMcpProxyError::BindingMismatch => NodeFailureCode::BindingMismatch,
18032        HarnessMcpProxyError::NotActivated => NodeFailureCode::NotActivated,
18033        HarnessMcpProxyError::CallNotFound => NodeFailureCode::CallNotFound,
18034        HarnessMcpProxyError::ChunkOutOfOrder => NodeFailureCode::ChunkOutOfOrder,
18035        HarnessMcpProxyError::ResponseTooLarge => NodeFailureCode::ResponseTooLarge,
18036    };
18037    failure(code, "harness MCP proxy operation failed")
18038}
18039
18040/// Numbers each harness-MCP overlay this process constructs, in
18041/// construction order, so two overlays built back to back in one node process
18042/// still get distinct trace files.
18043static NEXT_HARNESS_MCP_TRACE_SEQUENCE: AtomicU64 = AtomicU64::new(1);
18044
18045/// Builds the optional trace environment entry of one harness-MCP overlay.
18046/// The caller's launch description names both the entry and the node-process
18047/// variable that opts in: when that variable holds a non-empty directory, the
18048/// entry names a trace file under it. Returns `None` when the launch has no
18049/// trace, or the directory is unset, empty, or not valid Unicode. Never creates
18050/// the directory; that is the operator's job.
18051fn harness_mcp_trace_env_entry(launch: &HarnessMcpLaunchV1) -> Option<(OsString, OsString)> {
18052    let trace = launch.trace.as_ref()?;
18053    let trace_dir = std::env::var(&trace.dir_env).ok()?;
18054    if trace_dir.is_empty() {
18055        return None;
18056    }
18057    let sequence = NEXT_HARNESS_MCP_TRACE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
18058    let path = std::path::Path::new(&trace_dir).join(format!("harness-mcp-{sequence}.trace"));
18059    Some((OsString::from(trace.env.as_str()), path.into_os_string()))
18060}
18061
18062fn spawn_deadline_remaining(deadline: Instant) -> Result<Duration, NodeFailure> {
18063    let remaining = deadline.saturating_duration_since(Instant::now());
18064    if remaining.is_zero() {
18065        Err(failure(
18066            NodeFailureCode::SpawnDeadlineExceeded,
18067            "spawn deadline elapsed",
18068        ))
18069    } else {
18070        Ok(remaining)
18071    }
18072}
18073
18074fn spawn_dispatch_timeout(deadline: Option<Instant>) -> Result<Duration, NodeFailure> {
18075    let bounded = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
18076    match deadline {
18077        Some(deadline) => Ok(spawn_deadline_remaining(deadline)?.min(bounded)),
18078        None => Ok(bounded),
18079    }
18080}
18081
18082fn spawn_dispatch_error(error: NodeFailure, deadline: Option<Instant>) -> NodeFailure {
18083    if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
18084        failure(
18085            NodeFailureCode::SpawnDeadlineExceeded,
18086            "spawn dispatch exceeded the spawn deadline",
18087        )
18088    } else {
18089        error
18090    }
18091}
18092
18093fn spawn_runtime_capabilities(
18094    capabilities: &SpawnRequiredCapabilities,
18095) -> Result<Vec<ProviderRuntimeCapability>, NodeFailure> {
18096    capabilities
18097        .iter()
18098        .map(|capability| match capability.as_str() {
18099            SPAWN_RUNTIME_RAW_PTY_LIFECYCLE => Ok(ProviderRuntimeCapability::RawPtyLifecycle),
18100            SPAWN_RUNTIME_SEMANTIC_READINESS => Ok(ProviderRuntimeCapability::SemanticReadiness),
18101            SPAWN_RUNTIME_STRUCTURED_PROMPT => Ok(ProviderRuntimeCapability::StructuredPrompt),
18102            SPAWN_RUNTIME_PROVIDER_SESSION_IDENTITY => {
18103                Ok(ProviderRuntimeCapability::ProviderSessionIdentity)
18104            }
18105            SPAWN_RUNTIME_SEMANTIC_RESUME => Ok(ProviderRuntimeCapability::SemanticResume),
18106            _ => Err(failure(
18107                NodeFailureCode::UnsupportedSpawnCapability,
18108                "spawn required capability is unknown",
18109            )),
18110        })
18111        .collect()
18112}
18113
18114fn persistence_failure(_error: io::Error) -> NodeFailure {
18115    NodeFailure {
18116        code: NodeFailureCode::BackendOperationFailed,
18117        message: DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned(),
18118    }
18119}
18120
18121fn node_failure_category(code: NodeFailureCode) -> &'static str {
18122    match code {
18123        NodeFailureCode::InvalidRequest => "invalid-request",
18124        NodeFailureCode::UnsupportedCapability => "unsupported-capability",
18125        NodeFailureCode::HarnessMcpUnavailable => "harness-mcp-unavailable",
18126        NodeFailureCode::ReservationNotFound => "reservation-not-found",
18127        NodeFailureCode::ReservationConflict => "reservation-conflict",
18128        NodeFailureCode::ReservationExpired => "reservation-expired",
18129        NodeFailureCode::BindingMismatch => "binding-mismatch",
18130        NodeFailureCode::NotActivated => "not-activated",
18131        NodeFailureCode::CallNotFound => "call-not-found",
18132        NodeFailureCode::ChunkOutOfOrder => "chunk-out-of-order",
18133        NodeFailureCode::ResponseTooLarge => "response-too-large",
18134        NodeFailureCode::DeliveryManifestInvalid => "delivery-manifest-invalid",
18135        NodeFailureCode::UnknownDeliveryStage => "unknown-delivery-stage",
18136        NodeFailureCode::DeliveryStageConflict => "delivery-stage-conflict",
18137        NodeFailureCode::DeliveryBlobUnexpected => "delivery-blob-unexpected",
18138        NodeFailureCode::DeliveryChunkOutOfOrder => "delivery-chunk-out-of-order",
18139        NodeFailureCode::DeliveryBlobDigestMismatch => "delivery-blob-digest-mismatch",
18140        NodeFailureCode::DeliveryBundleDigestMismatch => "delivery-bundle-digest-mismatch",
18141        NodeFailureCode::DeliveryStageIncomplete => "delivery-stage-incomplete",
18142        NodeFailureCode::DeliveryStageStorageFailed => "delivery-stage-storage-failed",
18143        NodeFailureCode::Unauthorized => "unauthorized",
18144        NodeFailureCode::ObserverReadOnly => "observer-read-only",
18145        NodeFailureCode::ControllerBusy => "controller-busy",
18146        NodeFailureCode::ControllerRequired => "controller-required",
18147        NodeFailureCode::UnknownWorkspace => "unknown-workspace",
18148        NodeFailureCode::HostDirectoryInvalid => "host-directory-invalid",
18149        NodeFailureCode::HostDirectoryReadFailed => "host-directory-read-failed",
18150        NodeFailureCode::HostDirectoryReadTimedOut => "host-directory-read-timed-out",
18151        NodeFailureCode::InvalidRepositoryPath => "invalid-repository-path",
18152        NodeFailureCode::RepositoryFileNotFound => "repository-file-not-found",
18153        NodeFailureCode::RepositoryFileNotRegular => "repository-file-not-regular",
18154        NodeFailureCode::RepositoryPathUnsafe => "repository-path-unsafe",
18155        NodeFailureCode::RepositoryFileReadTimedOut => "repository-file-read-timed-out",
18156        NodeFailureCode::RepositoryFileReadFailed => "repository-file-read-failed",
18157        NodeFailureCode::RepositoryFileWriteTimedOut => "repository-file-write-timed-out",
18158        NodeFailureCode::RepositoryFileWriteFailed => "repository-file-write-failed",
18159        NodeFailureCode::RepositoryFileRevisionConflict => "repository-file-revision-conflict",
18160        NodeFailureCode::RepositoryEntryAlreadyExists => "repository-entry-already-exists",
18161        NodeFailureCode::RepositoryParentNotFound => "repository-parent-not-found",
18162        NodeFailureCode::RepositoryParentNotDirectory => "repository-parent-not-directory",
18163        NodeFailureCode::RepositoryEntryCreateTimedOut => "repository-entry-create-timed-out",
18164        NodeFailureCode::RepositoryEntryCreateFailed => "repository-entry-create-failed",
18165        NodeFailureCode::GitReadTimedOut => "git-read-timed-out",
18166        NodeFailureCode::GitReadFailed => "git-read-failed",
18167        NodeFailureCode::UnknownSpawnProfile => "unknown-spawn-profile",
18168        NodeFailureCode::SpawnProfileRevisionMismatch => "spawn-profile-revision-mismatch",
18169        NodeFailureCode::SpawnTargetMismatch => "spawn-target-mismatch",
18170        NodeFailureCode::SpawnIdempotencyConflict => "spawn-idempotency-conflict",
18171        NodeFailureCode::SpawnIdempotencyCapacity => "spawn-idempotency-capacity",
18172        NodeFailureCode::SpawnDeadlineExceeded => "spawn-deadline-exceeded",
18173        NodeFailureCode::UnsupportedSpawnCapability => "unsupported-spawn-capability",
18174        NodeFailureCode::UnsupportedTransport => "unsupported-transport",
18175        NodeFailureCode::TurnInFlight => "turn-in-flight",
18176        NodeFailureCode::UnknownEnvironmentProfile => "unknown-environment-profile",
18177        NodeFailureCode::UnknownNetworkAllowlist => "unknown-network-allowlist",
18178        NodeFailureCode::UnsupportedNetworkAllowlistMapping => {
18179            "unsupported-network-allowlist-mapping"
18180        }
18181        NodeFailureCode::BrowserStationProbeUnavailable => {
18182            "browser-station-probe-unavailable"
18183        }
18184        NodeFailureCode::BrowserStationUnreachable => "browser-station-unreachable",
18185        NodeFailureCode::BrowserStationProfileBusy => "browser-station-profile-busy",
18186        NodeFailureCode::EnvironmentProfileBindingMismatch => {
18187            "environment-profile-binding-mismatch"
18188        }
18189        NodeFailureCode::UnknownBundle => "unknown-bundle",
18190        NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
18191        NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
18192        NodeFailureCode::UnknownContextPack => "unknown-context-pack",
18193        NodeFailureCode::ContextPackBusy => "context-pack-busy",
18194        NodeFailureCode::ContextPackMaterializationFailed => {
18195            "context-pack-materialization-failed"
18196        }
18197        NodeFailureCode::InvalidWorkspaceRoot => "invalid-workspace-root",
18198        NodeFailureCode::DuplicateWorkspaceId => "duplicate-workspace-id",
18199        NodeFailureCode::DuplicateWorkspaceRoot => "duplicate-workspace-root",
18200        NodeFailureCode::WorkspaceBusy => "workspace-busy",
18201        NodeFailureCode::LastWorkspace => "last-workspace",
18202        NodeFailureCode::NotGitRepository => "not-git-repository",
18203        NodeFailureCode::WorktreeConflict => "worktree-conflict",
18204        NodeFailureCode::WorktreeProtected => "worktree-protected",
18205        NodeFailureCode::WorktreeDirty => "worktree-dirty",
18206        NodeFailureCode::WorktreeLocked => "worktree-locked",
18207        NodeFailureCode::UnknownManagedWorktreeLease => "unknown-managed-worktree-lease",
18208        NodeFailureCode::ManagedWorktreeBusy => "managed-worktree-busy",
18209        NodeFailureCode::ManagedWorktreeOwnershipConflict => "managed-worktree-ownership-conflict",
18210        NodeFailureCode::ManagedWorktreeProfileRevisionMismatch => {
18211            "managed-worktree-profile-revision-mismatch"
18212        }
18213        NodeFailureCode::ManagedWorktreeRecoveryRequired => "managed-worktree-recovery-required",
18214        NodeFailureCode::StandaloneWorkspaceRecoveryRequired => {
18215            "standalone-workspace-recovery-required"
18216        }
18217        NodeFailureCode::UnknownSession => "unknown-session",
18218        NodeFailureCode::UnknownSessionRecord => "unknown-session-record",
18219        NodeFailureCode::SessionRecordNotResumable => "session-record-not-resumable",
18220        NodeFailureCode::SessionRecordBusy => "session-record-busy",
18221        NodeFailureCode::SessionRecordConflict => "session-record-conflict",
18222        NodeFailureCode::SessionWorkspaceMismatch => "session-workspace-mismatch",
18223        NodeFailureCode::WorkspaceRegistrationRequired => {
18224            "workspace-registration-required"
18225        }
18226        NodeFailureCode::StaleNativeSessionCatalog => "stale-native-session-catalog",
18227        NodeFailureCode::StaleGeneration => "stale-generation",
18228        NodeFailureCode::BackendBusy => "backend-busy",
18229        NodeFailureCode::BackendDisconnected => "backend-disconnected",
18230        NodeFailureCode::BackendOperationFailed => "backend-operation-failed",
18231        NodeFailureCode::ShuttingDown => "shutting-down",
18232    }
18233}
18234
18235fn durable_state_server_error(error: io::Error, category: &'static str) -> NodeServerError {
18236    NodeServerError::DurableState(io::Error::new(error.kind(), category))
18237}
18238
18239fn durable_state_load_error(error: io::Error) -> NodeServerError {
18240    let category = match session_registry::state_load_refusal(&error) {
18241        Some(session_registry::StateLoadRefusal::UnsupportedSchema) => {
18242            DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR
18243        }
18244        Some(session_registry::StateLoadRefusal::PathSemanticsUnsupported) => {
18245            DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR
18246        }
18247        Some(session_registry::StateLoadRefusal::NodeIdentityMismatch) => {
18248            DURABLE_STATE_CONFLICT_ERROR
18249        }
18250        _ => DURABLE_STATE_LOAD_FAILED_ERROR,
18251    };
18252    durable_state_server_error(error, category)
18253}
18254
18255fn unix_time_ms() -> u64 {
18256    std::time::SystemTime::now()
18257        .duration_since(std::time::UNIX_EPOCH)
18258        .unwrap_or_default()
18259        .as_millis()
18260        .min(u64::MAX as u128) as u64
18261}
18262
18263fn detached_record_state(record: &ManagedSessionRecord) -> ManagedSessionState {
18264    if record.provider_session.is_some() {
18265        ManagedSessionState::Dormant
18266    } else {
18267        ManagedSessionState::Unavailable
18268    }
18269}
18270
18271fn active_record_state(
18272    identity_admitted: bool,
18273    provider_session_is_known: bool,
18274) -> ManagedSessionState {
18275    if identity_admitted && !provider_session_is_known {
18276        ManagedSessionState::IdentityPending
18277    } else {
18278        ManagedSessionState::Live
18279    }
18280}
18281
18282fn managed_worktree_record_holders(
18283    records: &[ManagedSessionRecord],
18284) -> Vec<(SessionRecordId, WorkspaceId)> {
18285    records
18286        .iter()
18287        .filter(|record| record.provider_session.is_some())
18288        .map(|record| (record.record_id.clone(), record.workspace_id.clone()))
18289        .collect()
18290}
18291
18292fn managed_spawn_record_ownership(
18293    binding: &SessionBinding,
18294    effective_runtime_policy: ProviderRuntimePolicy,
18295) -> (Option<SessionRecordId>, Option<SessionRecordId>) {
18296    debug_assert_eq!(binding.runtime_policy, effective_runtime_policy);
18297    if effective_runtime_policy.provider_session_identity {
18298        (binding.record_id.clone(), None)
18299    } else {
18300        (None, binding.record_id.clone())
18301    }
18302}
18303
18304#[derive(Debug, Error)]
18305pub enum NodeServerError {
18306    #[error("node endpoint must be a bounded absolute local transport path")]
18307    InvalidEndpoint,
18308    #[error("node access token must contain 1..=4096 bytes")]
18309    InvalidAccessToken,
18310    #[error("node HTTP observer must listen on a loopback address: {0}")]
18311    InvalidApiListen(std::net::SocketAddr),
18312    #[error("node browser bridge must listen on a loopback address: {0}")]
18313    InvalidBridgeListen(std::net::SocketAddr),
18314    #[error("node browser bridge token must contain 1..=4096 bytes")]
18315    InvalidBridgeToken,
18316    #[error("bridge underlay requires --bridge-listen (local HTTP+WS door)")]
18317    BridgeUnderlayRequiresBridgeListen,
18318    #[error("bridge underlay requires GATE4AGENT_BRIDGE_TOKEN (application barrier)")]
18319    BridgeUnderlayRequiresBridgeToken,
18320    #[error("mesh underlay auth token must contain 1..=4096 bytes")]
18321    InvalidBridgeUnderlayToken,
18322    #[error("mesh underlay transport key must be 32 bytes (64 hex digits)")]
18323    InvalidBridgeUnderlayKey,
18324    #[error("node call-home relay must be loopback or a concrete C2 tunnel address with a nonzero port: {0}")]
18325    InvalidCallHome(std::net::SocketAddr),
18326    #[error("node could not reach its call-home relay: {0}")]
18327    CallHomeTransport(#[source] std::io::Error),
18328    #[error("node durable state path must be an absolute file path: {0}")]
18329    InvalidStatePath(String),
18330    #[error("harness MCP helper must be an exact reviewed absolute regular file")]
18331    InvalidHarnessMcpHelper,
18332    #[error("network allowlist catalog path must be an absolute regular file: {0}")]
18333    InvalidNetworkAllowlistCatalogPath(String),
18334    #[error("network allowlist catalog could not be read: {0}")]
18335    NetworkAllowlistCatalogIo(#[source] std::io::Error),
18336    #[error("network allowlist catalog line {line}: {message}")]
18337    InvalidNetworkAllowlistCatalogEntry { line: usize, message: String },
18338    #[error("network allowlist catalog: {0}")]
18339    InvalidNetworkAllowlistCatalog(String),
18340    #[error("network allowlist catalog exceeds the {max}-entry limit")]
18341    NetworkAllowlistCatalogCapacity { max: usize },
18342    #[error("the local state directory is unavailable; supply an explicit state path")]
18343    LocalStateDirectoryUnavailable,
18344    #[error("the local runtime directory is unavailable; supply an explicit endpoint")]
18345    LocalRuntimeDirectoryUnavailable,
18346    #[error("node durable state failed: {0}")]
18347    DurableState(io::Error),
18348    #[error("node requires at least one configured workspace")]
18349    NoWorkspaces,
18350    #[error("workspace '{workspace_id}' root '{path}' is invalid: {message}")]
18351    InvalidWorkspaceRoot {
18352        workspace_id: WorkspaceId,
18353        path: String,
18354        message: String,
18355    },
18356    #[error("duplicate workspace ID: {0}")]
18357    DuplicateWorkspaceId(WorkspaceId),
18358    #[error("workspaces '{first}' and '{second}' resolve to the same root '{root}'")]
18359    DuplicateWorkspaceRoot {
18360        first: WorkspaceId,
18361        second: WorkspaceId,
18362        root: String,
18363    },
18364    #[error("workspace '{0}' uses managed worktrees but has no local profile")]
18365    ManagedWorktreeProfileRequired(WorkspaceId),
18366    #[error("workspace '{0}' has managed worktree profiles but is not in managed mode")]
18367    ManagedWorktreeProfileModeMismatch(WorkspaceId),
18368    #[error("workspace '{workspace_id}' has duplicate managed worktree profile '{profile_id}'")]
18369    DuplicateManagedWorktreeProfile {
18370        workspace_id: WorkspaceId,
18371        profile_id: WorktreeProfileId,
18372    },
18373    #[error("workspace '{workspace_id}' exceeds the {max}-profile managed worktree limit")]
18374    ManagedWorktreeProfileCapacity {
18375        workspace_id: WorkspaceId,
18376        max: usize,
18377    },
18378    #[error("workspace '{workspace_id}' managed worktree profile '{profile_id}' is invalid: {message}")]
18379    InvalidManagedWorktreeProfile {
18380        workspace_id: WorkspaceId,
18381        profile_id: WorktreeProfileId,
18382        message: String,
18383    },
18384    #[error("duplicate node environment profile: {0}")]
18385    DuplicateEnvironmentProfile(SpawnEnvironmentProfileId),
18386    #[error("node environment profile provider is unavailable: {0}")]
18387    EnvironmentProfileProviderUnavailable(AgentId),
18388    #[error("node environment profile capacity is {max}")]
18389    EnvironmentProfileCapacity { max: usize },
18390    #[error("node environment profiles reuse native profile ID '{0}'")]
18391    DuplicateNativeEnvironmentProfile(String),
18392    #[error("node native environment profile is invalid: {0}")]
18393    NativeEnvironmentProfile(String),
18394    #[error("node session-environment materialization root must be absolute")]
18395    InvalidSessionEnvironmentRoot,
18396    #[error("node session-environment materializer is unavailable")]
18397    SessionEnvironmentMaterializerRequired,
18398    #[error("node session-environment materializer failed to initialize")]
18399    SessionEnvironmentMaterializer,
18400    #[error("node bundle catalog is invalid: {0}")]
18401    BundleCatalog(String),
18402    #[error("node delivery store failed to initialize: {0}")]
18403    DeliveryStore(String),
18404    #[error("node context pack store failed to initialize")]
18405    ContextPackStore,
18406    #[error("active agent registry failed: {0}")]
18407    Registry(String),
18408    #[error("node provider contract manifest is invalid: {0}")]
18409    ProviderContractManifest(String),
18410    #[error("named pipe I/O failed: {0}")]
18411    Io(#[from] io::Error),
18412    #[error("node HTTP observer failed: {0}")]
18413    HttpApi(io::Error),
18414    #[error("node browser bridge failed: {0}")]
18415    BridgeApi(io::Error),
18416    #[error(transparent)]
18417    Frame(#[from] FrameError),
18418    #[error("node handshake failed: {0}")]
18419    Handshake(String),
18420    #[error("node authentication frame was not received before the bounded deadline")]
18421    AuthenticationTimedOut,
18422    #[error("node authentication primitive failed: {0}")]
18423    Authentication(String),
18424    #[error("node incarnation identity generation failed: {0}")]
18425    IncarnationIdentity(String),
18426    #[error("node pre-authentication limiter is closed")]
18427    PreauthClosed,
18428    #[error("node authenticated connection limit was reached")]
18429    AuthenticatedConnectionLimit,
18430    #[error("node Ctrl+C/CtrlBreak signal stream closed")]
18431    SignalStreamClosed,
18432    #[error("node stop-all dispatch failed: {0:?}")]
18433    ShutdownDispatch(NodeFailure),
18434    #[error("node shutdown timed out with {active_native_sessions} physical sessions retained")]
18435    ShutdownTimedOut { active_native_sessions: usize },
18436}
18437
18438impl From<crate::network_allowlist_catalog::NetworkAllowlistCatalogError> for NodeServerError {
18439    fn from(error: crate::network_allowlist_catalog::NetworkAllowlistCatalogError) -> Self {
18440        use crate::network_allowlist_catalog::NetworkAllowlistCatalogError as E;
18441        match error {
18442            E::InvalidPath(path) => Self::InvalidNetworkAllowlistCatalogPath(path),
18443            E::Io(error) => Self::NetworkAllowlistCatalogIo(error),
18444            E::InvalidEntry { line, message } => {
18445                Self::InvalidNetworkAllowlistCatalogEntry { line, message }
18446            }
18447            E::InvalidCatalog(message) => Self::InvalidNetworkAllowlistCatalog(message),
18448            E::Capacity { max } => Self::NetworkAllowlistCatalogCapacity { max },
18449        }
18450    }
18451}
18452
18453#[cfg(test)]
18454mod agent_stream_blocked_tests {
18455    use super::*;
18456    use gate4agent_types::{
18457        AdapterId, AdapterVerification, HostRequestOutcome as ProviderHostRequestOutcome,
18458        ProviderSource,
18459    };
18460
18461    fn provider_source(family: AdapterFamily, adapter: &str) -> ProviderSource {
18462        ProviderSource {
18463            family,
18464            binding: AdapterBinding::new(
18465                AdapterId::new(adapter).unwrap(),
18466                "fixture/v1",
18467                AdapterVerification::SyntheticFixture,
18468            )
18469            .unwrap(),
18470        }
18471    }
18472
18473    fn provider_control_event_at(
18474        family: AdapterFamily,
18475        adapter: &str,
18476        source_sequence: u64,
18477        provider_event: ProviderEvent,
18478    ) -> ControlEvent {
18479        ControlEvent {
18480            sequence: 41,
18481            command_id: None,
18482            instance_id: AgentInstanceId(7),
18483            generation: SessionGeneration(3),
18484            event: ControlEventKind::ProviderEvent {
18485                sequence: 8,
18486                source: provider_source(family, adapter),
18487                source_sequence,
18488                event: provider_event,
18489            },
18490        }
18491    }
18492
18493    fn provider_control_event(family: AdapterFamily, provider_event: ProviderEvent) -> ControlEvent {
18494        provider_control_event_at(family, "fixture", 9, provider_event)
18495    }
18496
18497    fn denied_host_request(
18498        by: ProviderHostDecisionAuthority,
18499        reason: Option<String>,
18500    ) -> ControlEvent {
18501        provider_control_event_at(
18502            AdapterFamily::Acp,
18503            "claude-code",
18504            2,
18505            ProviderEvent::HostRequestObserved {
18506                method: "terminal/create".to_owned(),
18507                params_json: String::new(),
18508                decision: ProviderHostRequestDecision::Denied { by },
18509                outcome: ProviderHostRequestOutcome::Executed,
18510                reason,
18511            },
18512        )
18513    }
18514
18515    fn blocked(
18516        event: &ControlEvent,
18517    ) -> (
18518        Option<String>,
18519        String,
18520        BlockAuthorityV1,
18521        Option<String>,
18522        String,
18523        Option<String>,
18524    ) {
18525        let chunk = agent_stream_chunk(event).expect("expected a Blocked chunk");
18526        chunk.validate().expect("minted chunk must validate");
18527        let AgentStreamChunkKindV1::Blocked {
18528            correlation_id,
18529            tool_class,
18530            authority,
18531            reason_kind,
18532            reason,
18533            help,
18534        } = chunk.kind
18535        else {
18536            panic!("expected AgentStreamChunkKindV1::Blocked");
18537        };
18538        (correlation_id, tool_class, authority, reason_kind, reason, help)
18539    }
18540
18541    /// Pins the fleet and every adapter each member declares: the fleet is
18542    /// exactly what `active_registry` says, and no member declares a hook
18543    /// adapter (lifecycle hooks are retired).
18544    #[test]
18545    fn the_enabled_provider_matrix_is_exact() {
18546        let registry = active_registry().unwrap();
18547        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
18548        assert_eq!(
18549            providers
18550                .iter()
18551                .map(|contract| contract.provider.as_str())
18552                .collect::<Vec<_>>(),
18553            // `shell` is the plain PTY provider (operator terminals and smoke tests).
18554            vec!["shell", "claude", "codex", "grok", "kimi"],
18555        );
18556        assert!(adapters.iter().all(|contract| {
18557            !matches!(contract.family, AdapterFamily::Hook | AdapterFamily::ManagedHook)
18558        }));
18559    }
18560
18561    /// A gate-denied host request, WITH its reason text carried all the way
18562    /// from the ACP host, mints a `Blocked` chunk naming the gate.
18563    #[test]
18564    fn gate_denied_host_request_mints_a_blocked_chunk() {
18565        let gate_text =
18566            "blocked by dangerous-command gate: rule=filesystem-wipe, argument=rm -rf /";
18567        let event = denied_host_request(
18568            ProviderHostDecisionAuthority::Gate,
18569            Some(gate_text.to_owned()),
18570        );
18571        let chunk = agent_stream_chunk(&event).expect("expected a Blocked chunk");
18572        assert_eq!(chunk.source_sequence, 2);
18573        let (correlation_id, tool_class, authority, reason_kind, reason, help) = blocked(&event);
18574        assert_eq!(authority, BlockAuthorityV1::HostGate);
18575        assert_eq!(reason, gate_text);
18576        assert_eq!(correlation_id, None);
18577        assert_eq!(reason_kind, None);
18578        assert_eq!(help, None);
18579        assert_eq!(tool_class, "Write");
18580    }
18581
18582    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.";
18583
18584    /// The owner's classifier sample, verbatim, arriving as a
18585    /// `tool_call_update`-derived `ProviderEvent::ToolCompleted` with
18586    /// `non_execution_kind: "permission-rule"` -- mints a `Blocked` chunk with
18587    /// `authority: ProviderPermissionRule`, the `Reason:` sentence as `reason`
18588    /// and the guidance tail as `help`.
18589    #[test]
18590    fn classifier_sample_permission_rule_mints_a_blocked_chunk() {
18591        let event = provider_control_event(
18592            AdapterFamily::Acp,
18593            ProviderEvent::ToolCompleted {
18594                id: "toolu_9".to_owned(),
18595                output: CLASSIFIER_SAMPLE.to_owned(),
18596                is_error: true,
18597                duration_ms: None,
18598                agent_id: None,
18599                non_execution_kind: Some("permission-rule".to_owned()),
18600            },
18601        );
18602        let (correlation_id, _, authority, reason_kind, reason, help) = blocked(&event);
18603        assert_eq!(authority, BlockAuthorityV1::ProviderPermissionRule);
18604        assert_eq!(reason, "Blocked by classifier");
18605        assert_eq!(reason_kind, Some("permission-rule".to_owned()));
18606        assert!(help.as_deref().is_some_and(|help| help.starts_with("If you have other tasks")));
18607        assert!(correlation_id.is_some(), "a tool-correlated block must carry a correlation id");
18608    }
18609
18610    /// A `session/prompt` RPC error with `data.codexErrorInfo:
18611    /// "usageLimitExceeded"` (measured live 2026-09-05) mints a `Blocked` chunk
18612    /// with `authority: ProviderQuota`, the message verbatim as `reason` and the
18613    /// vendor code as `reason_kind`.
18614    #[test]
18615    fn codex_quota_rpc_error_mints_a_blocked_chunk() {
18616        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.";
18617        let event = provider_control_event(
18618            AdapterFamily::Acp,
18619            ProviderEvent::SessionEnded {
18620                result: "JSON-RPC error -32603: Internal error".to_owned(),
18621                cost_usd: None,
18622                is_error: true,
18623                stop_reason: Some(ProviderStopReason::ProviderError {
18624                    code: -32603,
18625                    message: message.to_owned(),
18626                    vendor_code: Some("usageLimitExceeded".to_owned()),
18627                }),
18628            },
18629        );
18630        let (_, tool_class, authority, reason_kind, reason, _) = blocked(&event);
18631        assert_eq!(authority, BlockAuthorityV1::ProviderQuota);
18632        assert_eq!(reason, message);
18633        assert_eq!(reason_kind, Some("usageLimitExceeded".to_owned()));
18634        assert_eq!(tool_class, "Session");
18635    }
18636
18637    /// ACP's `stopReason: "refusal"` mints a `Blocked` chunk with
18638    /// `authority: ProviderRefusal`.
18639    #[test]
18640    fn session_end_stop_reason_refusal_mints_a_blocked_chunk() {
18641        let event = provider_control_event(
18642            AdapterFamily::Acp,
18643            ProviderEvent::SessionEnded {
18644                result: "refusal".to_owned(),
18645                cost_usd: None,
18646                is_error: true,
18647                stop_reason: Some(ProviderStopReason::Refusal),
18648            },
18649        );
18650        let (_, _, authority, _, _, _) = blocked(&event);
18651        assert_eq!(authority, BlockAuthorityV1::ProviderRefusal);
18652    }
18653
18654    /// A failed tool completion with no typed `non_execution_kind` -- or one
18655    /// that only says the turn stopped -- is a plain execution failure, never a
18656    /// block; nor is an agent's own narrative claiming one.
18657    #[test]
18658    fn failures_and_narrative_without_a_typed_block_mint_no_blocked_chunk() {
18659        for non_execution_kind in [None, Some("interrupted".to_owned()), Some("cancelled".to_owned())] {
18660            let event = provider_control_event(
18661                AdapterFamily::Acp,
18662                ProviderEvent::ToolCompleted {
18663                    id: "toolu_1".to_owned(),
18664                    output: "command exited with status 1".to_owned(),
18665                    is_error: true,
18666                    duration_ms: Some(12),
18667                    agent_id: None,
18668                    non_execution_kind,
18669                },
18670            );
18671            assert!(agent_stream_chunk(&event).is_none());
18672        }
18673        let narrative = provider_control_event(
18674            AdapterFamily::Acp,
18675            ProviderEvent::ToolCompleted {
18676                id: "toolu_2".to_owned(),
18677                output: "the harness blocked the write, approval needed".to_owned(),
18678                is_error: false,
18679                duration_ms: Some(3),
18680                agent_id: None,
18681                non_execution_kind: None,
18682            },
18683        );
18684        assert!(agent_stream_chunk(&narrative).is_none());
18685    }
18686
18687    /// `Granted`/`Deferred` decisions never mint a `Blocked` chunk, whatever the
18688    /// execution outcome: a granted request that failed while running was
18689    /// still authorized.
18690    #[test]
18691    fn granted_and_deferred_host_requests_never_mint_a_blocked_chunk() {
18692        for (decision, outcome) in [
18693            (
18694                ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
18695                ProviderHostRequestOutcome::Executed,
18696            ),
18697            (ProviderHostRequestDecision::Deferred, ProviderHostRequestOutcome::Executed),
18698            (
18699                ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
18700                ProviderHostRequestOutcome::Failed {
18701                    error: "terminal/create spawn failed: os error 3".to_owned(),
18702                },
18703            ),
18704        ] {
18705            let event = provider_control_event_at(
18706                AdapterFamily::Acp,
18707                "claude-code",
18708                3,
18709                ProviderEvent::HostRequestObserved {
18710                    method: "fs/read_text_file".to_owned(),
18711                    params_json: String::new(),
18712                    decision: decision.clone(),
18713                    outcome,
18714                    reason: None,
18715                },
18716            );
18717            assert!(agent_stream_chunk(&event).is_none(), "{decision:?}");
18718        }
18719    }
18720
18721    /// A deferred `session/request_permission` resolved by an operator or an
18722    /// expired deadline carries no reason text -- the chunk falls back to a
18723    /// categorical sentence naming the authority rather than inventing detail.
18724    #[test]
18725    fn denied_host_request_with_no_reason_falls_back_to_a_categorical_sentence() {
18726        for (by, expected_authority, expected_reason) in [
18727            (
18728                ProviderHostDecisionAuthority::Operator,
18729                BlockAuthorityV1::Operator,
18730                "declined by the operator",
18731            ),
18732            (
18733                ProviderHostDecisionAuthority::DeadlinePolicy,
18734                BlockAuthorityV1::HostDeadline,
18735                "denied by host policy after the operator deadline expired",
18736            ),
18737        ] {
18738            let (_, _, authority, _, reason, _) = blocked(&denied_host_request(by, None));
18739            assert_eq!(authority, expected_authority);
18740            assert_eq!(reason, expected_reason);
18741        }
18742    }
18743
18744    /// An over-long reason is cut to the wire bound BEFORE the chunk is built,
18745    /// at a safe UTF-8 boundary -- `validate` rejects an over-long value
18746    /// outright rather than truncating.
18747    #[test]
18748    fn overlong_blocked_reason_is_truncated_to_the_wire_bound() {
18749        let oversized = "x".repeat(MAX_ACP_BLOCKED_REASON_BYTES + 500);
18750        let (_, _, _, _, reason, _) = blocked(&denied_host_request(
18751            ProviderHostDecisionAuthority::Policy,
18752            Some(oversized),
18753        ));
18754        assert!(reason.len() <= MAX_ACP_BLOCKED_REASON_BYTES);
18755    }
18756}
18757
18758#[cfg(test)]
18759mod standalone_capability_platform_tests {
18760    use super::*;
18761
18762    #[test]
18763    fn standalone_workspace_capability_requires_windows_identity_guards() {
18764        let capabilities = baseline_capabilities().unwrap();
18765        let advertised = capabilities.iter().any(|capability| {
18766            capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
18767        });
18768        assert_eq!(advertised, cfg!(windows));
18769
18770        let request = NodeRequest::CreateStandaloneWorkspace {
18771            workspace_id: WorkspaceId::new("standalone-platform").unwrap(),
18772            root: OpaqueHostPath::utf8("standalone-platform-root".to_owned()).unwrap(),
18773            initial_branch: None,
18774        };
18775        assert_eq!(
18776            request_uses_unnegotiated_capability(&request, &capabilities),
18777            !cfg!(windows),
18778        );
18779    }
18780}
18781
18782
18783#[cfg(test)]
18784mod station_knobs_resolve_tests {
18785    use super::*;
18786    use gate4agent_catalog::EnvMutation;
18787    use gate4agent_runtime_native::{
18788        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
18789        NativeLaunchProfileId,
18790    };
18791    use std::ffi::OsString;
18792    use std::sync::Arc;
18793
18794    struct EmptyEnvironmentResolver;
18795
18796    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
18797        fn resolve_child_environment(
18798            &self,
18799        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
18800            Ok(vec![EnvMutation {
18801                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
18802                value: None,
18803            }])
18804        }
18805    }
18806
18807    fn agent(value: &str) -> AgentId {
18808        AgentId::new(value).unwrap()
18809    }
18810
18811    fn station_knobs_fixture() -> (NodeShared, crate::protocol::SpawnProfileDefaults) {
18812        let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
18813        let profile_revision =
18814            crate::protocol::SpawnEnvironmentProfileRevision::new("local-claude-r1").unwrap();
18815        let spawn_defaults = crate::protocol::SpawnProfileDefaults {
18816            profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
18817            revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
18818            provider: agent("claude"),
18819            mode: SessionMode::Pty,
18820            terminal_size: gate4agent_types::TerminalSize {
18821                rows: 24,
18822                columns: 80,
18823            },
18824            prompt: None,
18825            bundle_id: None,
18826            context_id: None,
18827            environment_profile_id: Some(profile_id.clone()),
18828        };
18829        let spawn_profiles = SpawnProfileRegistry::new([spawn_defaults.clone()]).unwrap();
18830        let catalog = active_registry().unwrap();
18831        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18832        let control = runtime.native_launch_profile_control();
18833        let node_profile = NodeEnvironmentProfile::new(
18834            profile_id.clone(),
18835            profile_revision,
18836            agent("claude"),
18837            [NativeLaunchProfile::new(
18838                NativeLaunchProfileId::new("local-claude-pty").unwrap(),
18839                agent("claude"),
18840                TransportKind::Pty,
18841                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
18842                Arc::new(EmptyEnvironmentResolver),
18843            )
18844            .unwrap()],
18845        )
18846        .unwrap();
18847        let (binding, native_profiles, _materialization) = node_profile.into_parts();
18848        for native_profile in native_profiles {
18849            runtime.upsert_native_launch_profile(native_profile).unwrap();
18850        }
18851        let workspace = WorkspaceConfig::new(
18852            WorkspaceId::new("primary").unwrap(),
18853            std::env::current_dir().unwrap(),
18854        )
18855        .unwrap();
18856        let shared = NodeShared::new_with_incarnation(
18857            handle,
18858            "fixture-token".to_owned(),
18859            NodeId::new("node-station-knobs-test").unwrap(),
18860            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
18861            vec![workspace],
18862            vec![agent("claude")],
18863            ProviderRuntimeStatuses::default(),
18864            None,
18865            Vec::new(),
18866            Vec::new(),
18867            spawn_profiles,
18868            Some(control),
18869            None,
18870            None,
18871            None,
18872            Vec::new(),
18873            Vec::new(),
18874            Vec::new(),
18875            Vec::new(),
18876            None,
18877            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
18878        );
18879        shared
18880            .environment_profiles
18881            .write()
18882            .unwrap_or_else(|poisoned| poisoned.into_inner())
18883            .insert(binding.id.clone(), binding);
18884        (shared, spawn_defaults)
18885    }
18886
18887    /// Empty-default catalog refuses unknown network_allowlist ids.
18888    /// Feature-off dig2-station-probe keeps browser_profile stub echo.
18889    #[test]
18890    fn resolve_environment_profile_refuses_unknown_network_allowlist() {
18891        let (shared, defaults) = station_knobs_fixture();
18892        let unknown = crate::protocol::SpawnNetworkAllowlistId::new("not-registered").unwrap();
18893        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18894        let spec = crate::protocol::SpawnSpec {
18895            target: crate::protocol::SpawnTarget {
18896                node_id: shared.node_id.clone(),
18897                workspace_id: WorkspaceId::new("primary").unwrap(),
18898                worktree_id: None,
18899            },
18900            profile_id: defaults.profile_id.clone(),
18901            expected_profile_revision: defaults.revision.clone(),
18902            overrides: crate::protocol::SpawnOverrides {
18903                network_allowlist: Some(unknown),
18904                browser_profile_id: Some(browser),
18905                ..crate::protocol::SpawnOverrides::default()
18906            },
18907            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18908            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18909                "station-knobs-unknown-allowlist",
18910            )
18911            .unwrap(),
18912            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18913        };
18914        let resolved = spec.resolve(&defaults).unwrap();
18915        let failure = shared
18916            .resolve_environment_profile(&resolved)
18917            .expect_err("unknown allowlist must refuse");
18918        assert_eq!(failure.code, NodeFailureCode::UnknownNetworkAllowlist);
18919    }
18920
18921    /// Registered network_allowlist (+ browser_profile_id when feature off)
18922    /// echo onto the env-profile receipt. Empty catalog still allows omit
18923    /// (None). Empty/whitespace refuse is type-level. Feature-off
18924    /// dig2-station-probe keeps stub browser echo; feature-on refuse is
18925    /// covered by `…_refuses_browser_profile_when_station_probe_fails`.
18926    #[test]
18927    fn resolve_environment_profile_echoes_registered_station_network_and_browser_knobs() {
18928        let (shared, defaults) = station_knobs_fixture();
18929        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18930        #[cfg(not(feature = "dig2-station-probe"))]
18931        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18932        shared
18933            .network_allowlist_catalog
18934            .write()
18935            .unwrap_or_else(|poisoned| poisoned.into_inner())
18936            .insert_id(allowlist.clone())
18937            .unwrap();
18938        let spec = crate::protocol::SpawnSpec {
18939            target: crate::protocol::SpawnTarget {
18940                node_id: shared.node_id.clone(),
18941                workspace_id: WorkspaceId::new("primary").unwrap(),
18942                worktree_id: None,
18943            },
18944            profile_id: defaults.profile_id.clone(),
18945            expected_profile_revision: defaults.revision.clone(),
18946            overrides: crate::protocol::SpawnOverrides {
18947                network_allowlist: Some(allowlist.clone()),
18948                #[cfg(not(feature = "dig2-station-probe"))]
18949                browser_profile_id: Some(browser.clone()),
18950                ..crate::protocol::SpawnOverrides::default()
18951            },
18952            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18953            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-echo")
18954                .unwrap(),
18955            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18956        };
18957        let resolved = spec.resolve(&defaults).unwrap();
18958        assert_eq!(resolved.network_allowlist.as_ref(), Some(&allowlist));
18959        #[cfg(not(feature = "dig2-station-probe"))]
18960        assert_eq!(resolved.browser_profile_id.as_ref(), Some(&browser));
18961        let receipt = shared
18962            .resolve_environment_profile(&resolved)
18963            .expect("resolve ok")
18964            .expect("env profile present");
18965        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18966        #[cfg(not(feature = "dig2-station-probe"))]
18967        assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
18968        #[cfg(feature = "dig2-station-probe")]
18969        assert_eq!(receipt.browser_profile_id, None);
18970        assert_eq!(receipt.profile_id.as_str(), "local-claude");
18971
18972        let omitted = crate::protocol::SpawnSpec {
18973            overrides: crate::protocol::SpawnOverrides::default(),
18974            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-omit")
18975                .unwrap(),
18976            ..spec
18977        };
18978        let resolved_omit = omitted.resolve(&defaults).unwrap();
18979        let bare = shared
18980            .resolve_environment_profile(&resolved_omit)
18981            .expect("resolve ok")
18982            .expect("env profile present");
18983        assert_eq!(bare.network_allowlist, None);
18984        assert_eq!(bare.browser_profile_id, None);
18985    }
18986
18987    /// Feature `dig2-station-probe`: browser_profile_id set refuses when the
18988    /// cheap station probe cannot succeed. On non-Windows (this CI host) that
18989    /// is `BrowserStationProbeUnavailable`. Never cookies on C2. Lease map is
18990    /// unit-tested separately (probe refuses before lease on Linux).
18991    #[cfg(feature = "dig2-station-probe")]
18992    #[test]
18993    fn resolve_environment_profile_refuses_browser_profile_when_station_probe_fails() {
18994        let (shared, defaults) = station_knobs_fixture();
18995        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18996        let spec = crate::protocol::SpawnSpec {
18997            target: crate::protocol::SpawnTarget {
18998                node_id: shared.node_id.clone(),
18999                workspace_id: WorkspaceId::new("primary").unwrap(),
19000                worktree_id: None,
19001            },
19002            profile_id: defaults.profile_id.clone(),
19003            expected_profile_revision: defaults.revision.clone(),
19004            overrides: crate::protocol::SpawnOverrides {
19005                browser_profile_id: Some(browser),
19006                ..crate::protocol::SpawnOverrides::default()
19007            },
19008            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19009            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19010                "station-knobs-probe-refuse",
19011            )
19012            .unwrap(),
19013            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19014        };
19015        let resolved = spec.resolve(&defaults).unwrap();
19016        let failure = shared
19017            .resolve_environment_profile(&resolved)
19018            .expect_err("browser_profile with failing probe must refuse");
19019        #[cfg(windows)]
19020        {
19021            assert!(
19022                matches!(
19023                    failure.code,
19024                    NodeFailureCode::BrowserStationUnreachable
19025                        | NodeFailureCode::BrowserStationProbeUnavailable
19026                ),
19027                "unexpected code {:?}",
19028                failure.code
19029            );
19030        }
19031        #[cfg(not(windows))]
19032        {
19033            assert_eq!(
19034                failure.code,
19035                NodeFailureCode::BrowserStationProbeUnavailable
19036            );
19037        }
19038    }
19039
19040    /// Feature on: omitting browser_profile_id still resolves (no probe).
19041    #[cfg(feature = "dig2-station-probe")]
19042    #[test]
19043    fn resolve_environment_profile_skips_station_probe_when_browser_profile_omitted() {
19044        let (shared, defaults) = station_knobs_fixture();
19045        let spec = crate::protocol::SpawnSpec {
19046            target: crate::protocol::SpawnTarget {
19047                node_id: shared.node_id.clone(),
19048                workspace_id: WorkspaceId::new("primary").unwrap(),
19049                worktree_id: None,
19050            },
19051            profile_id: defaults.profile_id.clone(),
19052            expected_profile_revision: defaults.revision.clone(),
19053            overrides: crate::protocol::SpawnOverrides::default(),
19054            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19055            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19056                "station-knobs-probe-omit",
19057            )
19058            .unwrap(),
19059            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19060        };
19061        let resolved = spec.resolve(&defaults).unwrap();
19062        let receipt = shared
19063            .resolve_environment_profile(&resolved)
19064            .expect("omit browser_profile must not probe")
19065            .expect("env profile present");
19066        assert_eq!(receipt.browser_profile_id, None);
19067    }
19068
19069    /// Feature dig2-station-probe: exclusive BrowserStationLease on NodeShared
19070    /// refuses a second holder for the same opaque profile id; release on
19071    /// remove_binding allows reacquire. Never ImportSession/cookies on C2.
19072    #[cfg(feature = "dig2-station-probe")]
19073    #[test]
19074    fn browser_station_lease_exclusive_and_clears_on_remove_binding() {
19075        let (shared, _defaults) = station_knobs_fixture();
19076        let profile = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
19077        let workspace_id = WorkspaceId::new("primary").unwrap();
19078        let address_a = SessionAddress {
19079            workspace_id: workspace_id.clone(),
19080            session: SessionKey {
19081                instance_id: AgentInstanceId(101),
19082                generation: SessionGeneration(1),
19083            },
19084        };
19085        let address_b = SessionAddress {
19086            workspace_id: workspace_id.clone(),
19087            session: SessionKey {
19088                instance_id: AgentInstanceId(102),
19089                generation: SessionGeneration(1),
19090            },
19091        };
19092        let guard_a = crate::dig2_station_lease::acquire_lease(
19093            &shared.browser_station_leases,
19094            profile.clone(),
19095            address_a.session.instance_id,
19096            1,
19097        )
19098        .expect("first lease");
19099        let busy = crate::dig2_station_lease::acquire_lease(
19100            &shared.browser_station_leases,
19101            profile.clone(),
19102            address_b.session.instance_id,
19103            2,
19104        )
19105        .expect_err("second holder must refuse");
19106        assert!(matches!(
19107            busy,
19108            crate::dig2_station_lease::BrowserStationLeaseError::ProfileBusy { .. }
19109        ));
19110        // Simulate a bound session so remove_binding clears the lease.
19111        shared.bind_session_with_policy(
19112            &address_a,
19113            ProviderRuntimePolicy::raw_pty(),
19114            None,
19115        );
19116        guard_a.retain();
19117        assert!(shared.remove_binding(&address_a).is_some());
19118        crate::dig2_station_lease::acquire_lease(
19119            &shared.browser_station_leases,
19120            profile,
19121            address_b.session.instance_id,
19122            3,
19123        )
19124        .expect("reacquire after clear")
19125        .retain();
19126        assert!(
19127            crate::dig2_station_lease::release_lease_for_holder(
19128                &shared.browser_station_leases,
19129                address_b.session.instance_id,
19130            )
19131        );
19132    }
19133
19134    /// Feature-off: no browser_station_leases field; stub echo documented in
19135    /// resolve_environment_profile_echoes_registered_station_network_and_browser_knobs.
19136    #[cfg(not(feature = "dig2-station-probe"))]
19137    #[test]
19138    fn browser_station_lease_feature_off_keeps_stub_without_lease_table() {
19139        let (shared, defaults) = station_knobs_fixture();
19140        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
19141        let spec = crate::protocol::SpawnSpec {
19142            target: crate::protocol::SpawnTarget {
19143                node_id: shared.node_id.clone(),
19144                workspace_id: WorkspaceId::new("primary").unwrap(),
19145                worktree_id: None,
19146            },
19147            profile_id: defaults.profile_id.clone(),
19148            expected_profile_revision: defaults.revision.clone(),
19149            overrides: crate::protocol::SpawnOverrides {
19150                browser_profile_id: Some(browser.clone()),
19151                ..crate::protocol::SpawnOverrides::default()
19152            },
19153            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19154            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("lease-stub-off")
19155                .unwrap(),
19156            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19157        };
19158        let resolved = spec.resolve(&defaults).unwrap();
19159        let receipt = shared
19160            .resolve_environment_profile(&resolved)
19161            .expect("stub resolve")
19162            .expect("env profile");
19163        assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
19164    }
19165
19166
19167    /// Startup file loader registers ids into the empty-default catalog.
19168    /// Feature-off dig2 probe stays stub. Never secrets on C2.
19169    #[test]
19170    fn network_allowlist_catalog_loads_from_temp_file() {
19171        let unique = std::time::SystemTime::now()
19172            .duration_since(std::time::UNIX_EPOCH)
19173            .unwrap()
19174            .as_nanos();
19175        let path = std::env::temp_dir().join(format!(
19176            "gate4agent-node-allowlist-config-{}-{unique}.txt",
19177            std::process::id(),
19178        ));
19179        std::fs::write(
19180            &path,
19181            "# opaque station allowlist ids\negress-default\nlab-egress\n",
19182        )
19183        .unwrap();
19184        let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
19185            .expect("temp catalog file must load");
19186        let _ = std::fs::remove_file(&path);
19187        assert_eq!(loaded.len(), 2);
19188        let (shared, defaults) = station_knobs_fixture();
19189        {
19190            let mut catalog = shared
19191                .network_allowlist_catalog
19192                .write()
19193                .unwrap_or_else(|poisoned| poisoned.into_inner());
19194            *catalog = loaded;
19195        }
19196        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19197        let spec = crate::protocol::SpawnSpec {
19198            target: crate::protocol::SpawnTarget {
19199                node_id: shared.node_id.clone(),
19200                workspace_id: WorkspaceId::new("primary").unwrap(),
19201                worktree_id: None,
19202            },
19203            profile_id: defaults.profile_id.clone(),
19204            expected_profile_revision: defaults.revision.clone(),
19205            overrides: crate::protocol::SpawnOverrides {
19206                network_allowlist: Some(allowlist.clone()),
19207                ..crate::protocol::SpawnOverrides::default()
19208            },
19209            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19210            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19211                "station-knobs-catalog-file",
19212            )
19213            .unwrap(),
19214            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19215        };
19216        let resolved = spec.resolve(&defaults).unwrap();
19217        let receipt = shared
19218            .resolve_environment_profile(&resolved)
19219            .expect("registered via file load")
19220            .expect("env profile present");
19221        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
19222    }
19223
19224    /// Snapshot launch inventory echoes registered catalog ids (opaque).
19225    /// Dig2 station probe is orthogonal (feature `dig2-station-probe`). Never secrets on C2.
19226    #[test]
19227    fn snapshot_launch_inventory_lists_network_allowlist_catalog_ids() {
19228        let (shared, _defaults) = station_knobs_fixture();
19229        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19230        shared
19231            .network_allowlist_catalog
19232            .write()
19233            .unwrap_or_else(|poisoned| poisoned.into_inner())
19234            .insert_id(allowlist.clone())
19235            .unwrap();
19236        let snapshot = shared.snapshot();
19237        let inventory = snapshot
19238            .launch_inventory
19239            .expect("launch inventory present");
19240        let ids = inventory
19241            .network_allowlists
19242            .expect("non-empty catalog surfaces ids");
19243        assert_eq!(ids, vec![allowlist]);
19244    }
19245
19246    /// Track B: registered id with provider_native codex_network_access refuses
19247    /// clearly for Claude (fixture provider). Unknown id still UnknownNetworkAllowlist.
19248    /// Never secrets on C2. Codex Moderate maps to `-c` overlay; RO/FullAuto refuse.
19249    #[test]
19250    fn resolve_environment_profile_refuses_unsupported_provider_native_mapping() {
19251        let (shared, defaults) = station_knobs_fixture();
19252        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-only").unwrap();
19253        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19254            id: allowlist.clone(),
19255            permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
19256                protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19257                peer: "127.0.0.1:443".to_owned(),
19258            }],
19259            provider_native: Some(
19260                crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
19261                    codex_network_access: Some(true),
19262                },
19263            ),
19264        };
19265        shared
19266            .network_allowlist_catalog
19267            .write()
19268            .unwrap_or_else(|poisoned| poisoned.into_inner())
19269            .insert(entry)
19270            .unwrap();
19271        let spec = crate::protocol::SpawnSpec {
19272            target: crate::protocol::SpawnTarget {
19273                node_id: shared.node_id.clone(),
19274                workspace_id: WorkspaceId::new("primary").unwrap(),
19275                worktree_id: None,
19276            },
19277            profile_id: defaults.profile_id.clone(),
19278            expected_profile_revision: defaults.revision.clone(),
19279            overrides: crate::protocol::SpawnOverrides {
19280                network_allowlist: Some(allowlist),
19281                ..crate::protocol::SpawnOverrides::default()
19282            },
19283            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19284            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19285                "station-knobs-unsupported-mapping",
19286            )
19287            .unwrap(),
19288            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19289        };
19290        let resolved = spec.resolve(&defaults).unwrap();
19291        assert_eq!(resolved.provider.as_str(), "claude");
19292        let failure = shared
19293            .resolve_environment_profile(&resolved)
19294            .expect_err("claude + codex_network_access must refuse");
19295        assert_eq!(
19296            failure.code,
19297            NodeFailureCode::UnsupportedNetworkAllowlistMapping
19298        );
19299    }
19300
19301    /// Codex Moderate + codex_network_access yields `-c` overlay argv.
19302    /// ReadOnly / FullAuto refuse UnsupportedNetworkAllowlistMapping.
19303    /// Never secrets on C2.
19304    #[test]
19305    fn resolve_codex_network_access_overlay_moderate_only() {
19306        let (shared, defaults) = station_knobs_fixture();
19307        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-net").unwrap();
19308        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19309            id: allowlist.clone(),
19310            permits: Vec::new(),
19311            provider_native: Some(
19312                crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
19313                    codex_network_access: Some(true),
19314                },
19315            ),
19316        };
19317        shared
19318            .network_allowlist_catalog
19319            .write()
19320            .unwrap_or_else(|poisoned| poisoned.into_inner())
19321            .insert(entry)
19322            .unwrap();
19323
19324        let overlay = shared
19325            .resolve_codex_network_access_overlay_args(
19326                &agent("codex"),
19327                SessionMode::Pty,
19328                ApprovalLevel::Moderate,
19329                Some(&allowlist),
19330            )
19331            .expect("Moderate Codex maps network_access");
19332        assert_eq!(
19333            overlay,
19334            vec![
19335                std::ffi::OsString::from("-c"),
19336                std::ffi::OsString::from("sandbox_workspace_write.network_access=true"),
19337            ]
19338        );
19339
19340        let acp = shared
19341            .resolve_codex_network_access_overlay_args(
19342                &agent("codex"),
19343                SessionMode::Acp,
19344                ApprovalLevel::Moderate,
19345                Some(&allowlist),
19346            )
19347            .unwrap();
19348        assert_eq!(acp, overlay);
19349
19350        let inline = shared
19351            .resolve_codex_network_access_overlay_args(
19352                &agent("codex"),
19353                SessionMode::Inline,
19354                ApprovalLevel::Moderate,
19355                Some(&allowlist),
19356            )
19357            .unwrap_err();
19358        assert_eq!(
19359            inline.code,
19360            NodeFailureCode::UnsupportedNetworkAllowlistMapping
19361        );
19362
19363        for level in [ApprovalLevel::ReadOnly, ApprovalLevel::FullAuto] {
19364            let spec = crate::protocol::SpawnSpec {
19365                target: crate::protocol::SpawnTarget {
19366                    node_id: shared.node_id.clone(),
19367                    workspace_id: WorkspaceId::new("primary").unwrap(),
19368                    worktree_id: None,
19369                },
19370                profile_id: defaults.profile_id.clone(),
19371                expected_profile_revision: defaults.revision.clone(),
19372                overrides: crate::protocol::SpawnOverrides {
19373                    provider: crate::protocol::SpawnOverride::Set {
19374                        value: agent("codex"),
19375                    },
19376                    approval_level: Some(level),
19377                    network_allowlist: Some(allowlist.clone()),
19378                    ..crate::protocol::SpawnOverrides::default()
19379                },
19380                deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19381                idempotency_key: crate::protocol::SpawnIdempotencyKey::new(format!(
19382                    "codex-net-refuse-{}",
19383                    match level {
19384                        ApprovalLevel::ReadOnly => "ro",
19385                        ApprovalLevel::FullAuto => "fa",
19386                        _ => "other",
19387                    }
19388                ))
19389                .unwrap(),
19390                required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19391            };
19392            let resolved = spec.resolve(&defaults).unwrap();
19393            let failure = shared
19394                .resolve_environment_profile(&resolved)
19395                .expect_err("RO/FullAuto must refuse");
19396            assert_eq!(
19397                failure.code,
19398                NodeFailureCode::UnsupportedNetworkAllowlistMapping
19399            );
19400        }
19401    }
19402
19403    /// Claude PTY + catalog permits → ProviderHome `--settings` sidecar with
19404    /// sandbox.network.allowedDomains. ACP / missing ProviderHome refuse.
19405    /// Never secrets on C2; does not invent provider_native Claude keys.
19406    #[test]
19407    fn resolve_claude_settings_network_overlay_pty_writes_sidecar() {
19408        let (shared, _defaults) = station_knobs_fixture();
19409        let allowlist =
19410            crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-domains").unwrap();
19411        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19412            id: allowlist.clone(),
19413            permits: vec![
19414                crate::network_allowlist_catalog::NetworkPermitSketch {
19415                    protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19416                    peer: "github.com:443".to_owned(),
19417                },
19418                crate::network_allowlist_catalog::NetworkPermitSketch {
19419                    protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19420                    peer: "*.npmjs.org".to_owned(),
19421                },
19422            ],
19423            provider_native: None,
19424        };
19425        shared
19426            .network_allowlist_catalog
19427            .write()
19428            .unwrap_or_else(|poisoned| poisoned.into_inner())
19429            .insert(entry)
19430            .unwrap();
19431
19432        let unique = std::time::SystemTime::now()
19433            .duration_since(std::time::UNIX_EPOCH)
19434            .unwrap()
19435            .as_nanos();
19436        let provider_home = std::env::temp_dir().join(format!(
19437            "gate4agent-claude-provider-home-{}-{unique}",
19438            std::process::id(),
19439        ));
19440        std::fs::create_dir_all(&provider_home).unwrap();
19441
19442        let overlay = shared
19443            .resolve_network_allowlist_overlay_args(
19444                &agent("claude"),
19445                SessionMode::Pty,
19446                ApprovalLevel::Moderate,
19447                Some(&allowlist),
19448                Some(provider_home.as_path()),
19449            )
19450            .expect("Claude PTY settings overlay on unix");
19451        let settings_path = provider_home.join(
19452            crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
19453        );
19454        assert_eq!(
19455            overlay,
19456            vec![
19457                std::ffi::OsString::from("--settings"),
19458                std::ffi::OsString::from(settings_path.as_os_str()),
19459            ]
19460        );
19461        let body = std::fs::read_to_string(&settings_path).unwrap();
19462        let value: serde_json::Value = serde_json::from_str(&body).unwrap();
19463        assert_eq!(value["sandbox"]["enabled"], true);
19464        assert_eq!(
19465            value["sandbox"]["network"]["allowedDomains"],
19466            serde_json::json!(["github.com", "*.npmjs.org"])
19467        );
19468        assert!(settings_path.starts_with(&provider_home));
19469
19470        let acp = shared
19471            .resolve_network_allowlist_overlay_args(
19472                &agent("claude"),
19473                SessionMode::Acp,
19474                ApprovalLevel::Moderate,
19475                Some(&allowlist),
19476                Some(provider_home.as_path()),
19477            )
19478            .expect_err("Claude ACP must refuse settings overlay");
19479        assert_eq!(
19480            acp.code,
19481            NodeFailureCode::UnsupportedNetworkAllowlistMapping
19482        );
19483
19484        let no_home = shared
19485            .resolve_network_allowlist_overlay_args(
19486                &agent("claude"),
19487                SessionMode::Pty,
19488                ApprovalLevel::Moderate,
19489                Some(&allowlist),
19490                None,
19491            )
19492            .expect_err("Claude overlay requires ProviderHome");
19493        assert_eq!(
19494            no_home.code,
19495            NodeFailureCode::UnsupportedNetworkAllowlistMapping
19496        );
19497
19498        let bare = crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-bare").unwrap();
19499        shared
19500            .network_allowlist_catalog
19501            .write()
19502            .unwrap_or_else(|poisoned| poisoned.into_inner())
19503            .insert_id(bare.clone())
19504            .unwrap();
19505        let empty = shared
19506            .resolve_network_allowlist_overlay_args(
19507                &agent("claude"),
19508                SessionMode::Pty,
19509                ApprovalLevel::Moderate,
19510                Some(&bare),
19511                Some(provider_home.as_path()),
19512            )
19513            .unwrap();
19514        assert!(empty.is_empty());
19515
19516        let _ = std::fs::remove_dir_all(&provider_home);
19517    }
19518
19519    /// Kimi / Grok with permits stay without invented argv overlays.
19520    #[test]
19521    fn resolve_kimi_grok_network_overlay_stays_empty_without_native() {
19522        let (shared, _defaults) = station_knobs_fixture();
19523        let allowlist =
19524            crate::protocol::SpawnNetworkAllowlistId::new("egress-kimi-permits").unwrap();
19525        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19526            id: allowlist.clone(),
19527            permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
19528                protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19529                peer: "example.com:443".to_owned(),
19530            }],
19531            provider_native: None,
19532        };
19533        shared
19534            .network_allowlist_catalog
19535            .write()
19536            .unwrap_or_else(|poisoned| poisoned.into_inner())
19537            .insert(entry)
19538            .unwrap();
19539        for provider in ["kimi", "grok"] {
19540            let overlay = shared
19541                .resolve_network_allowlist_overlay_args(
19542                    &agent(provider),
19543                    SessionMode::Pty,
19544                    ApprovalLevel::Moderate,
19545                    Some(&allowlist),
19546                    None,
19547                )
19548                .unwrap();
19549            assert!(
19550                overlay.is_empty(),
19551                "{provider} must not invent network argv from permits"
19552            );
19553        }
19554    }
19555
19556    /// Track B: JSON v2 catalog file loads permits; inventory still ids only.
19557    #[test]
19558    fn network_allowlist_catalog_loads_json_v2_and_lists_ids_only() {
19559        let unique = std::time::SystemTime::now()
19560            .duration_since(std::time::UNIX_EPOCH)
19561            .unwrap()
19562            .as_nanos();
19563        let path = std::env::temp_dir().join(format!(
19564            "gate4agent-node-allowlist-json-{}-{unique}.json",
19565            std::process::id(),
19566        ));
19567        std::fs::write(
19568            &path,
19569            r#"{
19570              "schema_version": 2,
19571              "entries": [
19572                {
19573                  "id": "egress-default",
19574                  "permits": [{ "protocol": "tcp", "peer": "127.0.0.1:443" }]
19575                },
19576                { "id": "lab-egress" }
19577              ]
19578            }"#,
19579        )
19580        .unwrap();
19581        let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
19582            .expect("json v2 catalog must load");
19583        let _ = std::fs::remove_file(&path);
19584        assert_eq!(loaded.len(), 2);
19585        let entry = loaded
19586            .get(&crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap())
19587            .unwrap();
19588        assert_eq!(entry.permits.len(), 1);
19589        let (shared, defaults) = station_knobs_fixture();
19590        {
19591            let mut catalog = shared
19592                .network_allowlist_catalog
19593                .write()
19594                .unwrap_or_else(|poisoned| poisoned.into_inner());
19595            *catalog = loaded;
19596        }
19597        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19598        let spec = crate::protocol::SpawnSpec {
19599            target: crate::protocol::SpawnTarget {
19600                node_id: shared.node_id.clone(),
19601                workspace_id: WorkspaceId::new("primary").unwrap(),
19602                worktree_id: None,
19603            },
19604            profile_id: defaults.profile_id.clone(),
19605            expected_profile_revision: defaults.revision.clone(),
19606            overrides: crate::protocol::SpawnOverrides {
19607                network_allowlist: Some(allowlist.clone()),
19608                ..crate::protocol::SpawnOverrides::default()
19609            },
19610            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19611            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19612                "station-knobs-catalog-json",
19613            )
19614            .unwrap(),
19615            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19616        };
19617        let resolved = spec.resolve(&defaults).unwrap();
19618        let receipt = shared
19619            .resolve_environment_profile(&resolved)
19620            .expect("permits-only entry ok for claude")
19621            .expect("env profile present");
19622        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
19623        let inventory = shared
19624            .snapshot()
19625            .launch_inventory
19626            .expect("launch inventory present");
19627        let ids = inventory.network_allowlists.expect("ids surface");
19628        assert_eq!(
19629            ids,
19630            vec![
19631                crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap(),
19632                crate::protocol::SpawnNetworkAllowlistId::new("lab-egress").unwrap(),
19633            ]
19634        );
19635    }
19636
19637
19638}
19639
19640
19641#[cfg(test)]
19642mod workspace_environment_collision_tests {
19643    use super::*;
19644    use crate::session_environment::{
19645        NodeSessionMaterializationProfile, NodeSessionPathBinding, NodeSessionPathClass,
19646    };
19647    use gate4agent_catalog::EnvMutation;
19648    use gate4agent_runtime_native::{
19649        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
19650        NativeLaunchProfileId,
19651    };
19652    use std::ffi::OsString;
19653    use std::sync::Arc;
19654
19655    struct EmptyEnvironmentResolver;
19656
19657    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19658        fn resolve_child_environment(
19659            &self,
19660        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19661            Ok(vec![EnvMutation {
19662                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19663                value: None,
19664            }])
19665        }
19666    }
19667
19668    fn agent(value: &str) -> AgentId {
19669        AgentId::new(value).unwrap()
19670    }
19671
19672    /// Two Claude ProviderHome profiles (`CLAUDE_CONFIG_DIR` A vs B) on one node.
19673    fn claude_config_dir_collision_fixture() -> (
19674        NodeShared,
19675        ResolvedEnvironmentProfileReceipt,
19676        ResolvedEnvironmentProfileReceipt,
19677    ) {
19678        let catalog = active_registry().unwrap();
19679        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19680        let control = runtime.native_launch_profile_control();
19681        let workspace = WorkspaceConfig::new(
19682            WorkspaceId::new("primary").unwrap(),
19683            std::env::current_dir().unwrap(),
19684        )
19685        .unwrap();
19686        let shared = NodeShared::new_with_incarnation(
19687            handle,
19688            "fixture-token".to_owned(),
19689            NodeId::new("node-env-collision-test").unwrap(),
19690            NodeIncarnationId::from_bytes([3; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19691            vec![workspace],
19692            vec![agent("claude")],
19693            ProviderRuntimeStatuses::default(),
19694            None,
19695            Vec::new(),
19696            Vec::new(),
19697            SpawnProfileRegistry::default(),
19698            Some(control),
19699            None,
19700            None,
19701            None,
19702            Vec::new(),
19703            Vec::new(),
19704            Vec::new(),
19705            Vec::new(),
19706            None,
19707            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19708        );
19709
19710        let mut receipts = Vec::new();
19711        for (suffix, native_suffix) in [("a", "a-pty"), ("b", "b-pty")] {
19712            let profile_id =
19713                SpawnEnvironmentProfileId::new(format!("claude-config-dir-{suffix}")).unwrap();
19714            let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(format!(
19715                "claude-config-dir-{suffix}-r1"
19716            ))
19717            .unwrap();
19718            let materialization = NodeSessionMaterializationProfile::new(
19719                Vec::new(),
19720                vec![NodeSessionPathBinding::new(
19721                    "CLAUDE_CONFIG_DIR",
19722                    NodeSessionPathClass::ProviderHome,
19723                )
19724                .unwrap()],
19725                Vec::new(),
19726            )
19727            .unwrap();
19728            let node_profile = NodeEnvironmentProfile::new_with_materialization(
19729                profile_id.clone(),
19730                profile_revision.clone(),
19731                agent("claude"),
19732                [NativeLaunchProfile::new(
19733                    NativeLaunchProfileId::new(format!("claude-config-dir-{native_suffix}"))
19734                        .unwrap(),
19735                    agent("claude"),
19736                    TransportKind::Pty,
19737                    vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19738                    Arc::new(EmptyEnvironmentResolver),
19739                )
19740                .unwrap()],
19741                Some(materialization),
19742            )
19743            .unwrap();
19744            let (binding, native_profiles, materialization) = node_profile.into_parts();
19745            for native_profile in native_profiles {
19746                runtime.upsert_native_launch_profile(native_profile).unwrap();
19747            }
19748            shared
19749                .environment_profiles
19750                .write()
19751                .unwrap_or_else(|poisoned| poisoned.into_inner())
19752                .insert(binding.id.clone(), binding);
19753            shared
19754                .environment_materialization_profiles
19755                .write()
19756                .unwrap_or_else(|poisoned| poisoned.into_inner())
19757                .insert(profile_id.clone(), materialization.unwrap());
19758            receipts.push(ResolvedEnvironmentProfileReceipt {
19759                profile_id,
19760                profile_revision,
19761                network_allowlist: None,
19762                browser_profile_id: None,
19763            });
19764        }
19765        let profile_b = receipts.pop().unwrap();
19766        let profile_a = receipts.pop().unwrap();
19767        (shared, profile_a, profile_b)
19768    }
19769
19770    #[test]
19771    fn refuse_distinct_claude_config_dir_profiles_on_same_workspace_cwd() {
19772        let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
19773        let primary = WorkspaceId::new("primary").unwrap();
19774        let worktree = WorkspaceId::new("worktree-b").unwrap();
19775        let address = SessionAddress {
19776            workspace_id: primary.clone(),
19777            session: SessionKey {
19778                instance_id: AgentInstanceId(41),
19779                generation: SessionGeneration(1),
19780            },
19781        };
19782        shared.bind_session_with_policy(
19783            &address,
19784            ProviderRuntimePolicy::raw_pty(),
19785            Some(profile_a.clone()),
19786        );
19787
19788        let refused = shared
19789            .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_b))
19790            .unwrap_err();
19791        assert_eq!(refused.code, NodeFailureCode::WorkspaceBusy);
19792
19793        // Some vs None on the same cwd is also a collision.
19794        let refused_none = shared
19795            .ensure_workspace_environment_profile_compatible(&primary, None)
19796            .unwrap_err();
19797        assert_eq!(refused_none.code, NodeFailureCode::WorkspaceBusy);
19798
19799        // Same profile on the same workspace remains allowed.
19800        shared
19801            .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_a))
19802            .unwrap();
19803
19804        // Distinct workspace id (managed-worktree parallel path) allows profile B.
19805        shared
19806            .ensure_workspace_environment_profile_compatible(&worktree, Some(&profile_b))
19807            .unwrap();
19808    }
19809
19810    #[tokio::test]
19811    async fn spawn_session_refuses_conflicting_environment_profile_before_mutation() {
19812        let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
19813        let primary = WorkspaceId::new("primary").unwrap();
19814        let address = SessionAddress {
19815            workspace_id: primary.clone(),
19816            session: SessionKey {
19817                instance_id: AgentInstanceId(41),
19818                generation: SessionGeneration(1),
19819            },
19820        };
19821        shared.bind_session_with_policy(
19822            &address,
19823            ProviderRuntimePolicy::raw_pty(),
19824            Some(profile_a),
19825        );
19826
19827        let failure = shared
19828            .spawn_session_with_deadline(
19829                primary,
19830                agent("claude"),
19831                SessionMode::Pty,
19832                gate4agent_types::TerminalSize {
19833                    rows: 24,
19834                    columns: 80,
19835                },
19836                None,
19837                Some(profile_b),
19838                None,
19839                None,
19840                None,
19841                None,
19842                SpawnRecordPolicy::ProviderIdentityOnly,
19843                Some(Instant::now() + Duration::from_secs(30)),
19844                &[],
19845                None,
19846                ApprovalLevel::default(),
19847                None,
19848                None,
19849            )
19850            .await
19851            .unwrap_err();
19852        assert_eq!(failure.code, NodeFailureCode::WorkspaceBusy);
19853        // Guard runs before instance id mint / bind.
19854        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
19855        assert!(shared.handle.snapshot().sessions.is_empty());
19856    }
19857}
19858
19859#[cfg(test)]
19860mod spawn_characterization_tests {
19861    use super::*;
19862    use crate::session_environment::{
19863        NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
19864        NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
19865        NodeSessionPathClass,
19866    };
19867    use gate4agent_catalog::EnvMutation;
19868    use gate4agent_runtime_native::{
19869        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
19870        NativeLaunchProfile, NativeLaunchProfileId,
19871    };
19872    use std::ffi::OsString;
19873    use std::sync::Arc;
19874
19875    struct EmptyEnvironmentResolver;
19876
19877    struct FixtureSecretResolver {
19878        deny: Arc<AtomicBool>,
19879    }
19880
19881    impl NodeSecretResolver for FixtureSecretResolver {
19882        fn resolve(
19883            &self,
19884            _reference: &NodeSecretReference,
19885        ) -> Result<NodeSecretValue, NodeSecretResolveError> {
19886            if self.deny.load(Ordering::Acquire) {
19887                Err(NodeSecretResolveError::Denied)
19888            } else {
19889                NodeSecretValue::text("fixture-secret")
19890                    .map_err(|_| NodeSecretResolveError::Unavailable)
19891            }
19892        }
19893    }
19894
19895    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19896        fn resolve_child_environment(
19897            &self,
19898        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19899            Ok(vec![EnvMutation {
19900                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19901                value: None,
19902            }])
19903        }
19904    }
19905
19906    fn agent(value: &str) -> AgentId {
19907        AgentId::new(value).unwrap()
19908    }
19909
19910    fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
19911        let unique = std::time::SystemTime::now()
19912            .duration_since(std::time::UNIX_EPOCH)
19913            .unwrap()
19914            .as_nanos();
19915        std::env::temp_dir().join(format!(
19916            "gate4agent-node-{label}-{}-{unique}",
19917            std::process::id(),
19918        ))
19919    }
19920
19921    fn materialization_test_shared(
19922        deny_secret: bool,
19923    ) -> (
19924        NodeShared,
19925        NativeRuntime,
19926        ResolvedEnvironmentProfileReceipt,
19927        PathBuf,
19928        Arc<AtomicBool>,
19929    ) {
19930        let workspace = WorkspaceConfig::new(
19931            WorkspaceId::new("primary").unwrap(),
19932            std::env::current_dir().unwrap(),
19933        )
19934        .unwrap();
19935        materialization_test_shared_with_workspace(deny_secret, workspace)
19936    }
19937
19938    fn materialization_test_shared_with_workspace(
19939        deny_secret: bool,
19940        workspace: WorkspaceConfig,
19941    ) -> (
19942        NodeShared,
19943        NativeRuntime,
19944        ResolvedEnvironmentProfileReceipt,
19945        PathBuf,
19946        Arc<AtomicBool>,
19947    ) {
19948        let root = temporary_workspace_root(if deny_secret {
19949            "materialization-denied"
19950        } else {
19951            "materialization-ready"
19952        });
19953        std::fs::create_dir_all(&root).unwrap();
19954        let materialization_root = root.join("session-environments");
19955        let deny = Arc::new(AtomicBool::new(deny_secret));
19956        let resolver = Arc::new(FixtureSecretResolver {
19957            deny: Arc::clone(&deny),
19958        });
19959        let materializer = SessionEnvironmentMaterializer::new(
19960            materialization_root,
19961            resolver,
19962        )
19963        .unwrap();
19964        let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
19965        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19966            "materialized-claude-r1",
19967        )
19968        .unwrap();
19969        let materialization = NodeSessionMaterializationProfile::new(
19970            vec![NodeSessionEnvironmentMutation::SetSecret {
19971                key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
19972                reference: NodeSecretReference::new("fixture-token").unwrap(),
19973            }],
19974            vec![NodeSessionPathBinding::new(
19975                "GATE4AGENT_PROVIDER_HOME",
19976                NodeSessionPathClass::ProviderHome,
19977            )
19978            .unwrap()],
19979            vec![NodeSessionFile::secret(
19980                NodeSessionPathClass::Config,
19981                "auth/token",
19982                NodeSecretReference::new("fixture-token").unwrap(),
19983            )
19984            .unwrap()],
19985        )
19986        .unwrap();
19987        let catalog = active_registry().unwrap();
19988        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19989        let control = runtime.native_launch_profile_control();
19990        let node_profile = NodeEnvironmentProfile::new_with_materialization(
19991            profile_id.clone(),
19992            profile_revision.clone(),
19993            agent("claude"),
19994            [NativeLaunchProfile::new(
19995                NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
19996                agent("claude"),
19997                TransportKind::Pty,
19998                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19999                Arc::new(EmptyEnvironmentResolver),
20000            )
20001            .unwrap()],
20002            Some(materialization),
20003        )
20004        .unwrap();
20005        let (binding, native_profiles, materialization) = node_profile.into_parts();
20006        for native_profile in native_profiles {
20007            runtime.upsert_native_launch_profile(native_profile).unwrap();
20008        }
20009        let shared = NodeShared::new_with_incarnation(
20010            handle,
20011            "fixture-token".to_owned(),
20012            NodeId::new("node-materialization-test").unwrap(),
20013            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20014            vec![workspace],
20015            vec![agent("claude")],
20016            ProviderRuntimeStatuses::default(),
20017            None,
20018            Vec::new(),
20019            Vec::new(),
20020            SpawnProfileRegistry::default(),
20021            Some(control),
20022            None,
20023            Some(materializer),
20024            None,
20025            Vec::new(),
20026            Vec::new(),
20027            Vec::new(),
20028            Vec::new(),
20029            None,
20030            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20031        );
20032        shared
20033            .environment_profiles
20034            .write()
20035            .unwrap_or_else(|poisoned| poisoned.into_inner())
20036            .insert(binding.id.clone(), binding);
20037        shared
20038            .environment_materialization_profiles
20039            .write()
20040            .unwrap_or_else(|poisoned| poisoned.into_inner())
20041            .insert(profile_id.clone(), materialization.unwrap());
20042        (
20043            shared,
20044            runtime,
20045            ResolvedEnvironmentProfileReceipt {
20046                profile_id,
20047                profile_revision,
20048                network_allowlist: None,
20049                browser_profile_id: None,
20050            },
20051            root,
20052            deny,
20053        )
20054    }
20055
20056
20057    // ---- Characterization of the spawn preparation block (pre-SpawnPreparer) ----
20058
20059    async fn characterization_spawn(
20060        shared: &NodeShared,
20061        mode: SessionMode,
20062        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
20063        required: &[ProviderRuntimeCapability],
20064        harness_mcp: Option<&PreparedHarnessMcpSpawn>,
20065    ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
20066        shared
20067            .spawn_session_with_deadline(
20068                WorkspaceId::new("primary").unwrap(),
20069                agent("claude"),
20070                mode,
20071                gate4agent_types::TerminalSize { rows: 24, columns: 80 },
20072                None,
20073                environment_profile,
20074                None,
20075                None,
20076                None,
20077                Some(ProviderRuntimePolicy::raw_pty()),
20078                SpawnRecordPolicy::ProviderIdentityOnly,
20079                Some(Instant::now() + Duration::from_secs(30)),
20080                required,
20081                harness_mcp,
20082                ApprovalLevel::default(),
20083                None,
20084                None,
20085            )
20086            .await
20087    }
20088
20089    fn assert_no_session_state(shared: &NodeShared) {
20090        assert!(shared.handle.snapshot().sessions.is_empty());
20091        assert!(shared
20092            .session_bindings
20093            .lock()
20094            .unwrap_or_else(|poisoned| poisoned.into_inner())
20095            .is_empty());
20096        assert!(shared
20097            .materializations
20098            .lock()
20099            .unwrap_or_else(|poisoned| poisoned.into_inner())
20100            .is_empty());
20101    }
20102
20103    #[tokio::test]
20104    async fn spawn_unadmitted_required_capability_refuses_before_id_mint() {
20105        let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
20106        let error = characterization_spawn(
20107            &shared,
20108            SessionMode::Pty,
20109            None,
20110            &[ProviderRuntimeCapability::SemanticReadiness],
20111            None,
20112        )
20113        .await
20114        .unwrap_err();
20115        assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
20116        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
20117        assert_no_session_state(&shared);
20118        drop(shared);
20119        drop(runtime);
20120        std::fs::remove_dir_all(root).unwrap();
20121    }
20122
20123    #[tokio::test]
20124    async fn spawn_denied_environment_materialization_fails_after_id_mint_with_no_state() {
20125        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
20126        let error = characterization_spawn(&shared, SessionMode::Pty, Some(receipt), &[], None)
20127            .await
20128            .unwrap_err();
20129        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
20130        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 2);
20131        assert_no_session_state(&shared);
20132        drop(shared);
20133        drop(runtime);
20134        std::fs::remove_dir_all(root).unwrap();
20135    }
20136
20137    #[test]
20138    fn remove_binding_returns_binding_and_is_idempotent_for_environment_session() {
20139        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20140        let address = SessionAddress {
20141            workspace_id: WorkspaceId::new("primary").unwrap(),
20142            session: SessionKey {
20143                instance_id: AgentInstanceId(77),
20144                generation: SessionGeneration(1),
20145            },
20146        };
20147        shared.bind_session_with_policy(&address, ProviderRuntimePolicy::raw_pty(), Some(receipt));
20148        let removed = shared.remove_binding(&address).expect("binding removed");
20149        assert!(removed.environment_profile.is_some());
20150        assert!(shared.remove_binding(&address).is_none());
20151        assert_no_session_state(&shared);
20152        drop(shared);
20153        drop(runtime);
20154        std::fs::remove_dir_all(root).unwrap();
20155    }
20156
20157    async fn characterization_prepared_harness(provider: &str) -> PreparedHarnessMcpSpawn {
20158        use crate::harness_mcp_proxy::{HarnessMcpProxyRegistry, ReviewedHarnessMcpProgram};
20159        use crate::protocol::{HarnessMcpActivationDigest, HarnessMcpLaunchV1, HarnessMcpReservationId};
20160        let helper = ReviewedHarnessMcpProgram::review(std::env::current_exe().unwrap())
20161            .expect("review current test executable as fixture helper");
20162        let registry = HarnessMcpProxyRegistry::new(helper);
20163        let spec = crate::protocol::SpawnSpec {
20164            target: crate::protocol::SpawnTarget {
20165                node_id: NodeId::new("node-characterization").unwrap(),
20166                workspace_id: WorkspaceId::new("primary").unwrap(),
20167                worktree_id: None,
20168            },
20169            profile_id: crate::protocol::SpawnProfileId::new("claude").unwrap(),
20170            expected_profile_revision: crate::protocol::SpawnProfileRevision::new("r1").unwrap(),
20171            overrides: crate::protocol::SpawnOverrides::default(),
20172            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
20173            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("characterization-harness").unwrap(),
20174            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
20175        };
20176        let reservation = HarnessMcpReservationId::new(format!("hmcpres_{}", "c".repeat(24))).unwrap();
20177        let digest = HarnessMcpActivationDigest::new(format!("sha256:{}", "c".repeat(64))).unwrap();
20178        let launch = HarnessMcpLaunchV1 {
20179            server_name: "fixture-harness".to_owned(),
20180            args: vec!["--session-proxy".to_owned()],
20181            endpoint_env: "FIXTURE_SESSION_ENDPOINT".to_owned(),
20182            token_env: "FIXTURE_SESSION_TOKEN".to_owned(),
20183            program_env: "FIXTURE_MCP_PROGRAM".to_owned(),
20184            trace: None,
20185            scrub_env: Vec::new(),
20186        };
20187        let expires = unix_time_ms().saturating_add(30_000);
20188        registry.arm(reservation.clone(), digest.clone(), spec.clone(), launch, expires).await.unwrap();
20189        registry
20190            .prepare_spawn(&reservation, &digest, &spec, expires, agent(provider))
20191            .unwrap()
20192    }
20193
20194    #[tokio::test]
20195    async fn spawn_harness_overlay_refuses_inline_and_provider_mismatch_with_no_state() {
20196        let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
20197        let prepared = characterization_prepared_harness("claude").await;
20198        let error = characterization_spawn(&shared, SessionMode::Inline, None, &[], Some(&prepared))
20199            .await
20200            .unwrap_err();
20201        // Inline is stopped by runtime admission before the harness check runs.
20202        assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
20203        let mismatched = characterization_prepared_harness("codex").await;
20204        let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&mismatched))
20205            .await
20206            .unwrap_err();
20207        assert_eq!(error.code, NodeFailureCode::BindingMismatch);
20208        assert_no_session_state(&shared);
20209        drop(shared);
20210        drop(runtime);
20211        std::fs::remove_dir_all(root).unwrap();
20212    }
20213
20214    #[tokio::test]
20215    async fn spawn_harness_overlay_without_launch_control_is_unavailable_with_no_state() {
20216        let (mut shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
20217        shared.native_launch_profile_control = None;
20218        let prepared = characterization_prepared_harness("claude").await;
20219        let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&prepared))
20220            .await
20221            .unwrap_err();
20222        assert_eq!(error.code, NodeFailureCode::HarnessMcpUnavailable);
20223        assert_no_session_state(&shared);
20224        drop(shared);
20225        drop(runtime);
20226        std::fs::remove_dir_all(root).unwrap();
20227    }
20228}
20229
20230#[cfg(all(test, windows))]
20231mod tests {
20232    use super::*;
20233    use gate4agent_types::ProviderActivity;
20234    use crate::session_environment::{
20235        NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
20236        NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
20237        NodeSessionPathClass,
20238    };
20239    use gate4agent_catalog::EnvMutation;
20240    use gate4agent_runtime_native::{
20241        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
20242        NativeLaunchProfile, NativeLaunchProfileId,
20243    };
20244    use std::ffi::OsString;
20245    use std::sync::Arc;
20246
20247    struct EmptyEnvironmentResolver;
20248
20249    struct FixtureSecretResolver {
20250        deny: Arc<AtomicBool>,
20251    }
20252
20253    impl NodeSecretResolver for FixtureSecretResolver {
20254        fn resolve(
20255            &self,
20256            _reference: &NodeSecretReference,
20257        ) -> Result<NodeSecretValue, NodeSecretResolveError> {
20258            if self.deny.load(Ordering::Acquire) {
20259                Err(NodeSecretResolveError::Denied)
20260            } else {
20261                NodeSecretValue::text("fixture-secret")
20262                    .map_err(|_| NodeSecretResolveError::Unavailable)
20263            }
20264        }
20265    }
20266
20267    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
20268        fn resolve_child_environment(
20269            &self,
20270        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
20271            Ok(vec![EnvMutation {
20272                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
20273                value: None,
20274            }])
20275        }
20276    }
20277
20278    fn agent(value: &str) -> AgentId {
20279        AgentId::new(value).unwrap()
20280    }
20281
20282    fn terminal_test_shared_with_mode(mode: WorktreeServiceMode) -> NodeShared {
20283        let catalog = active_registry().unwrap();
20284        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20285        let workspace = WorkspaceConfig::new(
20286            WorkspaceId::new("primary").unwrap(),
20287            std::env::current_dir().unwrap(),
20288        )
20289        .unwrap()
20290        .with_worktree_service_mode(mode);
20291        NodeShared::new(
20292            handle,
20293            "fixture-token".to_owned(),
20294            NodeId::new("node-terminal-test").unwrap(),
20295            vec![workspace],
20296            vec![agent("claude")],
20297        )
20298    }
20299
20300    fn terminal_test_shared() -> NodeShared {
20301        terminal_test_shared_with_mode(WorktreeServiceMode::Manual)
20302    }
20303
20304    #[cfg(feature = "fixture")]
20305    #[test]
20306    fn managed_worktree_v2_failure_probe_keeps_only_the_latest_code() {
20307        let probe = SpawnManagedWorktreeV2FailureProbe::default();
20308        assert_eq!(probe.latest_code(), None);
20309
20310        probe.record(Some(NodeFailureCode::ManagedWorktreeOwnershipConflict));
20311        assert_eq!(
20312            probe.latest_code(),
20313            Some(NodeFailureCode::ManagedWorktreeOwnershipConflict),
20314        );
20315
20316        probe.record(Some(NodeFailureCode::SpawnDeadlineExceeded));
20317        assert_eq!(
20318            probe.latest_code(),
20319            Some(NodeFailureCode::SpawnDeadlineExceeded),
20320        );
20321
20322        probe.record(None);
20323        assert_eq!(probe.latest_code(), None);
20324    }
20325
20326    #[test]
20327    fn launch_snapshot_is_authoritative_bounded_and_path_free() {
20328        let source = temporary_workspace_root("launch-inventory-source");
20329        let allocation = temporary_workspace_root("launch-inventory-allocation");
20330        std::fs::create_dir_all(&source).unwrap();
20331        std::fs::create_dir_all(&allocation).unwrap();
20332        let workspace_id = WorkspaceId::new("primary").unwrap();
20333        let mut workspace = WorkspaceConfig::new(workspace_id.clone(), &source)
20334            .unwrap()
20335            .with_worktree_service_mode(WorktreeServiceMode::Managed);
20336        for index in 0..MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE {
20337            workspace = workspace
20338                .with_managed_worktree_profile(
20339                    ManagedWorktreeProfile::new(
20340                        WorktreeProfileId::new(format!("profile-{index}")).unwrap(),
20341                        crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
20342                        &allocation,
20343                        "gate4agent",
20344                        "HEAD",
20345                        ManagedWorktreeRetention::RemoveWhenReleased,
20346                    )
20347                    .unwrap(),
20348                )
20349                .unwrap();
20350        }
20351        let overflow = workspace.clone().with_managed_worktree_profile(
20352            ManagedWorktreeProfile::new(
20353                WorktreeProfileId::new("overflow").unwrap(),
20354                crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
20355                &allocation,
20356                "gate4agent",
20357                "HEAD",
20358                ManagedWorktreeRetention::Retain,
20359            )
20360            .unwrap(),
20361        );
20362        assert!(matches!(
20363            overflow,
20364            Err(NodeServerError::ManagedWorktreeProfileCapacity {
20365                max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
20366                ..
20367            }),
20368        ));
20369
20370        let catalog = active_registry().unwrap();
20371        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20372        let shared = NodeShared::new(
20373            handle,
20374            "fixture-token".to_owned(),
20375            NodeId::new("node-launch-inventory").unwrap(),
20376            vec![workspace],
20377            vec![agent("claude")],
20378        );
20379        let snapshot = shared.snapshot();
20380        let launch = snapshot.launch_inventory.as_ref().unwrap();
20381        assert_eq!(launch.spawn_profiles.as_ref().unwrap().len(), 1);
20382        assert!(launch.bundles.as_ref().unwrap().is_empty());
20383        let profiles = snapshot.workspaces[0]
20384            .managed_worktree_profiles
20385            .as_ref()
20386            .unwrap();
20387        assert_eq!(
20388            snapshot.workspaces[0].worktree_service_mode,
20389            Some(WorktreeServiceMode::Managed),
20390        );
20391        assert_eq!(
20392            profiles.profiles.len(),
20393            MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
20394        );
20395        let public_inventory = serde_json::to_string(&(launch, profiles)).unwrap();
20396        for forbidden in ["allocation_root", "branch_prefix", "base", "prompt", "environment", "home"] {
20397            assert!(!public_inventory.contains(forbidden), "launch inventory leaked {forbidden}");
20398        }
20399        let legacy = snapshot_for_wire(
20400            &shared,
20401            true,
20402            true,
20403            false,
20404            false,
20405            true,
20406            false,
20407            true,
20408            false,
20409            true,
20410        );
20411        assert!(legacy.launch_inventory.is_none());
20412        assert!(legacy.workspaces[0].managed_worktree_profiles.is_none());
20413
20414        drop(shared);
20415        std::fs::remove_dir_all(source).unwrap();
20416        std::fs::remove_dir_all(allocation).unwrap();
20417    }
20418
20419    #[test]
20420    fn agent_progress_projection_omits_sensitive_provider_payloads_and_is_capability_gated() {
20421        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20422            capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
20423        }));
20424        let source = gate4agent_types::ProviderSource {
20425            family: AdapterFamily::PtySemantic,
20426            binding: AdapterBinding::new(
20427                gate4agent_types::AdapterId::new("claude").unwrap(),
20428                "fixture/v1",
20429                gate4agent_types::AdapterVerification::SyntheticFixture,
20430            )
20431            .unwrap(),
20432        };
20433        let provider = ProviderSnapshot {
20434            sequence: 17,
20435            session: Some(ProviderSessionIdentity {
20436                key: ProviderSessionKey::SessionId,
20437                id: "PRIVATE_PROVIDER_SESSION".to_owned(),
20438                transcript_path: Some(r"C:\PRIVATE_PROVIDER_PATH".to_owned()),
20439            }),
20440            model: Some("PRIVATE_MODEL".to_owned()),
20441            tools: vec!["PRIVATE_TOOL_INVENTORY".to_owned()],
20442            completed_turns: 3,
20443            usage: gate4agent_types::TokenUsage {
20444                input_tokens: 10,
20445                output_tokens: 20,
20446                cache_read_tokens: 30,
20447                cache_write_tokens: 40,
20448                reasoning_tokens: 50,
20449                context_window: Some(200_000),
20450            },
20451            lead_activity: ProviderActivity::WaitingForInput,
20452            activity: ProviderActivity::WaitingForInput,
20453            current_prompt: Some("PRIVATE_CURRENT_PROMPT".to_owned()),
20454            active_tools: (0..9)
20455                .map(|index| gate4agent_types::ActiveProviderTool {
20456                    id: format!("private-tool-id-{index}"),
20457                    name: if index == 0 {
20458                        format!("  Read\u{0000}{}", "x".repeat(80))
20459                    } else {
20460                        format!("Tool-{index}")
20461                    },
20462                    input_json: "PRIVATE_TOOL_INPUT_JSON".to_owned(),
20463                })
20464                .collect(),
20465            interactions: vec![gate4agent_types::ProviderInteraction {
20466                id: gate4agent_types::ProviderInteractionId(17),
20467                source: source.clone(),
20468                provider_request_id: Some("PRIVATE_PROVIDER_REQUEST".to_owned()),
20469                interaction_kind: ProviderInteractionKind::Approval,
20470                tool_name: " Approve\u{0007}Tool ".to_owned(),
20471                prompt: "PRIVATE_INTERACTION_PROMPT".to_owned(),
20472                agent_id: Some("PRIVATE_INTERACTION_AGENT".to_owned()),
20473                resume_lead_activity: Some(ProviderActivity::Working),
20474                status: ProviderInteractionStatus::Pending,
20475            }],
20476            subagents: vec![gate4agent_types::ProviderSubagent {
20477                source: source.clone(),
20478                provider_agent_id: "PRIVATE_SUBAGENT_ID".to_owned(),
20479                agent_type: Some("PRIVATE_SUBAGENT_TYPE".to_owned()),
20480                description: Some("PRIVATE_SUBAGENT_DESCRIPTION".to_owned()),
20481            }],
20482            sources: vec![gate4agent_types::ProviderSourceCursor {
20483                source,
20484                sequence: 17,
20485                gap_count: 2,
20486                stale: true,
20487            }],
20488            last_event: Some(ProviderEvent::Error {
20489                message: "PRIVATE_PROVIDER_ERROR".to_owned(),
20490            }),
20491            gap_count: 2,
20492            stale: true,
20493        };
20494        let address = terminal_address(3);
20495        let entry = agent_progress_from_provider_snapshot(address.clone(), &provider).unwrap();
20496        assert_eq!(entry.address, address);
20497        assert_eq!(entry.progress.provider_sequence, 17);
20498        assert_eq!(entry.progress.active_tool_count, 9);
20499        assert_eq!(
20500            entry.progress.active_tool_labels.len(),
20501            MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS,
20502        );
20503        assert_eq!(entry.progress.subagent_count, 1);
20504        assert_eq!(entry.progress.last_event_kind, Some(AgentProgressEventKindV1::Error));
20505        assert!(entry.progress.truncated);
20506        let encoded = serde_json::to_string(&entry).unwrap();
20507        for forbidden in [
20508            "PRIVATE_PROVIDER_SESSION",
20509            "PRIVATE_PROVIDER_PATH",
20510            "PRIVATE_MODEL",
20511            "PRIVATE_TOOL_INVENTORY",
20512            "PRIVATE_CURRENT_PROMPT",
20513            "PRIVATE_TOOL_INPUT_JSON",
20514            "PRIVATE_PROVIDER_REQUEST",
20515            "PRIVATE_INTERACTION_PROMPT",
20516            "PRIVATE_INTERACTION_AGENT",
20517            "PRIVATE_SUBAGENT_ID",
20518            "PRIVATE_SUBAGENT_TYPE",
20519            "PRIVATE_SUBAGENT_DESCRIPTION",
20520            "PRIVATE_PROVIDER_ERROR",
20521            "input_json",
20522            "current_prompt",
20523            "session_id",
20524        ] {
20525            assert!(!encoded.contains(forbidden), "agent progress leaked {forbidden}");
20526        }
20527
20528        let snapshot = NodeSnapshot {
20529            node_id: NodeId::new("node-progress-test").unwrap(),
20530            enabled_providers: vec![agent("claude")],
20531            provider_runtime_statuses: ProviderRuntimeStatuses::default(),
20532            workspaces: Vec::new(),
20533            session_records: Vec::new(),
20534            managed_worktrees: Vec::new(),
20535            launch_inventory: None,
20536            agent_progress: vec![entry],
20537        };
20538        let mut legacy_reply = ResponseEnvelope {
20539            request_id: 1,
20540            result: Ok(NodeResponse::Snapshot {
20541                event_sequence: 0,
20542                controller: None,
20543                snapshot: snapshot.clone(),
20544            }),
20545        };
20546        project_response_without_agent_progress(&mut legacy_reply);
20547        let Ok(NodeResponse::Snapshot { snapshot: legacy, .. }) = legacy_reply.result else {
20548            panic!("agent progress projection changed response kind");
20549        };
20550        assert!(legacy.agent_progress.is_empty());
20551        assert_eq!(snapshot.agent_progress.len(), 1);
20552    }
20553
20554    fn terminal_address(generation: u64) -> SessionAddress {
20555        SessionAddress {
20556            workspace_id: WorkspaceId::new("primary").unwrap(),
20557            session: SessionKey {
20558                instance_id: AgentInstanceId(41),
20559                generation: SessionGeneration(generation),
20560            },
20561        }
20562    }
20563
20564    fn spawn_spec_fixture() -> SpawnSpec {
20565        SpawnSpec {
20566            target: crate::protocol::SpawnTarget {
20567                node_id: NodeId::new("node-terminal-test").unwrap(),
20568                workspace_id: WorkspaceId::new("primary").unwrap(),
20569                worktree_id: None,
20570            },
20571            profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
20572            expected_profile_revision: crate::protocol::SpawnProfileRevision::new("builtin-v1")
20573                .unwrap(),
20574            overrides: crate::protocol::SpawnOverrides::default(),
20575            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
20576            idempotency_key: SpawnIdempotencyKey::new("spawn-spec-fixture").unwrap(),
20577            required_capabilities: SpawnRequiredCapabilities::default(),
20578        }
20579    }
20580
20581    fn environment_profile_test_shared(
20582    ) -> (NodeShared, NativeRuntime, ResolvedEnvironmentProfileReceipt) {
20583        let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
20584        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20585            "local-claude-r1",
20586        )
20587        .unwrap();
20588        let spawn_profiles = SpawnProfileRegistry::new([
20589            crate::protocol::SpawnProfileDefaults {
20590                profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
20591                revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
20592                provider: agent("claude"),
20593                mode: SessionMode::Pty,
20594                terminal_size: gate4agent_types::TerminalSize {
20595                    rows: 24,
20596                    columns: 80,
20597                },
20598                prompt: None,
20599                bundle_id: None,
20600                context_id: None,
20601                environment_profile_id: Some(profile_id.clone()),
20602            },
20603        ])
20604        .unwrap();
20605        let catalog = active_registry().unwrap();
20606        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20607        let control = runtime.native_launch_profile_control();
20608        let node_profile = NodeEnvironmentProfile::new(
20609            profile_id.clone(),
20610            profile_revision.clone(),
20611            agent("claude"),
20612            [NativeLaunchProfile::new(
20613                NativeLaunchProfileId::new("local-claude-pty").unwrap(),
20614                agent("claude"),
20615                TransportKind::Pty,
20616                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20617                Arc::new(EmptyEnvironmentResolver),
20618            )
20619            .unwrap()],
20620        )
20621        .unwrap();
20622        let (binding, native_profiles, _materialization) = node_profile.into_parts();
20623        for native_profile in native_profiles {
20624            runtime.upsert_native_launch_profile(native_profile).unwrap();
20625        }
20626        let workspace = WorkspaceConfig::new(
20627            WorkspaceId::new("primary").unwrap(),
20628            std::env::current_dir().unwrap(),
20629        )
20630        .unwrap();
20631        let shared = NodeShared::new_with_incarnation(
20632            handle,
20633            "fixture-token".to_owned(),
20634            NodeId::new("node-terminal-test").unwrap(),
20635            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20636            vec![workspace],
20637            vec![agent("claude")],
20638            ProviderRuntimeStatuses::default(),
20639            None,
20640            Vec::new(),
20641            Vec::new(),
20642            spawn_profiles,
20643            Some(control),
20644            None,
20645            None,
20646            None,
20647            Vec::new(),
20648            Vec::new(),
20649            Vec::new(),
20650            Vec::new(),
20651            None,
20652            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20653        );
20654        shared
20655            .environment_profiles
20656            .write()
20657            .unwrap_or_else(|poisoned| poisoned.into_inner())
20658            .insert(binding.id.clone(), binding);
20659        (
20660            shared,
20661            runtime,
20662            ResolvedEnvironmentProfileReceipt {
20663                profile_id,
20664                profile_revision,
20665                network_allowlist: None,
20666                browser_profile_id: None,
20667            },
20668        )
20669    }
20670
20671    fn materialization_test_shared(
20672        deny_secret: bool,
20673    ) -> (
20674        NodeShared,
20675        NativeRuntime,
20676        ResolvedEnvironmentProfileReceipt,
20677        PathBuf,
20678        Arc<AtomicBool>,
20679    ) {
20680        let workspace = WorkspaceConfig::new(
20681            WorkspaceId::new("primary").unwrap(),
20682            std::env::current_dir().unwrap(),
20683        )
20684        .unwrap();
20685        materialization_test_shared_with_workspace(deny_secret, workspace)
20686    }
20687
20688    fn materialization_test_shared_with_workspace(
20689        deny_secret: bool,
20690        workspace: WorkspaceConfig,
20691    ) -> (
20692        NodeShared,
20693        NativeRuntime,
20694        ResolvedEnvironmentProfileReceipt,
20695        PathBuf,
20696        Arc<AtomicBool>,
20697    ) {
20698        let root = temporary_workspace_root(if deny_secret {
20699            "materialization-denied"
20700        } else {
20701            "materialization-ready"
20702        });
20703        std::fs::create_dir_all(&root).unwrap();
20704        let materialization_root = root.join("session-environments");
20705        let deny = Arc::new(AtomicBool::new(deny_secret));
20706        let resolver = Arc::new(FixtureSecretResolver {
20707            deny: Arc::clone(&deny),
20708        });
20709        let materializer = SessionEnvironmentMaterializer::new(
20710            materialization_root,
20711            resolver,
20712        )
20713        .unwrap();
20714        let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
20715        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20716            "materialized-claude-r1",
20717        )
20718        .unwrap();
20719        let materialization = NodeSessionMaterializationProfile::new(
20720            vec![NodeSessionEnvironmentMutation::SetSecret {
20721                key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
20722                reference: NodeSecretReference::new("fixture-token").unwrap(),
20723            }],
20724            vec![NodeSessionPathBinding::new(
20725                "GATE4AGENT_PROVIDER_HOME",
20726                NodeSessionPathClass::ProviderHome,
20727            )
20728            .unwrap()],
20729            vec![NodeSessionFile::secret(
20730                NodeSessionPathClass::Config,
20731                "auth/token",
20732                NodeSecretReference::new("fixture-token").unwrap(),
20733            )
20734            .unwrap()],
20735        )
20736        .unwrap();
20737        let catalog = active_registry().unwrap();
20738        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20739        let control = runtime.native_launch_profile_control();
20740        let node_profile = NodeEnvironmentProfile::new_with_materialization(
20741            profile_id.clone(),
20742            profile_revision.clone(),
20743            agent("claude"),
20744            [NativeLaunchProfile::new(
20745                NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
20746                agent("claude"),
20747                TransportKind::Pty,
20748                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20749                Arc::new(EmptyEnvironmentResolver),
20750            )
20751            .unwrap()],
20752            Some(materialization),
20753        )
20754        .unwrap();
20755        let (binding, native_profiles, materialization) = node_profile.into_parts();
20756        for native_profile in native_profiles {
20757            runtime.upsert_native_launch_profile(native_profile).unwrap();
20758        }
20759        let shared = NodeShared::new_with_incarnation(
20760            handle,
20761            "fixture-token".to_owned(),
20762            NodeId::new("node-materialization-test").unwrap(),
20763            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20764            vec![workspace],
20765            vec![agent("claude")],
20766            ProviderRuntimeStatuses::default(),
20767            None,
20768            Vec::new(),
20769            Vec::new(),
20770            SpawnProfileRegistry::default(),
20771            Some(control),
20772            None,
20773            Some(materializer),
20774            None,
20775            Vec::new(),
20776            Vec::new(),
20777            Vec::new(),
20778            Vec::new(),
20779            None,
20780            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20781        );
20782        shared
20783            .environment_profiles
20784            .write()
20785            .unwrap_or_else(|poisoned| poisoned.into_inner())
20786            .insert(binding.id.clone(), binding);
20787        shared
20788            .environment_materialization_profiles
20789            .write()
20790            .unwrap_or_else(|poisoned| poisoned.into_inner())
20791            .insert(profile_id.clone(), materialization.unwrap());
20792        (
20793            shared,
20794            runtime,
20795            ResolvedEnvironmentProfileReceipt {
20796                profile_id,
20797                profile_revision,
20798                network_allowlist: None,
20799                browser_profile_id: None,
20800            },
20801            root,
20802            deny,
20803        )
20804    }
20805
20806    #[cfg(feature = "fixture")]
20807    #[derive(Clone, Copy)]
20808    enum CodexHomeProfile {
20809        Missing,
20810        WrongClass,
20811        Exact,
20812    }
20813
20814    #[cfg(feature = "fixture")]
20815    fn codex_bundle_test_shared(
20816        home_profile: CodexHomeProfile,
20817    ) -> (
20818        NodeShared,
20819        NativeRuntime,
20820        ResolvedEnvironmentProfileReceipt,
20821        NodeBundle,
20822        PathBuf,
20823    ) {
20824        const ROOT_MANIFEST: &[u8] = br#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"review-tools"}"#;
20825        const CLAUDE_MANIFEST: &[u8] =
20826            br#"{"name":"review-tools","version":"1.0.0","description":"Review helpers"}"#;
20827        const SKILL: &[u8] = b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n";
20828        const DIGEST: &str =
20829            "sha256:941f20868a6ef49b4329bf1bb1368515763f5b64d8279b05cd9700966083d707";
20830
20831        let (shared, mut runtime, _claude_receipt, root, _deny) =
20832            materialization_test_shared(false);
20833        let profile_id = SpawnEnvironmentProfileId::new(match home_profile {
20834            CodexHomeProfile::Missing => "codex-home-missing",
20835            CodexHomeProfile::WrongClass => "codex-home-wrong",
20836            CodexHomeProfile::Exact => "codex-home-exact",
20837        })
20838        .unwrap();
20839        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20840            "codex-home-r1",
20841        )
20842        .unwrap();
20843        let materialization = match home_profile {
20844            CodexHomeProfile::Missing => None,
20845            CodexHomeProfile::WrongClass | CodexHomeProfile::Exact => {
20846                Some(NodeSessionMaterializationProfile::new(
20847                    Vec::new(),
20848                    vec![NodeSessionPathBinding::new(
20849                        "CODEX_HOME",
20850                        if matches!(home_profile, CodexHomeProfile::Exact) {
20851                            NodeSessionPathClass::ProviderHome
20852                        } else {
20853                            NodeSessionPathClass::Config
20854                        },
20855                    )
20856                    .unwrap()],
20857                    Vec::new(),
20858                )
20859                .unwrap())
20860            }
20861        };
20862        let node_profile = NodeEnvironmentProfile::new_with_materialization(
20863            profile_id.clone(),
20864            profile_revision.clone(),
20865            agent("codex"),
20866            [NativeLaunchProfile::new(
20867                NativeLaunchProfileId::new(format!("{profile_id}-pty")).unwrap(),
20868                agent("codex"),
20869                TransportKind::Pty,
20870                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20871                Arc::new(EmptyEnvironmentResolver),
20872            )
20873            .unwrap()],
20874            materialization,
20875        )
20876        .unwrap();
20877        let (binding, native_profiles, materialization) = node_profile.into_parts();
20878        for native_profile in native_profiles {
20879            runtime.upsert_native_launch_profile(native_profile).unwrap();
20880        }
20881        shared
20882            .environment_profiles
20883            .write()
20884            .unwrap_or_else(|poisoned| poisoned.into_inner())
20885            .insert(binding.id.clone(), binding);
20886        if let Some(materialization) = materialization {
20887            shared
20888                .environment_materialization_profiles
20889                .write()
20890                .unwrap_or_else(|poisoned| poisoned.into_inner())
20891                .insert(profile_id.clone(), materialization);
20892        }
20893
20894        let bundle_source = root.join(format!("bundle-source-{profile_id}"));
20895        std::fs::create_dir_all(bundle_source.join(".claude-plugin")).unwrap();
20896        std::fs::create_dir_all(bundle_source.join("skills/review-code")).unwrap();
20897        std::fs::write(bundle_source.join("plugin.json"), ROOT_MANIFEST).unwrap();
20898        std::fs::write(
20899            bundle_source.join(".claude-plugin/plugin.json"),
20900            CLAUDE_MANIFEST,
20901        )
20902        .unwrap();
20903        std::fs::write(bundle_source.join("skills/review-code/SKILL.md"), SKILL).unwrap();
20904        crate::bundle_catalog::protect_bundle_source_tree_fixture(&bundle_source).unwrap();
20905        let bundle = NodeBundle::new(
20906            crate::protocol::SpawnBundleId::new("review-tools").unwrap(),
20907            crate::protocol::SpawnBundleRevision::new("review-tools-r1").unwrap(),
20908            crate::protocol::SpawnBundleDigest::new(DIGEST).unwrap(),
20909            &bundle_source,
20910        )
20911        .unwrap();
20912        *shared
20913            .bundle_catalog
20914            .write()
20915            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
20916            BundleCatalog::new([bundle.clone()]).unwrap();
20917        (
20918            shared,
20919            runtime,
20920            ResolvedEnvironmentProfileReceipt {
20921                profile_id,
20922                profile_revision,
20923                network_allowlist: None,
20924                browser_profile_id: None,
20925            },
20926            bundle,
20927            root,
20928        )
20929    }
20930
20931    #[cfg(feature = "fixture")]
20932    #[test]
20933    fn codex_bundle_preflight_rejects_missing_or_wrong_home_before_materialization() {
20934        for home_profile in [CodexHomeProfile::Missing, CodexHomeProfile::WrongClass] {
20935            let (shared, runtime, receipt, bundle, root) =
20936                codex_bundle_test_shared(home_profile);
20937            let error = shared
20938                .bundle_layout(
20939                    &agent("codex"),
20940                    SessionMode::Pty,
20941                    Some(&receipt),
20942                    &bundle,
20943                )
20944                .unwrap_err();
20945            assert_eq!(error.code, NodeFailureCode::BundleBindingMismatch);
20946            assert!(shared
20947                .materializations
20948                .lock()
20949                .unwrap_or_else(|poisoned| poisoned.into_inner())
20950                .is_empty());
20951            let materialization_root = root.join("session-environments");
20952            assert!(!std::fs::read_dir(&materialization_root)
20953                .unwrap()
20954                .filter_map(Result::ok)
20955                .any(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())));
20956            drop(shared);
20957            drop(runtime);
20958            let _ = std::fs::remove_dir_all(root);
20959        }
20960    }
20961
20962    #[cfg(feature = "fixture")]
20963    #[test]
20964    fn codex_bundle_exact_home_profile_materializes_only_the_skill_tree() {
20965        let (shared, runtime, receipt, bundle, root) =
20966            codex_bundle_test_shared(CodexHomeProfile::Exact);
20967        let address = SessionAddress {
20968            workspace_id: WorkspaceId::new("primary").unwrap(),
20969            session: SessionKey {
20970                instance_id: AgentInstanceId(310),
20971                generation: SessionGeneration(1),
20972            },
20973        };
20974        let (overlay, guard) = shared
20975            .prepare_session_materialization(
20976                &address,
20977                &agent("codex"),
20978                SessionMode::Pty,
20979                Some(&receipt),
20980                Some(&bundle.receipt()),
20981                None,
20982                None,
20983            )
20984            .unwrap()
20985            .unwrap();
20986        assert!(matches!(overlay, Some(PreparedNativeLaunchOverlay::Instance(_))));
20987        let ownership = shared
20988            .materializations
20989            .lock()
20990            .unwrap_or_else(|poisoned| poisoned.into_inner())
20991            .get(guard.id().unwrap())
20992            .unwrap()
20993            .clone();
20994        let (profile, _, _) = shared
20995            .materialization_profile(
20996                &agent("codex"),
20997                SessionMode::Pty,
20998                Some(&receipt),
20999                Some(&bundle.receipt()),
21000                None,
21001            )
21002            .unwrap()
21003            .unwrap();
21004        let environment = shared
21005            .session_environment_materializer
21006            .as_ref()
21007            .unwrap()
21008            .resolve_environment(&ownership, &profile)
21009            .unwrap();
21010        assert!(environment.iter().any(|mutation| {
21011            mutation.key == OsString::from("CODEX_HOME")
21012                && mutation.value.as_deref() == Some(ownership.provider_home().as_os_str())
21013        }));
21014        assert_eq!(
21015            std::fs::read(ownership.provider_home().join("skills/review-code/SKILL.md"))
21016                .unwrap(),
21017            b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n",
21018        );
21019        assert!(std::fs::read_dir(ownership.bundle_root())
21020            .unwrap()
21021            .next()
21022            .is_none());
21023        drop(guard);
21024        drop(shared);
21025        drop(runtime);
21026        let _ = std::fs::remove_dir_all(root);
21027    }
21028
21029    #[cfg(feature = "fixture")]
21030    #[test]
21031    fn codex_cold_record_resume_revalidates_the_isolated_skill_tree() {
21032        let (shared, runtime, receipt, bundle, root) =
21033            codex_bundle_test_shared(CodexHomeProfile::Exact);
21034        let bundle_receipt = bundle.receipt();
21035        let address = SessionAddress {
21036            workspace_id: WorkspaceId::new("primary").unwrap(),
21037            session: SessionKey {
21038                instance_id: AgentInstanceId(311),
21039                generation: SessionGeneration(1),
21040            },
21041        };
21042        let (_overlay, guard) = shared
21043            .prepare_session_materialization(
21044                &address,
21045                &agent("codex"),
21046                SessionMode::Pty,
21047                Some(&receipt),
21048                Some(&bundle_receipt),
21049                None,
21050                None,
21051            )
21052            .unwrap()
21053            .unwrap();
21054        let materialization_id = guard.id().unwrap().clone();
21055        let skill_path = shared
21056            .materializations
21057            .lock()
21058            .unwrap_or_else(|poisoned| poisoned.into_inner())
21059            .get(&materialization_id)
21060            .unwrap()
21061            .provider_home()
21062            .join("skills/review-code/SKILL.md");
21063        let record_id = shared
21064            .bind_spawn_session_with_materialization(
21065                &address,
21066                agent("codex"),
21067                SessionMode::Pty,
21068                ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap(),
21069                SpawnRecordPolicy::ProviderIdentityOnly,
21070                Some(receipt.clone()),
21071                Some(bundle_receipt.clone()),
21072                None,
21073                Some(materialization_id.clone()),
21074            )
21075            .unwrap()
21076            .unwrap();
21077        guard.retain();
21078        std::fs::write(skill_path, b"changed-after-record-bind").unwrap();
21079
21080        let error = match shared.resolve_record_materialization(
21081                &record_id,
21082                &agent("codex"),
21083                SessionMode::Pty,
21084                Some(&receipt),
21085                Some(&bundle_receipt),
21086                None,
21087            ) {
21088                Ok(_) => panic!("changed Codex skill tree unexpectedly revalidated"),
21089                Err(error) => error,
21090            };
21091        assert_eq!(error.code, NodeFailureCode::BundleMaterializationFailed);
21092        assert_eq!(
21093            shared
21094                .materializations
21095                .lock()
21096                .unwrap_or_else(|poisoned| poisoned.into_inner())
21097                .get(&materialization_id)
21098                .unwrap()
21099                .state(),
21100            MaterializationState::RecoveryRequired,
21101        );
21102        drop(shared);
21103        drop(runtime);
21104        let _ = std::fs::remove_dir_all(root);
21105    }
21106
21107    fn bind_record_owned_materialization_fixture(
21108        shared: &NodeShared,
21109        receipt: &ResolvedEnvironmentProfileReceipt,
21110        instance_id: u64,
21111    ) -> (SessionAddress, SessionRecordId, MaterializationId, PathBuf) {
21112        let address = SessionAddress {
21113            workspace_id: WorkspaceId::new("primary").unwrap(),
21114            session: SessionKey {
21115                instance_id: AgentInstanceId(instance_id),
21116                generation: SessionGeneration(1),
21117            },
21118        };
21119        let (overlay, materialization_guard) = shared
21120            .prepare_session_materialization(
21121                &address,
21122                &agent("claude"),
21123                SessionMode::Pty,
21124                Some(receipt),
21125                None,
21126                None,
21127                None,
21128            )
21129            .unwrap()
21130            .unwrap();
21131        let materialization_id = materialization_guard.id().unwrap().clone();
21132        let materialization_root = shared
21133            .materializations
21134            .lock()
21135            .unwrap_or_else(|poisoned| poisoned.into_inner())
21136            .get(&materialization_id)
21137            .unwrap()
21138            .root()
21139            .to_path_buf();
21140        let selection = shared
21141            .select_environment_profile(
21142                address.session.instance_id,
21143                &agent("claude"),
21144                SessionMode::Pty,
21145                Some(receipt),
21146            )
21147            .unwrap()
21148            .unwrap();
21149        assert!(shared
21150            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21151            .unwrap()
21152            .is_none());
21153        let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
21154        let record_id = shared
21155            .bind_spawn_session_with_materialization(
21156                &address,
21157                agent("claude"),
21158                SessionMode::Pty,
21159                policy,
21160                SpawnRecordPolicy::ProviderIdentityOnly,
21161                Some(receipt.clone()),
21162                None,
21163                None,
21164                Some(materialization_id.clone()),
21165            )
21166            .unwrap()
21167            .unwrap();
21168        selection.retain();
21169        materialization_guard.retain();
21170        (address, record_id, materialization_id, materialization_root)
21171    }
21172
21173    #[test]
21174    fn child_environment_profile_capability_is_state_aware_and_legacy_safe() {
21175        let (shared, _runtime, environment_profile) = environment_profile_test_shared();
21176        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21177            capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
21178        }));
21179        let support = node_compatibility_support_for_manifest(&[], &[]).unwrap();
21180        assert_eq!(support.state_schema.versions.maximum(), NODE_STATE_SCHEMA_V10);
21181        let mut spec = spawn_spec_fixture();
21182        spec.expected_profile_revision =
21183            crate::protocol::SpawnProfileRevision::new("test-r1").unwrap();
21184        assert!(matches!(
21185            &spec.overrides.environment_profile_id,
21186            crate::protocol::SpawnOverride::Inherit,
21187        ));
21188        assert!(!NodeRequest::SpawnSpec { spec: spec.clone() }
21189            .requires_child_environment_profile_capability());
21190        assert!(request_requires_child_environment_profile(
21191            &shared,
21192            &NodeRequest::SpawnSpec { spec: spec.clone() },
21193        ));
21194
21195        let address = terminal_address(1);
21196        shared.bind_session_with_policy(
21197            &address,
21198            ProviderRuntimePolicy::raw_pty(),
21199            Some(environment_profile.clone()),
21200        );
21201        assert!(request_requires_child_environment_profile(
21202            &shared,
21203            &NodeRequest::Input {
21204                session: address.clone(),
21205                text: "x".to_owned(),
21206            },
21207        ));
21208
21209        let mut record = record("claude", "profile-record");
21210        record.active_session = Some(address.clone());
21211        record.environment_profile = Some(environment_profile.clone());
21212        let record_id = record.record_id.clone();
21213        shared.insert_record(record.clone()).unwrap();
21214        assert!(request_requires_child_environment_profile(
21215            &shared,
21216            &NodeRequest::ResumeSessionRecord {
21217                record_id,
21218                terminal_size: gate4agent_types::TerminalSize {
21219                    rows: 24,
21220                    columns: 80,
21221                },
21222                initial_prompt: None,
21223            },
21224        ));
21225
21226        let snapshot = snapshot_for_wire(
21227            &shared, false, true, true, true, false, false, false, false, true,
21228        );
21229        assert_eq!(snapshot.session_records.len(), 1);
21230        assert!(snapshot.session_records[0].environment_profile.is_none());
21231        let event = project_event_without_child_environment_profile(NodeEventEnvelope {
21232            sequence: 1,
21233            event: NodeEvent::SessionRecordUpserted { record },
21234        });
21235        let NodeEvent::SessionRecordUpserted { record } = event.event else {
21236            panic!("record projection changed the event kind");
21237        };
21238        assert!(record.environment_profile.is_none());
21239
21240        let resolved = shared.resolve_spawn_spec(&spec).unwrap();
21241        let mut reply = ResponseEnvelope {
21242            request_id: 1,
21243            result: Ok(NodeResponse::SpawnSpecAccepted {
21244                receipt: resolved.receipt_with_environment(
21245                    shared.incarnation_id,
21246                    address,
21247                    Some(environment_profile),
21248                ),
21249            }),
21250        };
21251        project_response_without_child_environment_profile(&mut reply);
21252        assert_eq!(
21253            reply.result.unwrap_err().code,
21254            NodeFailureCode::UnsupportedCapability,
21255        );
21256    }
21257
21258    #[test]
21259    fn spawn_profile_environment_authority_is_exact_and_legacy_safe() {
21260        let (shared, _runtime, environment_profile) = environment_profile_test_shared();
21261        let snapshot = shared.snapshot();
21262        let profiles = snapshot
21263            .launch_inventory
21264            .as_ref()
21265            .and_then(|inventory| inventory.spawn_profiles.as_ref())
21266            .unwrap();
21267        assert_eq!(profiles.len(), 1);
21268        assert_eq!(profiles[0].environment_profile, Some(environment_profile));
21269        assert!(snapshot.requires_child_environment_profile_capability());
21270
21271        let mut legacy = snapshot.clone();
21272        clear_snapshot_child_environment_profiles(&mut legacy);
21273        assert!(legacy
21274            .launch_inventory
21275            .as_ref()
21276            .unwrap()
21277            .spawn_profiles
21278            .as_ref()
21279            .unwrap()[0]
21280            .environment_profile
21281            .is_none());
21282        assert!(!legacy.requires_child_environment_profile_capability());
21283
21284        let profile = shared.spawn_profiles.iter().next().unwrap().clone();
21285        let bindings = shared
21286            .environment_profiles
21287            .read()
21288            .unwrap_or_else(|poisoned| poisoned.into_inner())
21289            .clone();
21290        let mut provider_mismatch = bindings.clone();
21291        provider_mismatch
21292            .values_mut()
21293            .next()
21294            .unwrap()
21295            .provider = agent("codex");
21296        assert!(resolved_spawn_profile_summary(&profile, &provider_mismatch).is_none());
21297        let mut mode_mismatch = profile;
21298        mode_mismatch.mode = SessionMode::Inline;
21299        assert!(resolved_spawn_profile_summary(&mode_mismatch, &bindings).is_none());
21300
21301        shared
21302            .environment_profiles
21303            .write()
21304            .unwrap_or_else(|poisoned| poisoned.into_inner())
21305            .clear();
21306        assert!(shared
21307            .snapshot()
21308            .launch_inventory
21309            .unwrap()
21310            .spawn_profiles
21311            .unwrap()
21312            .is_empty());
21313    }
21314
21315    #[test]
21316    fn session_bundle_capability_is_state_aware_and_legacy_safe() {
21317        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21318            capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
21319        }));
21320        let shared = terminal_test_shared();
21321        let bundle = ResolvedBundleReceipt {
21322            id: crate::protocol::SpawnBundleId::new("review-bundle").unwrap(),
21323            revision: crate::protocol::SpawnBundleRevision::new("r1").unwrap(),
21324            digest: crate::protocol::SpawnBundleDigest::new(format!(
21325                "sha256:{}",
21326                "0".repeat(64),
21327            ))
21328            .unwrap(),
21329        };
21330        let mut bundled = record("claude", "bundle-record");
21331        bundled.bundle = Some(bundle.clone());
21332        let record_id = bundled.record_id.clone();
21333        shared.insert_record(bundled.clone()).unwrap();
21334        assert!(request_requires_session_bundle(
21335            &shared,
21336            &NodeRequest::ForgetSessionRecord {
21337                record_id: record_id.clone(),
21338            },
21339        ));
21340
21341        let snapshot = snapshot_for_wire(
21342            &shared, false, true, true, true, true, false, false, false, true,
21343        );
21344        assert!(snapshot.session_records[0].bundle.is_none());
21345        let launch = snapshot.launch_inventory.as_ref().unwrap();
21346        assert!(launch.spawn_profiles.is_some());
21347        assert!(launch.bundles.is_none());
21348        let event = project_event_without_session_bundle(NodeEventEnvelope {
21349            sequence: 1,
21350            event: NodeEvent::SessionRecordUpserted { record: bundled },
21351        });
21352        let NodeEvent::SessionRecordUpserted { record } = event.event else {
21353            panic!("record projection changed the event kind");
21354        };
21355        assert!(record.bundle.is_none());
21356
21357        let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
21358        let mut reply = ResponseEnvelope {
21359            request_id: 1,
21360            result: Ok(NodeResponse::SpawnSpecAccepted {
21361                receipt: resolved.receipt_with_materialization(
21362                    shared.incarnation_id,
21363                    terminal_address(17),
21364                    None,
21365                    Some(bundle),
21366                    None,
21367                ),
21368            }),
21369        };
21370        project_response_without_session_bundle(&mut reply);
21371        assert_eq!(
21372            reply.result.unwrap_err().code,
21373            NodeFailureCode::UnsupportedCapability,
21374        );
21375    }
21376
21377    #[test]
21378    fn history_context_pack_capability_redacts_loaded_messages_and_legacy_metadata() {
21379        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21380            capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
21381        }));
21382        let context_id = SpawnContextId::new("context-review").unwrap();
21383        let context = ResolvedContextPackReceipt {
21384            id: context_id.clone(),
21385            digest: crate::protocol::SpawnContextDigest::new(format!(
21386                "sha256:{}",
21387                "c".repeat(64),
21388            ))
21389            .unwrap(),
21390            lineage: ContextPackLineageReceipt {
21391                source_node_id: NodeId::new("node-source").unwrap(),
21392                source_session: terminal_address(1),
21393                source_provider: agent("kimi"),
21394            },
21395            source_message_count: 2,
21396            retained_message_count: 2,
21397            byte_len: 256,
21398            truncated: false,
21399        };
21400        assert!(context.is_valid());
21401        let mut bound_record = record("codex", "context-record");
21402        bound_record.context_id = Some(context_id);
21403        bound_record.context = Some(context.clone());
21404        let session = gate4agent_types::SessionSnapshot {
21405            instance_id: AgentInstanceId(91),
21406            agent_id: agent("kimi"),
21407            transport: TransportKind::Pty,
21408            generation: SessionGeneration(3),
21409            status: SessionStatus::Running,
21410            pending_operation: None,
21411            pending_input: None,
21412            process_id: Some(9001),
21413            terminal_size: None,
21414            terminal_frame: None,
21415            terminal_stale: None,
21416            session_options: None,
21417            capabilities: gate4agent_types::CapabilitySnapshot::default(),
21418            history: gate4agent_types::HistorySnapshot {
21419                pending: None,
21420                candidates: vec![HistoryCandidateSummary {
21421                    id: "candidate-opaque".to_owned(),
21422                    session_id_hint: "session-hint".to_owned(),
21423                    modified_at_unix_ms: Some(1),
21424                }],
21425                loaded_candidate_id: Some("candidate-opaque".to_owned()),
21426                loaded: Some(HistorySessionRecord {
21427                    session_id: "private-vendor-session".to_owned(),
21428                    title: Some("private title".to_owned()),
21429                    cwd: Some(r"C:\private\history-root".to_owned()),
21430                    model: Some("private-model".to_owned()),
21431                    message_count: 2,
21432                    completed_turn_count: None,
21433                    total_tokens: 19,
21434                    messages: vec![
21435                        gate4agent_types::HistoryMessageRecord {
21436                            role: gate4agent_types::HistoryMessageRole::User,
21437                            text: "F7_PRIVATE_HISTORY_MESSAGE".to_owned(),
21438                        },
21439                        gate4agent_types::HistoryMessageRecord {
21440                            role: gate4agent_types::HistoryMessageRole::Assistant,
21441                            text: "private answer".to_owned(),
21442                        },
21443                    ],
21444                }),
21445                last_error: None,
21446            },
21447            resume: gate4agent_types::ResumeSnapshot::default(),
21448            foreground: gate4agent_types::ForegroundSnapshot::default(),
21449            provider: gate4agent_types::ProviderSnapshot::default(),
21450            screen_state: gate4agent_types::PtyScreenState::default(),
21451        };
21452        let original = NodeSnapshot {
21453            node_id: NodeId::new("node-terminal-test").unwrap(),
21454            enabled_providers: vec![agent("kimi"), agent("codex")],
21455            provider_runtime_statuses: ProviderRuntimeStatuses::default(),
21456            workspaces: vec![WorkspaceSnapshot {
21457                workspace_id: WorkspaceId::new("primary").unwrap(),
21458                canonical_root: opaque_windows_path(
21459                    std::env::current_dir().unwrap().to_string_lossy().into_owned(),
21460                ),
21461                sessions: vec![session],
21462                worktree_service_mode: None,
21463                managed_worktree_profiles: None,
21464            }],
21465            session_records: vec![bound_record],
21466            managed_worktrees: Vec::new(),
21467            launch_inventory: None,
21468            agent_progress: Vec::new(),
21469        };
21470
21471        let mut negotiated = original.clone();
21472        project_snapshot_history_for_wire(&mut negotiated, true);
21473        assert_eq!(negotiated.workspaces[0].sessions[0].history.candidates.len(), 1);
21474        assert!(negotiated.workspaces[0].sessions[0].history.loaded.is_none());
21475        assert_eq!(negotiated.session_records[0].context.as_ref(), Some(&context));
21476        assert!(!serde_json::to_string(&negotiated)
21477            .unwrap()
21478            .contains("F7_PRIVATE_HISTORY_MESSAGE"));
21479
21480        let mut legacy = original;
21481        project_snapshot_history_for_wire(&mut legacy, false);
21482        clear_snapshot_context_packs(&mut legacy);
21483        assert_eq!(
21484            legacy.workspaces[0].sessions[0].history,
21485            gate4agent_types::HistorySnapshot::default(),
21486        );
21487        assert!(legacy.session_records[0].context_id.is_none());
21488        assert!(legacy.session_records[0].context.is_none());
21489        let encoded = serde_json::to_string(&legacy).unwrap();
21490        assert!(!encoded.contains("F7_PRIVATE_HISTORY_MESSAGE"));
21491        assert!(!encoded.contains("context-review"));
21492
21493        let mut reply = ResponseEnvelope {
21494            request_id: 7,
21495            result: Ok(NodeResponse::ContextPackExported { context }),
21496        };
21497        project_response_without_context_pack(&mut reply);
21498        assert_eq!(
21499            reply.result.unwrap_err().code,
21500            NodeFailureCode::UnsupportedCapability,
21501        );
21502    }
21503
21504    #[test]
21505    fn forgetting_unbound_context_pack_evicts_replay_but_retained_record_is_busy() {
21506        let shared = terminal_test_shared();
21507        let history = HistorySessionRecord {
21508            session_id: "fixture-history".to_owned(),
21509            title: None,
21510            cwd: None,
21511            model: None,
21512            message_count: 1,
21513            completed_turn_count: None,
21514            total_tokens: 0,
21515            messages: vec![gate4agent_types::HistoryMessageRecord {
21516                role: gate4agent_types::HistoryMessageRole::User,
21517                text: "bounded handoff".to_owned(),
21518            }],
21519        };
21520        let pack = NodeContextPack::export(
21521            ContextPackLineageReceipt {
21522                source_node_id: shared.node_id.clone(),
21523                source_session: terminal_address(1),
21524                source_provider: agent("claude"),
21525            },
21526            &history,
21527        )
21528        .unwrap();
21529        let context = shared
21530            .context_catalog
21531            .write()
21532            .unwrap_or_else(|poisoned| poisoned.into_inner())
21533            .insert(pack)
21534            .unwrap();
21535        let mut retained = record("codex", "context-retained-record");
21536        retained.context_id = Some(context.id.clone());
21537        retained.context = Some(context.clone());
21538        let record_id = retained.record_id.clone();
21539        shared.insert_record(retained).unwrap();
21540        assert_eq!(
21541            shared.forget_context_pack(&context.id).unwrap_err().code,
21542            NodeFailureCode::ContextPackBusy,
21543        );
21544        assert!(shared
21545            .context_catalog
21546            .read()
21547            .unwrap_or_else(|poisoned| poisoned.into_inner())
21548            .get(&context.id)
21549            .is_some());
21550        shared
21551            .session_records
21552            .lock()
21553            .unwrap_or_else(|poisoned| poisoned.into_inner())
21554            .records
21555            .remove(&record_id);
21556
21557        let mut spec = spawn_spec_fixture();
21558        spec.overrides.context_id = crate::protocol::SpawnOverride::Set {
21559            value: context.id.clone(),
21560        };
21561        let resolved = shared.resolve_spawn_spec(&spec).unwrap();
21562        let receipt = resolved.receipt_with_materialization(
21563            shared.incarnation_id,
21564            terminal_address(2),
21565            None,
21566            None,
21567            Some(context.clone()),
21568        );
21569        assert!(receipt.context_binding_is_valid());
21570        shared.remember_spawn_spec(spec.clone(), Ok(receipt), Instant::now());
21571
21572        shared.forget_context_pack(&context.id).unwrap();
21573        assert!(shared
21574            .context_catalog
21575            .read()
21576            .unwrap_or_else(|poisoned| poisoned.into_inner())
21577            .get(&context.id)
21578            .is_none());
21579        assert!(shared
21580            .replay_spawn_spec(&spec, Instant::now())
21581            .unwrap()
21582            .is_none());
21583    }
21584
21585    #[test]
21586    fn environment_profile_selection_is_owned_by_the_exact_session_binding() {
21587        let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
21588        let address = terminal_address(1);
21589        let selection = shared
21590            .select_environment_profile(
21591                address.session.instance_id,
21592                &agent("claude"),
21593                SessionMode::Pty,
21594                Some(&environment_profile),
21595            )
21596            .unwrap()
21597            .unwrap();
21598        shared.bind_session_with_policy(
21599            &address,
21600            ProviderRuntimePolicy::raw_pty(),
21601            Some(environment_profile.clone()),
21602        );
21603        selection.retain();
21604        let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
21605        assert!(matches!(
21606            runtime.remove_native_launch_profile(&native_id),
21607            Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
21608        ));
21609        assert!(shared.remove_binding(&address).is_some());
21610        assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
21611
21612        let mismatched = ResolvedEnvironmentProfileReceipt {
21613            profile_id: environment_profile.profile_id,
21614            profile_revision: crate::protocol::SpawnEnvironmentProfileRevision::new(
21615                "local-claude-r2",
21616            )
21617            .unwrap(),
21618            network_allowlist: None,
21619            browser_profile_id: None,
21620        };
21621        let error = match shared.select_environment_profile(
21622                AgentInstanceId(42),
21623                &agent("claude"),
21624                SessionMode::Pty,
21625                Some(&mismatched),
21626            ) {
21627            Ok(_) => panic!("mismatched environment profile revision was selected"),
21628            Err(error) => error,
21629        };
21630        assert_eq!(
21631            error.code,
21632            NodeFailureCode::EnvironmentProfileBindingMismatch,
21633        );
21634        assert!(!shared
21635            .session_bindings
21636            .lock()
21637            .unwrap_or_else(|poisoned| poisoned.into_inner())
21638            .contains_key(&AgentInstanceId(42)));
21639    }
21640
21641    #[test]
21642    fn combined_environment_and_bundle_overlay_clears_with_its_binding() {
21643        let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
21644        let address = terminal_address(1);
21645        let selection = shared
21646            .select_environment_profile(
21647                address.session.instance_id,
21648                &agent("claude"),
21649                SessionMode::Pty,
21650                Some(&environment_profile),
21651            )
21652            .unwrap()
21653            .unwrap();
21654        let overlay = NativeInstanceLaunchOverlay::new(
21655            agent("claude"),
21656            TransportKind::Pty,
21657            vec![EnvMutation {
21658                key: OsString::from("GATE4AGENT_SESSION_TOKEN"),
21659                value: Some(OsString::from("fixture-secret")),
21660            }],
21661            vec![
21662                OsString::from("--plugin-dir"),
21663                std::env::current_dir().unwrap().into_os_string(),
21664            ],
21665        )
21666        .unwrap();
21667        let instance_overlay = shared
21668            .install_prepared_launch_overlay(
21669                address.session.instance_id,
21670                PreparedNativeLaunchOverlay::Instance(overlay),
21671            )
21672            .unwrap()
21673            .unwrap();
21674        let bundle = ResolvedBundleReceipt {
21675            id: crate::protocol::SpawnBundleId::new("combined-bundle").unwrap(),
21676            revision: crate::protocol::SpawnBundleRevision::new("combined-r1").unwrap(),
21677            digest: crate::protocol::SpawnBundleDigest::new(format!(
21678                "sha256:{}",
21679                "0".repeat(64),
21680            ))
21681            .unwrap(),
21682        };
21683        shared.bind_session_with_materialization(
21684            &address,
21685            ProviderRuntimePolicy::raw_pty(),
21686            Some(environment_profile),
21687            Some(bundle),
21688            None,
21689            None,
21690        );
21691        selection.retain();
21692        instance_overlay.retain();
21693
21694        let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
21695        assert!(matches!(
21696            runtime.remove_native_launch_profile(&native_id),
21697            Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
21698        ));
21699        assert!(shared.remove_binding(&address).is_some());
21700        assert!(!shared
21701            .native_launch_profile_control
21702            .as_ref()
21703            .unwrap()
21704            .clear_native_instance_launch_overlay(address.session.instance_id));
21705        assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
21706    }
21707
21708    #[test]
21709    fn session_environment_materialization_failure_is_pre_child_and_durable() {
21710        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
21711        let address = SessionAddress {
21712            workspace_id: WorkspaceId::new("primary").unwrap(),
21713            session: SessionKey {
21714                instance_id: AgentInstanceId(201),
21715                generation: SessionGeneration(1),
21716            },
21717        };
21718        let error = match shared.prepare_session_materialization(
21719            &address,
21720            &agent("claude"),
21721            SessionMode::Pty,
21722            Some(&receipt),
21723            None,
21724            None,
21725            None,
21726        ) {
21727            Ok(_) => panic!("denied local reference unexpectedly materialized"),
21728            Err(error) => error,
21729        };
21730        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21731        assert!(shared.handle.snapshot().sessions.is_empty());
21732        assert!(shared
21733            .session_bindings
21734            .lock()
21735            .unwrap_or_else(|poisoned| poisoned.into_inner())
21736            .is_empty());
21737        assert!(shared
21738            .materializations
21739            .lock()
21740            .unwrap_or_else(|poisoned| poisoned.into_inner())
21741            .is_empty());
21742        drop(shared);
21743        drop(runtime);
21744        std::fs::remove_dir_all(root).unwrap();
21745    }
21746
21747    #[test]
21748    fn session_environment_binding_retains_on_stop_and_cleans_after_remove_reap() {
21749        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21750        let address = SessionAddress {
21751            workspace_id: WorkspaceId::new("primary").unwrap(),
21752            session: SessionKey {
21753                instance_id: AgentInstanceId(202),
21754                generation: SessionGeneration(1),
21755            },
21756        };
21757        let (overlay, materialization_guard) = shared
21758            .prepare_session_materialization(
21759                &address,
21760                &agent("claude"),
21761                SessionMode::Pty,
21762                Some(&receipt),
21763                None,
21764                None,
21765                None,
21766            )
21767            .unwrap()
21768            .unwrap();
21769        let materialization_id = materialization_guard.id().unwrap().clone();
21770        let materialization_root = shared
21771            .materializations
21772            .lock()
21773            .unwrap_or_else(|poisoned| poisoned.into_inner())
21774            .get(&materialization_id)
21775            .unwrap()
21776            .root()
21777            .to_path_buf();
21778        let selection = shared
21779            .select_environment_profile(
21780                address.session.instance_id,
21781                &agent("claude"),
21782                SessionMode::Pty,
21783                Some(&receipt),
21784            )
21785            .unwrap()
21786            .unwrap();
21787        assert!(shared
21788            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21789            .unwrap()
21790            .is_none());
21791        shared.bind_session_with_materialization(
21792            &address,
21793            ProviderRuntimePolicy::raw_pty(),
21794            Some(receipt),
21795            None,
21796            None,
21797            Some(materialization_id),
21798        );
21799        selection.retain();
21800        materialization_guard.retain();
21801
21802        assert!(materialization_root.is_dir());
21803        assert!(shared
21804            .session_bindings
21805            .lock()
21806            .unwrap_or_else(|poisoned| poisoned.into_inner())
21807            .contains_key(&address.session.instance_id));
21808
21809        let removed = shared.remove_binding(&address).unwrap();
21810        shared
21811            .cleanup_session_owned_materialization(&address, &removed)
21812            .unwrap();
21813        assert!(!materialization_root.exists());
21814        assert!(shared
21815            .materializations
21816            .lock()
21817            .unwrap_or_else(|poisoned| poisoned.into_inner())
21818            .is_empty());
21819        drop(shared);
21820        drop(runtime);
21821        std::fs::remove_dir_all(root).unwrap();
21822    }
21823
21824    #[tokio::test]
21825    async fn record_owned_session_environment_reuses_on_resume_and_forget_cleans() {
21826        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21827        let address = SessionAddress {
21828            workspace_id: WorkspaceId::new("primary").unwrap(),
21829            session: SessionKey {
21830                instance_id: AgentInstanceId(203),
21831                generation: SessionGeneration(1),
21832            },
21833        };
21834        let (overlay, materialization_guard) = shared
21835            .prepare_session_materialization(
21836                &address,
21837                &agent("claude"),
21838                SessionMode::Pty,
21839                Some(&receipt),
21840                None,
21841                None,
21842                None,
21843            )
21844            .unwrap()
21845            .unwrap();
21846        let materialization_id = materialization_guard.id().unwrap().clone();
21847        let materialization_root = shared
21848            .materializations
21849            .lock()
21850            .unwrap_or_else(|poisoned| poisoned.into_inner())
21851            .get(&materialization_id)
21852            .unwrap()
21853            .root()
21854            .to_path_buf();
21855        let selection = shared
21856            .select_environment_profile(
21857                address.session.instance_id,
21858                &agent("claude"),
21859                SessionMode::Pty,
21860                Some(&receipt),
21861            )
21862            .unwrap()
21863            .unwrap();
21864        assert!(shared
21865            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21866            .unwrap()
21867            .is_none());
21868        let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
21869        let record_id = shared
21870            .bind_spawn_session_with_materialization(
21871                &address,
21872                agent("claude"),
21873                SessionMode::Pty,
21874                policy,
21875                SpawnRecordPolicy::ProviderIdentityOnly,
21876                Some(receipt.clone()),
21877                None,
21878                None,
21879                Some(materialization_id.clone()),
21880            )
21881            .unwrap()
21882            .unwrap();
21883        selection.retain();
21884        materialization_guard.retain();
21885        assert_eq!(
21886            shared
21887                .materializations
21888                .lock()
21889                .unwrap_or_else(|poisoned| poisoned.into_inner())
21890                .get(&materialization_id)
21891                .unwrap()
21892                .owner(),
21893            &MaterializationOwner::Record {
21894                record_id: record_id.clone(),
21895            },
21896        );
21897
21898        let removed = shared.remove_binding(&address).unwrap();
21899        shared
21900            .cleanup_session_owned_materialization(&address, &removed)
21901            .unwrap();
21902        assert!(materialization_root.is_dir());
21903        assert!(shared
21904            .resolve_record_materialization(
21905                &record_id,
21906                &agent("claude"),
21907                SessionMode::Pty,
21908                Some(&receipt),
21909                None,
21910                None,
21911            )
21912            .unwrap()
21913            .is_some());
21914        {
21915            let mut records = shared
21916                .session_records
21917                .lock()
21918                .unwrap_or_else(|poisoned| poisoned.into_inner());
21919            let record = records.records.get_mut(&record_id).unwrap();
21920            record.active_session = None;
21921            record.state = ManagedSessionState::Unavailable;
21922        }
21923        shared.forget_session_record(&record_id).await.unwrap();
21924        assert!(!materialization_root.exists());
21925        assert!(shared
21926            .materializations
21927            .lock()
21928            .unwrap_or_else(|poisoned| poisoned.into_inner())
21929            .is_empty());
21930        drop(shared);
21931        drop(runtime);
21932        std::fs::remove_dir_all(root).unwrap();
21933    }
21934
21935    #[test]
21936    fn record_materialization_revalidation_failure_persists_recovery_required() {
21937        let (mut shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21938        let state_path = root.join("node-state-v6.json");
21939        shared.state_path = Some(state_path.clone());
21940        let (_address, _record_id, materialization_id, materialization_root) =
21941            bind_record_owned_materialization_fixture(&shared, &receipt, 205);
21942        std::fs::write(
21943            materialization_root.join(".gate4agent-materialization-owner"),
21944            b"tampered",
21945        )
21946        .unwrap();
21947
21948        let error = match shared.resolve_record_materialization(
21949            &_record_id,
21950            &agent("claude"),
21951            SessionMode::Pty,
21952            Some(&receipt),
21953            None,
21954            None,
21955        ) {
21956            Ok(_) => panic!("tampered owner marker unexpectedly revalidated"),
21957            Err(error) => error,
21958        };
21959        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21960        assert_eq!(
21961            shared
21962                .materializations
21963                .lock()
21964                .unwrap_or_else(|poisoned| poisoned.into_inner())
21965                .get(&materialization_id)
21966                .unwrap()
21967                .state(),
21968            MaterializationState::RecoveryRequired,
21969        );
21970        let loaded = session_registry::load(Some(&state_path), &shared.node_id).unwrap();
21971        assert_eq!(loaded.materializations.len(), 1);
21972        assert_eq!(
21973            loaded.materializations[0].state(),
21974            MaterializationState::RecoveryRequired,
21975        );
21976        drop(shared);
21977        drop(runtime);
21978        std::fs::remove_dir_all(root).unwrap();
21979    }
21980
21981    #[test]
21982    fn transient_record_materialization_resolution_failure_remains_ready() {
21983        let (shared, runtime, receipt, root, deny) = materialization_test_shared(false);
21984        let (_address, record_id, materialization_id, _materialization_root) =
21985            bind_record_owned_materialization_fixture(&shared, &receipt, 206);
21986        deny.store(true, Ordering::Release);
21987
21988        let error = match shared.resolve_record_materialization(
21989            &record_id,
21990            &agent("claude"),
21991            SessionMode::Pty,
21992            Some(&receipt),
21993            None,
21994            None,
21995        ) {
21996            Ok(_) => panic!("denied transient resolver unexpectedly succeeded"),
21997            Err(error) => error,
21998        };
21999        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
22000        assert_eq!(
22001            shared
22002                .materializations
22003                .lock()
22004                .unwrap_or_else(|poisoned| poisoned.into_inner())
22005                .get(&materialization_id)
22006                .unwrap()
22007                .state(),
22008            MaterializationState::Ready,
22009        );
22010        drop(shared);
22011        drop(runtime);
22012        std::fs::remove_dir_all(root).unwrap();
22013    }
22014
22015    #[test]
22016    fn startup_missing_record_materialization_is_not_resumable() {
22017        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
22018        let record_id = SessionRecordId::new("sr-missing-materialization").unwrap();
22019        shared
22020            .insert_record(ManagedSessionRecord {
22021                record_id: record_id.clone(),
22022                display_name: "missing materialization".to_owned(),
22023                provider: agent("claude"),
22024                mode: SessionMode::Pty,
22025                state: ManagedSessionState::Dormant,
22026                workspace_id: WorkspaceId::new("primary").unwrap(),
22027                canonical_root: opaque_windows_path(
22028                    std::env::current_dir().unwrap().to_string_lossy().into_owned(),
22029                ),
22030                provider_session: Some(gate4agent_types::ProviderSessionIdentity {
22031                    key: gate4agent_types::ProviderSessionKey::SessionId,
22032                    id: "provider-session-missing-materialization".to_owned(),
22033                    transcript_path: None,
22034                }),
22035                active_session: None,
22036                environment_profile: Some(receipt),
22037                bundle: None,
22038                context_id: None,
22039                context: None,
22040                exported_context: None,
22041                task_binding: None,
22042                created_at_unix_ms: 1,
22043                updated_at_unix_ms: 1,
22044                last_error: None,
22045            })
22046            .unwrap();
22047
22048        shared.reconcile_materializations();
22049        let record = shared.record(&record_id).unwrap();
22050        assert_eq!(record.state, ManagedSessionState::Unavailable);
22051        assert!(record.provider_session.is_none());
22052        assert_eq!(
22053            record.last_error.as_deref(),
22054            Some("environment-profile-unavailable"),
22055        );
22056        drop(shared);
22057        drop(runtime);
22058        std::fs::remove_dir_all(root).unwrap();
22059    }
22060
22061    #[tokio::test]
22062    async fn managed_worktree_cleanup_waits_for_session_environment_cleanup() {
22063        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
22064        let lease = install_managed_lease(&shared);
22065        let address = SessionAddress {
22066            workspace_id: WorkspaceId::new("primary").unwrap(),
22067            session: SessionKey {
22068                instance_id: AgentInstanceId(204),
22069                generation: SessionGeneration(1),
22070            },
22071        };
22072        let (overlay, materialization_guard) = shared
22073            .prepare_session_materialization(
22074                &address,
22075                &agent("claude"),
22076                SessionMode::Pty,
22077                Some(&receipt),
22078                None,
22079                None,
22080                Some(lease.lease_id.clone()),
22081            )
22082            .unwrap()
22083            .unwrap();
22084        let materialization_id = materialization_guard.id().unwrap().clone();
22085        let materialization_root = shared
22086            .materializations
22087            .lock()
22088            .unwrap_or_else(|poisoned| poisoned.into_inner())
22089            .get(&materialization_id)
22090            .unwrap()
22091            .root()
22092            .to_path_buf();
22093        let selection = shared
22094            .select_environment_profile(
22095                address.session.instance_id,
22096                &agent("claude"),
22097                SessionMode::Pty,
22098                Some(&receipt),
22099            )
22100            .unwrap()
22101            .unwrap();
22102        assert!(shared
22103            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
22104            .unwrap()
22105            .is_none());
22106        shared.bind_session_with_materialization(
22107            &address,
22108            ProviderRuntimePolicy::raw_pty(),
22109            Some(receipt),
22110            None,
22111            None,
22112            Some(materialization_id),
22113        );
22114        selection.retain();
22115        materialization_guard.retain();
22116
22117        let blocked = shared
22118            .cleanup_managed_worktree(&lease.lease_id, false)
22119            .await
22120            .unwrap_err();
22121        assert_eq!(blocked.code, NodeFailureCode::ManagedWorktreeRecoveryRequired);
22122        assert!(materialization_root.is_dir());
22123        let removed = shared.remove_binding(&address).unwrap();
22124        shared
22125            .cleanup_session_owned_materialization(&address, &removed)
22126            .unwrap();
22127        assert!(!materialization_root.exists());
22128        drop(shared);
22129        drop(runtime);
22130        std::fs::remove_dir_all(root).unwrap();
22131    }
22132
22133    #[tokio::test]
22134    async fn raw_managed_record_is_inventory_only_and_does_not_block_session_cleanup() {
22135        let git_root = temporary_workspace_root("raw-managed-inventory");
22136        let source = git_root.join("source");
22137        let allocation = git_root.join("allocation");
22138        std::fs::create_dir_all(&source).unwrap();
22139        std::fs::create_dir_all(&allocation).unwrap();
22140        run_test_git(&source, &["init"]);
22141        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
22142        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
22143        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
22144        run_test_git(&source, &["add", "seed.txt"]);
22145        run_test_git(&source, &["commit", "-m", "seed"]);
22146        let profile = ManagedWorktreeProfile::new(
22147            WorktreeProfileId::new("default").unwrap(),
22148            crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
22149            &allocation,
22150            "gate4agent",
22151            "HEAD",
22152            ManagedWorktreeRetention::RemoveWhenReleased,
22153        )
22154        .unwrap();
22155        let workspace = WorkspaceConfig::new(
22156            WorkspaceId::new("primary").unwrap(),
22157            &source,
22158        )
22159        .unwrap()
22160        .with_worktree_service_mode(WorktreeServiceMode::Managed)
22161        .with_managed_worktree_profile(profile.clone())
22162        .unwrap();
22163        let (shared, mut runtime, receipt, materialization_fixture_root, _deny) =
22164            materialization_test_shared_with_workspace(false, workspace);
22165        let target = PathBuf::from(profile.allocation_root()).join("mw-raw-inventory");
22166        let source_text = source.to_string_lossy().into_owned();
22167        let target_text = target.to_string_lossy().into_owned();
22168        let created = create_git_worktree(
22169            &source_text,
22170            &target_text,
22171            "gate4agent/mw-raw-inventory",
22172            Some("HEAD"),
22173        )
22174        .await
22175        .unwrap();
22176        let lease = ManagedWorktreeLeaseRecord {
22177            lease_id: ManagedWorktreeLeaseId::new("mw-raw-inventory").unwrap(),
22178            source_workspace_id: WorkspaceId::new("primary").unwrap(),
22179            workspace_id: WorkspaceId::new("managed-raw-inventory").unwrap(),
22180            profile_id: profile.profile_id().clone(),
22181            profile_revision: profile.revision().clone(),
22182            target_root: created.path.clone(),
22183            branch: "gate4agent/mw-raw-inventory".to_owned(),
22184            base_commit: created.head.clone(),
22185            expected_head: Some(created.head.clone()),
22186            retention: ManagedWorktreeRetention::RemoveWhenReleased,
22187            state: ManagedWorktreeLeaseState::Ready,
22188            session_holders: Vec::new(),
22189            record_holders: Vec::new(),
22190            cleanup_failure: None,
22191            created_at_unix_ms: 1,
22192            updated_at_unix_ms: 1,
22193        };
22194        shared
22195            .workspaces
22196            .write()
22197            .unwrap_or_else(|poisoned| poisoned.into_inner())
22198            .insert(lease.workspace_id.clone(), created.path.clone());
22199        *shared
22200            .managed_worktrees
22201            .lock()
22202            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22203            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
22204        let address = SessionAddress {
22205            workspace_id: lease.workspace_id.clone(),
22206            session: SessionKey {
22207                instance_id: AgentInstanceId(205),
22208                generation: SessionGeneration(1),
22209            },
22210        };
22211        let (overlay, materialization_guard) = shared
22212            .prepare_session_materialization(
22213                &address,
22214                &agent("claude"),
22215                SessionMode::Pty,
22216                Some(&receipt),
22217                None,
22218                None,
22219                Some(lease.lease_id.clone()),
22220            )
22221            .unwrap()
22222            .unwrap();
22223        let materialization_id = materialization_guard.id().unwrap().clone();
22224        let materialization_root = shared
22225            .materializations
22226            .lock()
22227            .unwrap_or_else(|poisoned| poisoned.into_inner())
22228            .get(&materialization_id)
22229            .unwrap()
22230            .root()
22231            .to_path_buf();
22232        let selection = shared
22233            .select_environment_profile(
22234                address.session.instance_id,
22235                &agent("claude"),
22236                SessionMode::Pty,
22237                Some(&receipt),
22238            )
22239            .unwrap()
22240            .unwrap();
22241        assert!(shared
22242            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
22243            .unwrap()
22244            .is_none());
22245        let record_id = shared
22246            .bind_spawn_session_with_materialization(
22247                &address,
22248                agent("claude"),
22249                SessionMode::Pty,
22250                ProviderRuntimePolicy::raw_pty(),
22251                SpawnRecordPolicy::Always,
22252                Some(receipt.clone()),
22253                None,
22254                None,
22255                Some(materialization_id.clone()),
22256            )
22257            .unwrap()
22258            .unwrap();
22259        selection.retain();
22260        materialization_guard.retain();
22261        let record = shared.record(&record_id).unwrap();
22262        assert_eq!(record.state, ManagedSessionState::Live);
22263        assert!(record.provider_session.is_none());
22264        assert!(record.environment_profile.is_none());
22265        assert!(record.bundle.is_none());
22266        assert!(record.context_id.is_none());
22267        assert!(record.context.is_none());
22268        assert_eq!(record.active_session.as_ref(), Some(&address));
22269        assert_eq!(
22270            shared
22271                .materializations
22272                .lock()
22273                .unwrap_or_else(|poisoned| poisoned.into_inner())
22274                .get(&materialization_id)
22275                .unwrap()
22276                .owner(),
22277            &MaterializationOwner::Session {
22278                incarnation_id: shared.incarnation_id,
22279                instance_id: address.session.instance_id,
22280                generation: address.session.generation,
22281            },
22282        );
22283        {
22284            let mut bindings = shared
22285                .session_bindings
22286                .lock()
22287                .unwrap_or_else(|poisoned| poisoned.into_inner());
22288            bindings
22289                .get_mut(&address.session.instance_id)
22290                .unwrap()
22291                .managed_worktree_lease_id = Some(lease.lease_id.clone());
22292        }
22293        let lease_snapshot = shared
22294            .managed_worktrees
22295            .lock()
22296            .unwrap_or_else(|poisoned| poisoned.into_inner())
22297            .bind_session(
22298                &lease.lease_id,
22299                ManagedWorktreeSessionHolder {
22300                    incarnation_id: shared.incarnation_id,
22301                    instance_id: address.session.instance_id,
22302                    generation: address.session.generation,
22303                },
22304                None,
22305                unix_time_ms(),
22306            )
22307            .unwrap();
22308        assert_eq!(lease_snapshot.active_session_count, 1);
22309        assert_eq!(lease_snapshot.managed_record_count, 0);
22310        shared.persist_state().unwrap();
22311        assert!(shared
22312            .managed_worktrees
22313            .lock()
22314            .unwrap_or_else(|poisoned| poisoned.into_inner())
22315            .get(&lease.lease_id)
22316            .unwrap()
22317            .record_holders
22318            .is_empty());
22319        shared
22320            .handle
22321            .dispatch(shared.prepare_command(ControlCommand::Register {
22322                instance_id: address.session.instance_id,
22323                agent_id: agent("claude"),
22324                transport: TransportKind::Pty,
22325            }))
22326            .unwrap();
22327        runtime.tick().await;
22328        shared.reconcile_managed_record(
22329            &record_id,
22330            &address,
22331            &ControlEvent {
22332                sequence: 1,
22333                command_id: None,
22334                instance_id: address.session.instance_id,
22335                generation: address.session.generation,
22336                event: ControlEventKind::Removed,
22337            },
22338            false,
22339            false,
22340        );
22341        let remove = shared.remove_session(&address);
22342        let drive_runtime = async {
22343            runtime.tick().await;
22344        };
22345        let (removed, ()) = tokio::join!(remove, drive_runtime);
22346        removed.unwrap();
22347
22348        assert!(!materialization_root.exists());
22349        assert!(!Path::new(&created.path).exists());
22350        assert_eq!(
22351            shared
22352                .managed_worktrees
22353                .lock()
22354                .unwrap_or_else(|poisoned| poisoned.into_inner())
22355                .get(&lease.lease_id)
22356                .unwrap()
22357                .state,
22358            ManagedWorktreeLeaseState::Removed,
22359        );
22360        let renamed = shared
22361            .rename_session_record(&record_id, "raw managed inventory".to_owned())
22362            .unwrap();
22363        assert_eq!(renamed.record_id, record_id);
22364        assert_eq!(renamed.display_name, "raw managed inventory");
22365        assert_eq!(renamed.state, ManagedSessionState::Unavailable);
22366        assert!(renamed.active_session.is_none());
22367
22368        drop(shared);
22369        drop(runtime);
22370        std::fs::remove_dir_all(materialization_fixture_root).unwrap();
22371        std::fs::remove_dir_all(git_root).unwrap();
22372    }
22373
22374    fn install_managed_lease(shared: &NodeShared) -> ManagedWorktreeLeaseRecord {
22375        let root = std::env::current_dir().unwrap()
22376            .join("managed-test-target")
22377            .to_string_lossy()
22378            .into_owned();
22379        let lease = ManagedWorktreeLeaseRecord {
22380            lease_id: ManagedWorktreeLeaseId::new("mw-test").unwrap(),
22381            source_workspace_id: WorkspaceId::new("primary").unwrap(),
22382            workspace_id: WorkspaceId::new("managed-test").unwrap(),
22383            profile_id: WorktreeProfileId::new("default").unwrap(),
22384            profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
22385            target_root: root.clone(),
22386            branch: "gate4agent/mw-test".to_owned(),
22387            base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
22388            expected_head: Some("0123456789abcdef0123456789abcdef01234567".to_owned()),
22389            retention: ManagedWorktreeRetention::RemoveWhenReleased,
22390            state: ManagedWorktreeLeaseState::Ready,
22391            session_holders: Vec::new(),
22392            record_holders: Vec::new(),
22393            cleanup_failure: None,
22394            created_at_unix_ms: 1,
22395            updated_at_unix_ms: 1,
22396        };
22397        shared.workspaces.write()
22398            .unwrap_or_else(|poisoned| poisoned.into_inner())
22399            .insert(lease.workspace_id.clone(), root);
22400        *shared.managed_worktrees.lock()
22401            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22402            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
22403        lease
22404    }
22405
22406    #[cfg(feature = "fixture")]
22407    #[test]
22408    fn monitoring_fixture_managed_spawn_retains_effective_record_holder_and_converges_live() {
22409        let mut shared = terminal_test_shared();
22410        shared.fixture_semantic_hook_policy = true;
22411        let lease = install_managed_lease(&shared);
22412        let address = SessionAddress {
22413            workspace_id: lease.workspace_id.clone(),
22414            session: SessionKey {
22415                instance_id: AgentInstanceId(206),
22416                generation: SessionGeneration(1),
22417            },
22418        };
22419        let pre_admission_policy = ProviderRuntimePolicy::raw_pty();
22420        let effective_runtime_policy =
22421            shared.effective_spawn_runtime_policy(pre_admission_policy);
22422        assert_ne!(effective_runtime_policy, pre_admission_policy);
22423        assert!(effective_runtime_policy.provider_session_identity);
22424
22425        let record_id = shared
22426            .bind_spawn_session(
22427                &address,
22428                agent("claude"),
22429                SessionMode::Pty,
22430                effective_runtime_policy,
22431                SpawnRecordPolicy::Always,
22432                None,
22433            )
22434            .unwrap()
22435            .unwrap();
22436        assert_eq!(
22437            shared.record(&record_id).unwrap().state,
22438            ManagedSessionState::IdentityPending,
22439        );
22440        let (record_holder, raw_inventory_record_id) = {
22441            let mut bindings = shared
22442                .session_bindings
22443                .lock()
22444                .unwrap_or_else(|poisoned| poisoned.into_inner());
22445            let binding = bindings.get_mut(&address.session.instance_id).unwrap();
22446            binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
22447            managed_spawn_record_ownership(binding, effective_runtime_policy)
22448        };
22449        assert_eq!(record_holder.as_ref(), Some(&record_id));
22450        assert!(raw_inventory_record_id.is_none());
22451        let snapshot = shared
22452            .managed_worktrees
22453            .lock()
22454            .unwrap_or_else(|poisoned| poisoned.into_inner())
22455            .bind_session(
22456                &lease.lease_id,
22457                ManagedWorktreeSessionHolder {
22458                    incarnation_id: shared.incarnation_id,
22459                    instance_id: address.session.instance_id,
22460                    generation: address.session.generation,
22461                },
22462                record_holder,
22463                unix_time_ms(),
22464            )
22465            .unwrap();
22466        assert_eq!(snapshot.managed_record_count, 1);
22467        assert_eq!(
22468            shared
22469                .managed_worktrees
22470                .lock()
22471                .unwrap_or_else(|poisoned| poisoned.into_inner())
22472                .get(&lease.lease_id)
22473                .unwrap()
22474                .record_holders,
22475            vec![record_id.clone()],
22476        );
22477
22478        let identity = ProviderSessionIdentity {
22479            key: ProviderSessionKey::SessionId,
22480            id: "monitoring-fixture-managed-session".to_owned(),
22481            transcript_path: None,
22482        };
22483        shared.publish_control(ControlEvent {
22484            sequence: 1,
22485            command_id: None,
22486            instance_id: address.session.instance_id,
22487            generation: address.session.generation,
22488            event: ControlEventKind::ProviderEvent {
22489                sequence: 1,
22490                source: gate4agent_types::ProviderSource {
22491                    family: AdapterFamily::Hook,
22492                    binding: AdapterBinding::new(
22493                        gate4agent_types::AdapterId::new("claude-code").unwrap(),
22494                        "fixture/v1",
22495                        gate4agent_types::AdapterVerification::SyntheticFixture,
22496                    )
22497                    .unwrap(),
22498                },
22499                source_sequence: 1,
22500                event: ProviderEvent::SessionIdentityObserved {
22501                    identity: identity.clone(),
22502                },
22503            },
22504        });
22505
22506        let live = shared.record(&record_id).unwrap();
22507        assert_eq!(live.state, ManagedSessionState::Live);
22508        assert_eq!(live.provider_session.as_ref(), Some(&identity));
22509        assert_eq!(live.active_session.as_ref(), Some(&address));
22510        assert_eq!(
22511            shared
22512                .managed_worktrees
22513                .lock()
22514                .unwrap_or_else(|poisoned| poisoned.into_inner())
22515                .get(&lease.lease_id)
22516                .unwrap()
22517                .record_holders,
22518            vec![record_id],
22519        );
22520    }
22521
22522    fn managed_v2_test_shared(
22523        allocation_root: &Path,
22524        state_path: Option<PathBuf>,
22525        profile_revision: &str,
22526    ) -> NodeShared {
22527        let profile = ManagedWorktreeProfile::new(
22528            WorktreeProfileId::new("default").unwrap(),
22529            WorktreeProfileRevision::new(profile_revision).unwrap(),
22530            allocation_root,
22531            "gate4agent",
22532            "HEAD",
22533            ManagedWorktreeRetention::RemoveWhenReleased,
22534        )
22535        .unwrap();
22536        let workspace = WorkspaceConfig::new(
22537            WorkspaceId::new("primary").unwrap(),
22538            std::env::current_dir().unwrap(),
22539        )
22540        .unwrap()
22541        .with_worktree_service_mode(WorktreeServiceMode::Managed)
22542        .with_managed_worktree_profile(profile)
22543        .unwrap();
22544        let catalog = active_registry().unwrap();
22545        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22546        let mut shared = NodeShared::new(
22547            handle,
22548            "fixture-token".to_owned(),
22549            NodeId::new("node-terminal-test").unwrap(),
22550            vec![workspace],
22551            vec![agent("claude")],
22552        );
22553        shared.state_path = state_path;
22554        shared
22555    }
22556
22557    fn managed_v2_request(idempotency_key: &str) -> ManagedWorktreeSpawnRequestV2 {
22558        let mut spawn_spec = spawn_spec_fixture();
22559        spawn_spec.idempotency_key = SpawnIdempotencyKey::new(idempotency_key).unwrap();
22560        ManagedWorktreeSpawnRequestV2 {
22561            spawn_spec,
22562            worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
22563            expected_profile_revision: WorktreeProfileRevision::new("v1").unwrap(),
22564        }
22565    }
22566
22567    #[test]
22568    fn managed_spawn_receipt_rebinds_allocated_worktree_and_roundtrips_wire() {
22569        let shared = terminal_test_shared();
22570        let mut lease = install_managed_lease(&shared);
22571        let session = SessionAddress {
22572            workspace_id: lease.workspace_id.clone(),
22573            session: SessionKey {
22574                instance_id: AgentInstanceId(73),
22575                generation: SessionGeneration(4),
22576            },
22577        };
22578        lease.state = ManagedWorktreeLeaseState::InUse;
22579        lease.session_holders.push(ManagedWorktreeSessionHolder {
22580            incarnation_id: shared.incarnation_id,
22581            instance_id: session.session.instance_id,
22582            generation: session.session.generation,
22583        });
22584        let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
22585        assert!(resolved.target.worktree_id.is_none());
22586
22587        let receipt = managed_spawn_receipt(
22588            &resolved,
22589            shared.incarnation_id,
22590            session.clone(),
22591            lease.snapshot(),
22592            None,
22593            None,
22594            None,
22595        );
22596        assert!(resolved.target.worktree_id.is_none());
22597        assert_eq!(receipt.spawn.target.workspace_id, lease.source_workspace_id);
22598        assert_eq!(receipt.spawn.target.worktree_id.as_ref(), Some(&lease.workspace_id));
22599        assert_eq!(receipt.spawn.session, session);
22600        assert_eq!(receipt.lease.workspace_id, lease.workspace_id);
22601        let encoded = serde_json::to_vec(&receipt).unwrap();
22602        let decoded: ManagedWorktreeSpawnReceipt = serde_json::from_slice(&encoded).unwrap();
22603        assert_eq!(decoded, receipt);
22604    }
22605
22606    #[tokio::test]
22607    async fn managed_spawn_v2_rejects_profile_revision_before_allocation() {
22608        let root = temporary_workspace_root("managed-v2-revision");
22609        let allocation = root.join("allocation");
22610        std::fs::create_dir_all(&allocation).unwrap();
22611        let shared = managed_v2_test_shared(
22612            &allocation,
22613            Some(root.join("node-state.json")),
22614            "v1",
22615        );
22616        let mut request = managed_v2_request("managed-v2-revision");
22617        request.expected_profile_revision = WorktreeProfileRevision::new("v2").unwrap();
22618
22619        let failure = shared.spawn_managed_worktree_v2(request).await.unwrap_err();
22620
22621        assert_eq!(
22622            failure.code,
22623            NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
22624        );
22625        assert!(shared
22626            .managed_worktrees
22627            .lock()
22628            .unwrap_or_else(|poisoned| poisoned.into_inner())
22629            .records()
22630            .is_empty());
22631        assert!(shared
22632            .managed_spawn_replays
22633            .lock()
22634            .unwrap_or_else(|poisoned| poisoned.into_inner())
22635            .is_empty());
22636        std::fs::remove_dir_all(root).unwrap();
22637    }
22638
22639    #[test]
22640    fn managed_spawn_v2_concurrent_same_key_has_one_durable_reservation() {
22641        let root = temporary_workspace_root("managed-v2-concurrent");
22642        let allocation = root.join("allocation");
22643        std::fs::create_dir_all(&allocation).unwrap();
22644        let shared = Arc::new(managed_v2_test_shared(
22645            &allocation,
22646            Some(root.join("node-state.json")),
22647            "v1",
22648        ));
22649        let request = managed_v2_request("managed-v2-concurrent");
22650        let barrier = Arc::new(std::sync::Barrier::new(3));
22651        let mut joins = Vec::new();
22652        for _ in 0..2 {
22653            let shared = Arc::clone(&shared);
22654            let request = request.clone();
22655            let barrier = Arc::clone(&barrier);
22656            joins.push(std::thread::spawn(move || {
22657                let digest = managed_spawn_request_digest_v2(&request).unwrap();
22658                barrier.wait();
22659                shared.acquire_durable_managed_spawn_v2(&request, digest)
22660            }));
22661        }
22662        barrier.wait();
22663        let results = joins
22664            .into_iter()
22665            .map(|join| join.join().unwrap())
22666            .collect::<Vec<_>>();
22667        assert_eq!(
22668            results
22669                .iter()
22670                .filter(|result| {
22671                    matches!(result, Ok(DurableManagedSpawnReplayDecision::Reserved))
22672                })
22673                .count(),
22674            1,
22675        );
22676        assert_eq!(
22677            results
22678                .iter()
22679                .filter(|result| {
22680                    matches!(result, Err(error) if error.code == NodeFailureCode::BackendBusy)
22681                })
22682                .count(),
22683            1,
22684        );
22685        assert_eq!(
22686            shared
22687                .managed_spawn_replays
22688                .lock()
22689                .unwrap_or_else(|poisoned| poisoned.into_inner())
22690                .len(),
22691            1,
22692        );
22693        assert!(shared
22694            .managed_worktrees
22695            .lock()
22696            .unwrap_or_else(|poisoned| poisoned.into_inner())
22697            .records()
22698            .is_empty());
22699        drop(shared);
22700        std::fs::remove_dir_all(root).unwrap();
22701    }
22702
22703    #[tokio::test]
22704    async fn managed_spawn_v2_linked_pending_reopen_fails_closed_without_reallocation() {
22705        let root = temporary_workspace_root("managed-v2-linked-pending");
22706        let allocation = root.join("allocation");
22707        std::fs::create_dir_all(&allocation).unwrap();
22708        let state_path = root.join("node-state.json");
22709        let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
22710        let lease = install_managed_lease(&shared);
22711        let request = managed_v2_request("managed-v2-linked-pending");
22712        assert!(matches!(
22713            shared
22714                .acquire_durable_managed_spawn_v2(
22715                    &request,
22716                    managed_spawn_request_digest_v2(&request).unwrap(),
22717                )
22718                .unwrap(),
22719            DurableManagedSpawnReplayDecision::Reserved,
22720        ));
22721        {
22722            let _transaction = shared
22723                .state_transaction
22724                .lock()
22725                .unwrap_or_else(|poisoned| poisoned.into_inner());
22726            shared
22727                .link_durable_managed_spawn_v2_lease_locked(
22728                    &request.spawn_spec.idempotency_key,
22729                    &lease.lease_id,
22730                )
22731                .unwrap();
22732            shared.persist_state_locked().unwrap();
22733        }
22734        drop(shared);
22735
22736        let mut loaded = session_registry::load(
22737            Some(&state_path),
22738            &NodeId::new("node-terminal-test").unwrap(),
22739        )
22740        .unwrap();
22741        let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22742        *reopened
22743            .managed_worktrees
22744            .lock()
22745            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22746            ManagedWorktreeRegistry::from_records(
22747                std::mem::take(&mut loaded.managed_worktrees),
22748                std::mem::take(&mut loaded.managed_worktree_tombstones),
22749            )
22750            .unwrap();
22751        *reopened
22752            .managed_spawn_replays
22753            .lock()
22754            .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22755            .managed_spawn_replays
22756            .into_iter()
22757            .map(|record| (record.idempotency_key.clone(), record))
22758            .collect();
22759        let lease_count = reopened
22760            .managed_worktrees
22761            .lock()
22762            .unwrap_or_else(|poisoned| poisoned.into_inner())
22763            .records()
22764            .len();
22765
22766        let failure = reopened
22767            .spawn_managed_worktree_v2(request)
22768            .await
22769            .unwrap_err();
22770
22771        assert_eq!(
22772            failure.code,
22773            NodeFailureCode::ManagedWorktreeRecoveryRequired,
22774        );
22775        assert_eq!(
22776            reopened
22777                .managed_worktrees
22778                .lock()
22779                .unwrap_or_else(|poisoned| poisoned.into_inner())
22780                .records()
22781                .len(),
22782            lease_count,
22783        );
22784        std::fs::remove_dir_all(root).unwrap();
22785    }
22786
22787    #[tokio::test]
22788    async fn managed_spawn_v2_replays_exact_receipt_after_durable_reopen_and_conflicts() {
22789        let root = temporary_workspace_root("managed-v2-reopen");
22790        let allocation = root.join("allocation");
22791        std::fs::create_dir_all(&allocation).unwrap();
22792        let state_path = root.join("node-state.json");
22793        let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
22794        let lease = install_managed_lease(&shared);
22795        let request = managed_v2_request("managed-v2-reopen");
22796        let session = SessionAddress {
22797            workspace_id: lease.workspace_id.clone(),
22798            session: SessionKey {
22799                instance_id: AgentInstanceId(91),
22800                generation: SessionGeneration(1),
22801            },
22802        };
22803        let snapshot = {
22804            let mut registry = shared
22805                .managed_worktrees
22806                .lock()
22807                .unwrap_or_else(|poisoned| poisoned.into_inner());
22808            let current = registry.get_mut(&lease.lease_id).unwrap();
22809            current.state = ManagedWorktreeLeaseState::InUse;
22810            current.session_holders.push(ManagedWorktreeSessionHolder {
22811                incarnation_id: shared.incarnation_id,
22812                instance_id: session.session.instance_id,
22813                generation: session.session.generation,
22814            });
22815            current.snapshot()
22816        };
22817        let resolved = shared.resolve_spawn_spec(&request.spawn_spec).unwrap();
22818        let receipt = managed_spawn_receipt(
22819            &resolved,
22820            shared.incarnation_id,
22821            session,
22822            snapshot,
22823            None,
22824            None,
22825            None,
22826        );
22827        assert!(matches!(
22828            shared
22829                .acquire_durable_managed_spawn_v2(
22830                    &request,
22831                    managed_spawn_request_digest_v2(&request).unwrap(),
22832                )
22833                .unwrap(),
22834            DurableManagedSpawnReplayDecision::Reserved,
22835        ));
22836        {
22837            let _transaction = shared
22838                .state_transaction
22839                .lock()
22840                .unwrap_or_else(|poisoned| poisoned.into_inner());
22841            shared
22842                .link_durable_managed_spawn_v2_lease_locked(
22843                    &request.spawn_spec.idempotency_key,
22844                    &lease.lease_id,
22845                )
22846                .unwrap();
22847            shared.persist_state_locked().unwrap();
22848        }
22849        shared
22850            .commit_durable_managed_spawn_v2(&request, receipt.clone())
22851            .unwrap();
22852        let legacy_same_key = ManagedWorktreeSpawnRequest {
22853            spawn_spec: request.spawn_spec.clone(),
22854            worktree_profile_id: request.worktree_profile_id.clone(),
22855        };
22856        shared.remember_managed_spawn_attempt(
22857            Some(&request.spawn_spec.idempotency_key),
22858            legacy_same_key.clone(),
22859            Ok(receipt.clone()),
22860            Instant::now(),
22861        );
22862        assert!(shared
22863            .replay_managed_spawn(&legacy_same_key, Instant::now())
22864            .unwrap()
22865            .is_none());
22866        drop(shared);
22867
22868        let mut loaded = session_registry::load(
22869            Some(&state_path),
22870            &NodeId::new("node-terminal-test").unwrap(),
22871        )
22872        .unwrap();
22873        assert_eq!(loaded.managed_spawn_replays.len(), 1);
22874        let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22875        *reopened
22876            .managed_worktrees
22877            .lock()
22878            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22879            ManagedWorktreeRegistry::from_records(
22880                std::mem::take(&mut loaded.managed_worktrees),
22881                std::mem::take(&mut loaded.managed_worktree_tombstones),
22882            )
22883            .unwrap();
22884        *reopened
22885            .managed_spawn_replays
22886            .lock()
22887            .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22888            .managed_spawn_replays
22889            .into_iter()
22890            .map(|record| (record.idempotency_key.clone(), record))
22891            .collect();
22892        let lease_count_before = reopened
22893            .managed_worktrees
22894            .lock()
22895            .unwrap_or_else(|poisoned| poisoned.into_inner())
22896            .records()
22897            .len();
22898
22899        assert_eq!(
22900            reopened
22901                .spawn_managed_worktree_v2(request.clone())
22902                .await
22903                .unwrap(),
22904            receipt,
22905        );
22906        assert_eq!(
22907            reopened
22908                .managed_worktrees
22909                .lock()
22910                .unwrap_or_else(|poisoned| poisoned.into_inner())
22911                .records()
22912                .len(),
22913            lease_count_before,
22914        );
22915        let mut changed = request;
22916        changed.spawn_spec.deadline_ms = crate::protocol::SpawnDeadlineMs::new(29_000).unwrap();
22917        let failure = reopened
22918            .spawn_managed_worktree_v2(changed)
22919            .await
22920            .unwrap_err();
22921        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22922        assert_eq!(
22923            reopened
22924                .managed_worktrees
22925                .lock()
22926                .unwrap_or_else(|poisoned| poisoned.into_inner())
22927                .records()
22928                .len(),
22929            lease_count_before,
22930        );
22931        let mut new_key = managed_v2_request("managed-v2-new-after-profile-change");
22932        new_key.expected_profile_revision = WorktreeProfileRevision::new("v1").unwrap();
22933        let failure = reopened
22934            .spawn_managed_worktree_v2(new_key)
22935            .await
22936            .unwrap_err();
22937        assert_eq!(
22938            failure.code,
22939            NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
22940        );
22941        assert_eq!(
22942            reopened
22943                .managed_worktrees
22944                .lock()
22945                .unwrap_or_else(|poisoned| poisoned.into_inner())
22946                .records()
22947                .len(),
22948            lease_count_before,
22949        );
22950        assert_eq!(
22951            reopened
22952                .managed_spawn_replays
22953                .lock()
22954                .unwrap_or_else(|poisoned| poisoned.into_inner())
22955                .len(),
22956            1,
22957        );
22958        std::fs::remove_dir_all(root).unwrap();
22959    }
22960
22961    #[test]
22962    fn legacy_managed_spawn_idempotency_remains_transient_and_exact() {
22963        let shared = terminal_test_shared();
22964        let request = ManagedWorktreeSpawnRequest {
22965            spawn_spec: spawn_spec_fixture(),
22966            worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
22967        };
22968        let expected = failure(NodeFailureCode::BackendBusy, "legacy fixture failure");
22969        shared.remember_managed_spawn(
22970            request.clone(),
22971            Err(expected.clone()),
22972            Instant::now(),
22973        );
22974
22975        assert_eq!(
22976            shared
22977                .replay_managed_spawn(&request, Instant::now())
22978                .unwrap()
22979                .unwrap(),
22980            Err(expected),
22981        );
22982        assert!(shared
22983            .managed_spawn_replays
22984            .lock()
22985            .unwrap_or_else(|poisoned| poisoned.into_inner())
22986            .is_empty());
22987    }
22988
22989    #[tokio::test]
22990    async fn ordinary_spawn_and_spawn_spec_cannot_bypass_managed_lease_holder_fence() {
22991        let shared = terminal_test_shared();
22992        let lease = install_managed_lease(&shared);
22993        let raw = shared.spawn_session(
22994            lease.workspace_id.clone(),
22995            agent("claude"),
22996            SessionMode::Pty,
22997            gate4agent_types::TerminalSize { rows: 24, columns: 80 },
22998            None,
22999        ).await.unwrap_err();
23000        assert_eq!(raw.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
23001        let mut spec = spawn_spec_fixture();
23002        spec.target.workspace_id = lease.workspace_id.clone();
23003        let specified = shared.spawn_from_spec(spec).await.unwrap_err();
23004        assert_eq!(specified.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
23005        let mut selected = spawn_spec_fixture();
23006        selected.idempotency_key = SpawnIdempotencyKey::new("managed-selected-bypass").unwrap();
23007        selected.target.worktree_id = Some(lease.workspace_id);
23008        let selected = shared.spawn_from_spec(selected).await.unwrap_err();
23009        assert_eq!(selected.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
23010        assert!(shared.handle.snapshot().sessions.is_empty());
23011    }
23012
23013    #[test]
23014    fn legacy_projection_omits_managed_snapshot_events_and_path_bearing_diagnostics() {
23015        let shared = terminal_test_shared();
23016        let lease = install_managed_lease(&shared);
23017        let snapshot = snapshot_for_wire(
23018            &shared, true, true, true, false, true, true, false, false, true,
23019        );
23020        assert!(snapshot.managed_worktrees.is_empty());
23021        assert!(snapshot.workspaces.iter().all(|workspace| {
23022            workspace.managed_worktree_profiles.is_none()
23023        }));
23024        let mut reply = ResponseEnvelope {
23025            request_id: 1,
23026            result: Ok(NodeResponse::Resync {
23027                event_sequence: 1,
23028                oldest_available_sequence: 1,
23029                snapshot: shared.snapshot(),
23030                events: vec![NodeEventEnvelope {
23031                    sequence: 1,
23032                    event: NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() },
23033                }],
23034            }),
23035        };
23036        project_response_without_managed_worktrees(&mut reply);
23037        let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
23038            panic!("projection changed the response kind");
23039        };
23040        assert!(snapshot.managed_worktrees.is_empty());
23041        assert!(events.is_empty());
23042        let mut git = git_snapshot_for_parser();
23043        git.branch = Some(lease.branch.clone());
23044        git.managed_worktree = Some(crate::protocol::ManagedWorktreeGitScope {
23045            lease_id: lease.lease_id.clone(),
23046            source_workspace_id: lease.source_workspace_id.clone(),
23047            branch: lease.branch.clone(),
23048            base_commit: GitObjectId::new(lease.base_commit.clone()).unwrap(),
23049            active_session_count: 1,
23050            managed_record_count: 0,
23051        });
23052        let mut inspection_reply = ResponseEnvelope {
23053            request_id: 2,
23054            result: Ok(NodeResponse::WorkspaceInspected {
23055                inspection: WorkspaceInspection {
23056                    workspace_id: lease.workspace_id.clone(),
23057                    entries: Vec::new(),
23058                    tree_truncated: false,
23059                    git,
23060                    truncation: None,
23061                },
23062            }),
23063        };
23064        project_response_without_managed_worktrees(&mut inspection_reply);
23065        let Ok(NodeResponse::WorkspaceInspected { inspection }) = inspection_reply.result else {
23066            panic!("projection changed the inspection response kind");
23067        };
23068        assert!(inspection.git.managed_worktree.is_none());
23069        let failure = managed_git_worktree_failure(GitWorktreeError {
23070            kind: GitWorktreeErrorKind::Failed,
23071            message: r"git failed at C:\private\managed-target".to_owned(),
23072        });
23073        assert!(!failure.message.contains("private"));
23074        assert!(!failure.message.contains("managed-target"));
23075    }
23076
23077    #[tokio::test]
23078    async fn restart_reconciliation_retains_exact_owned_worktree_and_explicit_cleanup_removes_it() {
23079        let root = temporary_workspace_root("managed-restart");
23080        let source = root.join("source");
23081        let allocation = root.join("allocation");
23082        std::fs::create_dir_all(&source).unwrap();
23083        std::fs::create_dir_all(&allocation).unwrap();
23084        run_test_git(&source, &["init"]);
23085        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
23086        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
23087        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
23088        run_test_git(&source, &["add", "seed.txt"]);
23089        run_test_git(&source, &["commit", "-m", "seed"]);
23090        let profile = ManagedWorktreeProfile::new(
23091            WorktreeProfileId::new("default").unwrap(),
23092            crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
23093            &allocation,
23094            "gate4agent",
23095            "HEAD",
23096            ManagedWorktreeRetention::Retain,
23097        ).unwrap();
23098        let workspace = WorkspaceConfig::new(
23099            WorkspaceId::new("primary").unwrap(),
23100            &source,
23101        ).unwrap()
23102            .with_worktree_service_mode(WorktreeServiceMode::Managed)
23103            .with_managed_worktree_profile(profile.clone()).unwrap();
23104        let target = PathBuf::from(profile.allocation_root()).join("mw-restart");
23105        let source_text = workspace.canonical_root().to_owned();
23106        let target_text = target.to_string_lossy().into_owned();
23107        let created = create_git_worktree(
23108            &source_text,
23109            &target_text,
23110            "gate4agent/mw-restart",
23111            Some("HEAD"),
23112        ).await.unwrap();
23113        let lease = ManagedWorktreeLeaseRecord {
23114            lease_id: ManagedWorktreeLeaseId::new("mw-restart").unwrap(),
23115            source_workspace_id: WorkspaceId::new("primary").unwrap(),
23116            workspace_id: WorkspaceId::new("managed-restart").unwrap(),
23117            profile_id: profile.profile_id().clone(),
23118            profile_revision: profile.revision().clone(),
23119            target_root: created.path.clone(),
23120            branch: "gate4agent/mw-restart".to_owned(),
23121            base_commit: created.head.clone(),
23122            expected_head: None,
23123            retention: ManagedWorktreeRetention::Retain,
23124            state: ManagedWorktreeLeaseState::Allocating,
23125            session_holders: Vec::new(),
23126            record_holders: Vec::new(),
23127            cleanup_failure: None,
23128            created_at_unix_ms: 1,
23129            updated_at_unix_ms: 1,
23130        };
23131        let catalog = active_registry().unwrap();
23132        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23133        let shared = NodeShared::new_with_incarnation(
23134            handle,
23135            "fixture-token".to_owned(),
23136            NodeId::new("node-managed-restart").unwrap(),
23137            NodeIncarnationId::from_bytes([7; crate::protocol::NODE_INCARNATION_ID_BYTES]),
23138            vec![workspace],
23139            vec![agent("claude")],
23140            ProviderRuntimeStatuses::default(),
23141            None,
23142            Vec::new(),
23143            Vec::new(),
23144            SpawnProfileRegistry::default(),
23145            None,
23146            None,
23147            None,
23148            None,
23149            Vec::new(),
23150            vec![lease.clone()],
23151            Vec::new(),
23152            Vec::new(),
23153            None,
23154            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
23155        );
23156        shared.reconcile_managed_worktrees().await;
23157        let retained = shared.managed_worktrees.lock()
23158            .unwrap_or_else(|poisoned| poisoned.into_inner())
23159            .get(&lease.lease_id).unwrap().clone();
23160        assert_eq!(retained.state, ManagedWorktreeLeaseState::Retained);
23161        assert!(shared.workspaces.read()
23162            .unwrap_or_else(|poisoned| poisoned.into_inner())
23163            .contains_key(&lease.workspace_id));
23164        let removed = shared.cleanup_managed_worktree(&lease.lease_id, true).await.unwrap();
23165        assert_eq!(removed.state, ManagedWorktreeLeaseState::Removed);
23166        assert!(!Path::new(&created.path).exists());
23167        std::fs::remove_dir_all(root).unwrap();
23168    }
23169
23170    fn run_test_git(root: &Path, arguments: &[&str]) {
23171        let output = std::process::Command::new("git")
23172            .arg("-C").arg(root).args(arguments).output().unwrap();
23173        assert!(
23174            output.status.success(),
23175            "git {:?} failed: {}",
23176            arguments,
23177            String::from_utf8_lossy(&output.stderr),
23178        );
23179    }
23180
23181    #[tokio::test]
23182    async fn spawn_spec_validation_fails_before_session_mutation() {
23183        let shared = terminal_test_shared();
23184        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23185            capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
23186        }));
23187        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23188            capability.as_str() == NODE_SPAWN_PROFILE_REVISION_CAPABILITY
23189        }));
23190        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23191            capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
23192        }));
23193
23194        let mut target_mismatch = spawn_spec_fixture();
23195        target_mismatch.target.node_id = NodeId::new("other-node").unwrap();
23196        let failure = shared.spawn_from_spec(target_mismatch).await.unwrap_err();
23197        assert_eq!(failure.code, NodeFailureCode::SpawnTargetMismatch);
23198
23199        let mut unknown_profile = spawn_spec_fixture();
23200        unknown_profile.idempotency_key = SpawnIdempotencyKey::new("unknown-profile").unwrap();
23201        unknown_profile.profile_id = crate::protocol::SpawnProfileId::new("missing").unwrap();
23202        let failure = shared.spawn_from_spec(unknown_profile).await.unwrap_err();
23203        assert_eq!(failure.code, NodeFailureCode::UnknownSpawnProfile);
23204
23205        let mut revision_mismatch = spawn_spec_fixture();
23206        revision_mismatch.idempotency_key =
23207            SpawnIdempotencyKey::new("profile-revision-mismatch").unwrap();
23208        revision_mismatch.expected_profile_revision =
23209            crate::protocol::SpawnProfileRevision::new("test-r2").unwrap();
23210        let failure = shared.spawn_from_spec(revision_mismatch).await.unwrap_err();
23211        assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
23212
23213        let mut unsupported_materializer = spawn_spec_fixture();
23214        unsupported_materializer.idempotency_key =
23215            SpawnIdempotencyKey::new("unsupported-materializer").unwrap();
23216        unsupported_materializer.overrides.bundle_id = crate::protocol::SpawnOverride::Set {
23217            value: crate::protocol::SpawnBundleId::new("bundle-v1").unwrap(),
23218        };
23219        let failure = shared
23220            .spawn_from_spec(unsupported_materializer)
23221            .await
23222            .unwrap_err();
23223        assert_eq!(failure.code, NodeFailureCode::UnknownBundle);
23224
23225        let failure = shared
23226            .spawn_session_with_deadline(
23227                WorkspaceId::new("primary").unwrap(),
23228                agent("claude"),
23229                SessionMode::Pty,
23230                gate4agent_types::TerminalSize {
23231                    rows: 24,
23232                    columns: 80,
23233                },
23234                None,
23235                None,
23236                None,
23237                None,
23238                None,
23239                None,
23240                SpawnRecordPolicy::ProviderIdentityOnly,
23241                Some(Instant::now()),
23242                &[],
23243                None,
23244                ApprovalLevel::default(),
23245                None,
23246                None,
23247            )
23248            .await
23249            .unwrap_err();
23250        assert_eq!(failure.code, NodeFailureCode::SpawnDeadlineExceeded);
23251        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
23252        assert!(shared.handle.snapshot().sessions.is_empty());
23253    }
23254
23255    #[test]
23256    fn worktree_selection_requires_its_separate_negotiated_capability() {
23257        let mut spec = spawn_spec_fixture();
23258        spec.target.worktree_id = Some(WorkspaceId::new("selected-worktree").unwrap());
23259        let request = NodeRequest::SpawnSpec { spec };
23260        let spawn_spec_capability =
23261            CapabilityId::new(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY).unwrap();
23262        assert!(request_uses_unnegotiated_capability(
23263            &request,
23264            &[spawn_spec_capability.clone()],
23265        ));
23266        let worktree_capability =
23267            CapabilityId::new(NODE_WORKTREE_SELECTION_CAPABILITY).unwrap();
23268        assert!(request_uses_unnegotiated_capability(
23269            &request,
23270            &[spawn_spec_capability.clone(), worktree_capability.clone()],
23271        ));
23272        let profile_revision_capability =
23273            CapabilityId::new(NODE_SPAWN_PROFILE_REVISION_CAPABILITY).unwrap();
23274        assert!(request_uses_unnegotiated_capability(
23275            &request,
23276            &[
23277                spawn_spec_capability.clone(),
23278                worktree_capability.clone(),
23279                profile_revision_capability.clone(),
23280            ],
23281        ));
23282        // Default `SpawnOverrides` leaves `bundle_id`/`context_id` at
23283        // `Inherit`, not `Clear` — the profile's own defaults might still
23284        // materialize a bundle or a context pack, so both capabilities stay
23285        // conservatively required until negotiated (see
23286        // `requires_session_bundle_materialization_capability` /
23287        // `requires_history_context_pack_capability`).
23288        let bundle_materialization_capability =
23289            CapabilityId::new(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY).unwrap();
23290        assert!(request_uses_unnegotiated_capability(
23291            &request,
23292            &[
23293                spawn_spec_capability.clone(),
23294                worktree_capability.clone(),
23295                profile_revision_capability.clone(),
23296                bundle_materialization_capability.clone(),
23297            ],
23298        ));
23299        let history_context_pack_capability =
23300            CapabilityId::new(NODE_HISTORY_CONTEXT_PACK_CAPABILITY).unwrap();
23301        assert!(!request_uses_unnegotiated_capability(
23302            &request,
23303            &[
23304                spawn_spec_capability,
23305                worktree_capability,
23306                profile_revision_capability,
23307                bundle_materialization_capability,
23308                history_context_pack_capability,
23309            ],
23310        ));
23311    }
23312
23313    #[tokio::test]
23314    async fn spawn_spec_idempotency_replays_receipt_and_conflicts_before_session_mutation() {
23315        let mut shared = terminal_test_shared();
23316        let spec = spawn_spec_fixture();
23317        let receipt = shared
23318            .resolve_spawn_spec(&spec)
23319            .unwrap()
23320            .receipt(shared.incarnation_id, terminal_address(1));
23321        shared.remember_spawn_spec(spec.clone(), Ok(receipt.clone()), Instant::now());
23322
23323        let mut revised = shared.spawn_profiles.get(&spec.profile_id).unwrap().clone();
23324        revised.revision = crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
23325        shared.spawn_profiles = SpawnProfileRegistry::new([revised]).unwrap();
23326
23327        assert_eq!(shared.spawn_from_spec(spec.clone()).await.unwrap(), receipt);
23328
23329        let mut revision_conflicting = spec.clone();
23330        revision_conflicting.expected_profile_revision =
23331            crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
23332        let failure = shared.spawn_from_spec(revision_conflicting).await.unwrap_err();
23333        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
23334
23335        let mut stale_cache_miss = spec.clone();
23336        stale_cache_miss.idempotency_key =
23337            SpawnIdempotencyKey::new("stale-profile-cache-miss").unwrap();
23338        let failure = shared.spawn_from_spec(stale_cache_miss).await.unwrap_err();
23339        assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
23340
23341        let mut conflicting = spec;
23342        conflicting.deadline_ms = crate::protocol::SpawnDeadlineMs::new(31_000).unwrap();
23343        let failure = shared.spawn_from_spec(conflicting).await.unwrap_err();
23344        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
23345        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
23346        assert!(shared.handle.snapshot().sessions.is_empty());
23347    }
23348
23349    #[tokio::test]
23350    async fn off_worktree_service_rejects_selection_and_mutation_before_git() {
23351        let shared = terminal_test_shared_with_mode(WorktreeServiceMode::Off);
23352        let mut spec = spawn_spec_fixture();
23353        spec.target.worktree_id = Some(WorkspaceId::new("unregistered-worktree").unwrap());
23354        let failure = shared.spawn_from_spec(spec).await.unwrap_err();
23355        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
23356
23357        let failure = shared
23358            .create_worktree(
23359                WorkspaceId::new("primary").unwrap(),
23360                WorkspaceId::new("new-worktree").unwrap(),
23361                "not-an-absolute-path".to_owned(),
23362                "invalid branch".to_owned(),
23363                None,
23364            )
23365            .await
23366            .unwrap_err();
23367        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
23368
23369        let failure = shared
23370            .remove_worktree(
23371                WorkspaceId::new("primary").unwrap(),
23372                "not-an-absolute-path".to_owned(),
23373            )
23374            .await
23375            .unwrap_err();
23376        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
23377        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
23378        assert!(shared.handle.snapshot().sessions.is_empty());
23379    }
23380
23381    #[tokio::test]
23382    async fn manual_worktree_registration_failure_removes_the_created_worktree() {
23383        let root = temporary_workspace_root("manual-worktree-compensation");
23384        let source = root.join("source");
23385        let target = root.join("target");
23386        let invalid_state_parent = root.join("state-parent-is-a-file");
23387        let invalid_state_path = invalid_state_parent.join("state.json");
23388        std::fs::create_dir_all(&source).unwrap();
23389        std::fs::write(&invalid_state_parent, "not a directory").unwrap();
23390        run_test_git(&source, &["init"]);
23391        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
23392        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
23393        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
23394        run_test_git(&source, &["add", "seed.txt"]);
23395        run_test_git(&source, &["commit", "-m", "seed"]);
23396
23397        let catalog = active_registry().unwrap();
23398        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23399        let workspace = WorkspaceConfig::new(
23400            WorkspaceId::new("primary").unwrap(),
23401            &source,
23402        ).unwrap();
23403        let mut shared = NodeShared::new(
23404            handle,
23405            "fixture-token".to_owned(),
23406            NodeId::new("node-worktree-compensation").unwrap(),
23407            vec![workspace],
23408            vec![agent("claude")],
23409        );
23410        shared.state_path = Some(invalid_state_path);
23411
23412        let error = shared.create_worktree(
23413            WorkspaceId::new("primary").unwrap(),
23414            WorkspaceId::new("compensated").unwrap(),
23415            target.to_string_lossy().into_owned(),
23416            "gate4agent/compensated".to_owned(),
23417            Some("HEAD".to_owned()),
23418        ).await.unwrap_err();
23419
23420        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
23421        assert!(!target.exists());
23422        assert!(list_git_worktrees(source.to_str().unwrap()).await.unwrap()
23423            .iter()
23424            .all(|worktree| !worktree_paths_equal(&worktree.path, &target.to_string_lossy())));
23425        std::fs::remove_dir_all(root).unwrap();
23426    }
23427
23428    #[test]
23429    fn selected_worktree_requires_authoritative_non_main_non_bare_entry() {
23430        fn worktree(path: &str, is_main: bool, is_bare: bool) -> NativeGitWorktreeSnapshot {
23431            NativeGitWorktreeSnapshot {
23432                path: path.to_owned(),
23433                head: "0123456789abcdef".to_owned(),
23434                branch: Some("refs/heads/test".to_owned()),
23435                is_bare,
23436                is_main,
23437                locked: false,
23438                lock_reason: None,
23439                prunable: false,
23440                prunable_reason: None,
23441                workspace_id: None,
23442            }
23443        }
23444
23445        let source = r"C:\repo";
23446        let selected = r"C:\repo-worktree";
23447        let listed = vec![
23448            worktree(source, true, false),
23449            worktree(selected, false, false),
23450        ];
23451        assert!(validate_selected_worktree(source, selected, &listed).is_ok());
23452
23453        let main_failure = validate_selected_worktree(source, source, &listed).unwrap_err();
23454        assert_eq!(main_failure.code, NodeFailureCode::WorktreeProtected);
23455
23456        let bare = vec![
23457            worktree(source, true, false),
23458            worktree(selected, false, true),
23459        ];
23460        let bare_failure = validate_selected_worktree(source, selected, &bare).unwrap_err();
23461        assert_eq!(bare_failure.code, NodeFailureCode::WorktreeProtected);
23462
23463        let foreign_failure =
23464            validate_selected_worktree(source, r"C:\foreign", &listed).unwrap_err();
23465        assert_eq!(foreign_failure.code, NodeFailureCode::WorktreeProtected);
23466    }
23467
23468    fn terminal_frame(sequence: u64) -> TerminalFrame {
23469        TerminalFrame {
23470            sequence,
23471            size: gate4agent_types::TerminalSize {
23472                rows: 24,
23473                columns: 80,
23474            },
23475            cursor_row: 2,
23476            cursor_column: 3,
23477            contents: format!("frame-{sequence}"),
23478            formatted: vec![1, 2, 3],
23479            scrollback_formatted: Vec::new(),
23480            alternate_screen: false,
23481            mouse_protocol_enabled: false,
23482            mouse_protocol_encoding: Default::default(),
23483            produced_at_unix_ms: 0,
23484            screen_state: gate4agent_types::PtyScreenState::default(),
23485            bracketed_paste: None,
23486        }
23487    }
23488
23489    #[test]
23490    fn terminal_frame_watermark_coalesces_and_resets_for_exact_session_address() {
23491        let shared = terminal_test_shared();
23492        let mut receiver = shared.terminal_event_tx.subscribe();
23493        let original = terminal_address(1);
23494
23495        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
23496        let first = receiver.try_recv().unwrap();
23497        assert_eq!(first.len(), 1);
23498        assert!(matches!(
23499            &first[0].event,
23500            NodeEvent::TerminalFrame { address, frame }
23501                if address == &original && frame.sequence == 7
23502        ));
23503
23504        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
23505        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(6))]);
23506        assert!(matches!(
23507            receiver.try_recv(),
23508            Err(broadcast::error::TryRecvError::Empty)
23509        ));
23510
23511        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(8))]);
23512        let advanced = receiver.try_recv().unwrap();
23513        assert_eq!(advanced[0].sequence, 2);
23514
23515        let resumed = terminal_address(2);
23516        shared.publish_terminal_frame_candidates(vec![(resumed.clone(), terminal_frame(1))]);
23517        let reset = receiver.try_recv().unwrap();
23518        assert_eq!(reset[0].sequence, 3);
23519        assert!(matches!(
23520            &reset[0].event,
23521            NodeEvent::TerminalFrame { address, frame }
23522                if address == &resumed && frame.sequence == 1
23523        ));
23524        shared.clear_terminal_frame_watermark(&resumed);
23525        assert!(!shared
23526            .terminal_frame_watermarks
23527            .lock()
23528            .unwrap_or_else(|poisoned| poisoned.into_inner())
23529            .contains_key(&resumed.session.instance_id));
23530    }
23531
23532    #[test]
23533    fn terminal_frame_batches_replace_without_entering_node_event_history() {
23534        let shared = terminal_test_shared();
23535        let mut receiver = shared.terminal_event_tx.subscribe();
23536        let address = terminal_address(1);
23537        let count = NODE_EVENT_HISTORY_MAX as u64 + 17;
23538
23539        for sequence in 1..=count {
23540            shared.publish_terminal_frame_candidates(vec![(
23541                address.clone(),
23542                terminal_frame(sequence),
23543            )]);
23544        }
23545
23546        let history = shared
23547            .history
23548            .lock()
23549            .unwrap_or_else(|poisoned| poisoned.into_inner());
23550        assert!(history.events.is_empty());
23551        assert_eq!(history.last_sequence, count);
23552        assert_eq!(history.replay_floor_sequence, 1);
23553        drop(history);
23554        let NodeResponse::Resync {
23555            event_sequence,
23556            oldest_available_sequence,
23557            events,
23558            ..
23559        } = shared.resync(0) else {
23560            unreachable!("resync helper returned another response")
23561        };
23562        assert_eq!(event_sequence, count);
23563        assert_eq!(oldest_available_sequence, 1);
23564        assert!(events.is_empty());
23565        assert!(matches!(
23566            resync_required_event(&shared).event,
23567            NodeEvent::ResyncRequired {
23568                oldest_available_sequence: 1,
23569            }
23570        ));
23571        assert!(matches!(
23572            receiver.try_recv(),
23573            Err(broadcast::error::TryRecvError::Lagged(skipped)) if skipped == count - 1
23574        ));
23575        let latest = receiver.try_recv().unwrap();
23576        assert_eq!(latest.len(), 1);
23577        assert_eq!(latest[0].sequence, count);
23578    }
23579
23580    #[test]
23581    fn real_durable_eviction_advances_authoritative_replay_floor() {
23582        let shared = terminal_test_shared();
23583        for _ in 0..=NODE_EVENT_HISTORY_MAX {
23584            shared.publish(NodeEvent::ControllerChanged { controller: None });
23585        }
23586
23587        let NodeResponse::Resync {
23588            event_sequence,
23589            oldest_available_sequence,
23590            events,
23591            ..
23592        } = shared.resync(0) else {
23593            unreachable!("resync helper returned another response")
23594        };
23595        assert_eq!(event_sequence, NODE_EVENT_HISTORY_MAX as u64 + 1);
23596        assert_eq!(oldest_available_sequence, 2);
23597        assert_eq!(events.first().map(|event| event.sequence), Some(2));
23598        assert_eq!(events.len(), NODE_EVENT_HISTORY_MAX);
23599        assert!(matches!(
23600            resync_required_event(&shared).event,
23601            NodeEvent::ResyncRequired {
23602                oldest_available_sequence: 2,
23603            }
23604        ));
23605    }
23606
23607    #[test]
23608    fn legacy_terminal_frame_connection_has_no_live_subscription() {
23609        let shared = terminal_test_shared();
23610        let without_capability = terminal_event_subscription(&shared, false);
23611        let with_capability = terminal_event_subscription(&shared, true);
23612        assert!(without_capability.is_none());
23613        assert!(with_capability.is_some());
23614        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23615            capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
23616        }));
23617    }
23618
23619    #[test]
23620    fn continuous_terminal_output_leaves_request_service_after_bounded_event_burst() {
23621        let mut pending = Vec::new();
23622        let mut event_sequence = 0_u64;
23623        for frame_sequence in 1..=4 {
23624            for instance in 1..=(NODE_CONNECTION_EVENT_BURST_MAX as u64 * 2) {
23625                event_sequence += 1;
23626                let mut address = terminal_address(1);
23627                address.session.instance_id = AgentInstanceId(instance);
23628                queue_connection_event(
23629                    &mut pending,
23630                    NodeEventEnvelope {
23631                        sequence: event_sequence,
23632                        event: NodeEvent::TerminalFrame {
23633                            address,
23634                            frame: terminal_frame(frame_sequence),
23635                        },
23636                    },
23637                    0,
23638                );
23639            }
23640        }
23641        assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX * 2);
23642        pending.sort_unstable_by_key(|event| event.sequence);
23643
23644        let mut uninterrupted = pending.clone();
23645        let first_burst = connection_event_burst_len(uninterrupted.len());
23646        uninterrupted.drain(..first_burst);
23647        let second_burst = connection_event_burst_len(uninterrupted.len());
23648        uninterrupted.drain(..second_burst);
23649        assert!(uninterrupted.is_empty());
23650
23651        let (frame_tx, mut frame_rx) =
23652            mpsc::channel::<Result<ClientFrame, FrameError>>(1);
23653        frame_tx
23654            .try_send(Ok(ClientFrame::Request(RequestEnvelope {
23655                request_id: 77,
23656                request: NodeRequest::Snapshot,
23657            })))
23658            .unwrap();
23659        let burst_len = connection_event_burst_len(pending.len());
23660        assert_eq!(burst_len, NODE_CONNECTION_EVENT_BURST_MAX);
23661        pending.drain(..burst_len);
23662        assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX);
23663        assert!(matches!(
23664            frame_rx.try_recv(),
23665            Ok(Ok(ClientFrame::Request(RequestEnvelope {
23666                request_id: 77,
23667                request: NodeRequest::Snapshot,
23668            })))
23669        ));
23670    }
23671
23672    #[test]
23673    fn terminal_lag_watermark_discards_retained_and_replayed_batches() {
23674        let mut pending = Vec::new();
23675        for sequence in [7_u64, 8] {
23676            let mut address = terminal_address(1);
23677            address.session.instance_id = AgentInstanceId(sequence);
23678            queue_connection_event(
23679                &mut pending,
23680                NodeEventEnvelope {
23681                    sequence,
23682                    event: NodeEvent::TerminalFrame {
23683                        address,
23684                        frame: terminal_frame(sequence),
23685                    },
23686                },
23687                0,
23688            );
23689        }
23690        let mut discard_through = 0;
23691        apply_connection_resync_watermark(&mut pending, &mut discard_through, 8);
23692        assert!(pending.is_empty());
23693
23694        let replayed = NodeEventEnvelope {
23695            sequence: 8,
23696            event: NodeEvent::TerminalFrame {
23697                address: terminal_address(1),
23698                frame: terminal_frame(8),
23699            },
23700        };
23701        let advanced = NodeEventEnvelope {
23702            sequence: 9,
23703            event: NodeEvent::TerminalFrame {
23704                address: terminal_address(2),
23705                frame: terminal_frame(1),
23706            },
23707        };
23708        queue_connection_event_batch(
23709            &mut pending,
23710            &[replayed, advanced.clone()],
23711            discard_through,
23712        );
23713        assert_eq!(pending, vec![advanced]);
23714    }
23715
23716    fn record(provider: &str, record_id: &str) -> ManagedSessionRecord {
23717        ManagedSessionRecord {
23718            record_id: SessionRecordId::new(record_id).unwrap(),
23719            display_name: format!("{provider} record"),
23720            provider: agent(provider),
23721            mode: SessionMode::Pty,
23722            state: ManagedSessionState::Unavailable,
23723            workspace_id: WorkspaceId::new("primary").unwrap(),
23724            canonical_root: opaque_windows_path(
23725                std::env::current_dir().unwrap().to_string_lossy().into_owned(),
23726            ),
23727            provider_session: None,
23728            active_session: None,
23729            environment_profile: None,
23730            bundle: None,
23731            context_id: None,
23732            context: None,
23733            exported_context: None,
23734            task_binding: None,
23735            created_at_unix_ms: 1,
23736            updated_at_unix_ms: 1,
23737            last_error: None,
23738        }
23739    }
23740
23741    fn retention_test_record(
23742        workspace_id: &str,
23743        record_id: &str,
23744        state: ManagedSessionState,
23745        updated_at_unix_ms: u64,
23746    ) -> ManagedSessionRecord {
23747        ManagedSessionRecord {
23748            record_id: SessionRecordId::new(record_id).unwrap(),
23749            display_name: format!("{record_id} display"),
23750            provider: agent("claude"),
23751            mode: SessionMode::Pty,
23752            state,
23753            workspace_id: WorkspaceId::new(workspace_id).unwrap(),
23754            canonical_root: opaque_windows_path(
23755                std::env::current_dir().unwrap().to_string_lossy().into_owned(),
23756            ),
23757            provider_session: None,
23758            active_session: None,
23759            environment_profile: None,
23760            bundle: None,
23761            context_id: None,
23762            context: None,
23763            exported_context: None,
23764            task_binding: None,
23765            created_at_unix_ms: updated_at_unix_ms,
23766            updated_at_unix_ms,
23767            last_error: None,
23768        }
23769    }
23770
23771    /// Two workspaces of three `Unavailable` records each, staggered by
23772    /// age, plus one never-touchable decoy per non-`Unavailable` state in
23773    /// `workspace-a` (all as old as that workspace's oldest real record --
23774    /// if the sweep's state gate ever leaked, these would be the first
23775    /// thing it wrongly retired). Ages, oldest to newest:
23776    /// `workspace-a`: a1 (500s), a2 (300s), a3 (100s).
23777    /// `workspace-b`: b1 (400s), b2 (200s), b3 (50s).
23778    fn retention_fixture_records(now: u64) -> Vec<ManagedSessionRecord> {
23779        vec![
23780            retention_test_record("workspace-a", "a1", ManagedSessionState::Unavailable, now - 500_000),
23781            retention_test_record("workspace-a", "a2", ManagedSessionState::Unavailable, now - 300_000),
23782            retention_test_record("workspace-a", "a3", ManagedSessionState::Unavailable, now - 100_000),
23783            retention_test_record("workspace-b", "b1", ManagedSessionState::Unavailable, now - 400_000),
23784            retention_test_record("workspace-b", "b2", ManagedSessionState::Unavailable, now - 200_000),
23785            retention_test_record("workspace-b", "b3", ManagedSessionState::Unavailable, now - 50_000),
23786            retention_test_record("workspace-a", "decoy-live", ManagedSessionState::Live, now - 500_000),
23787            retention_test_record(
23788                "workspace-a",
23789                "decoy-pending",
23790                ManagedSessionState::IdentityPending,
23791                now - 500_000,
23792            ),
23793            retention_test_record(
23794                "workspace-a",
23795                "decoy-dormant",
23796                ManagedSessionState::Dormant,
23797                now - 500_000,
23798            ),
23799        ]
23800    }
23801
23802    fn retention_fixture_shared() -> (NodeShared, NativeRuntime, PathBuf, u64) {
23803        let root = temporary_workspace_root("session-record-retention");
23804        std::fs::create_dir_all(&root).unwrap();
23805        let catalog = active_registry().unwrap();
23806        let (handle, runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23807        let workspace_a =
23808            WorkspaceConfig::new(WorkspaceId::new("workspace-a").unwrap(), &root).unwrap();
23809        let workspace_b =
23810            WorkspaceConfig::new(WorkspaceId::new("workspace-b").unwrap(), &root).unwrap();
23811        let mut shared = NodeShared::new(
23812            handle,
23813            "fixture-token".to_owned(),
23814            NodeId::new("node-retention-test").unwrap(),
23815            vec![workspace_a, workspace_b],
23816            vec![agent("claude")],
23817        );
23818        shared.state_path = Some(root.join("node-state-v10.json"));
23819        let now = unix_time_ms();
23820        for record in retention_fixture_records(now) {
23821            shared.insert_record(record).unwrap();
23822        }
23823        shared
23824            .persist_state_locked()
23825            .expect("fixture-only, empty-history persist against a fresh temp state path");
23826        (shared, runtime, root, now)
23827    }
23828
23829    fn retention_removed_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23830        shared
23831            .history
23832            .lock()
23833            .unwrap_or_else(|poisoned| poisoned.into_inner())
23834            .events
23835            .iter()
23836            .filter_map(|envelope| match &envelope.event {
23837                NodeEvent::SessionRecordRemoved { record_id } => {
23838                    Some(record_id.as_str().to_owned())
23839                }
23840                _ => None,
23841            })
23842            .collect()
23843    }
23844
23845    fn retention_remaining_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23846        shared
23847            .session_records
23848            .lock()
23849            .unwrap_or_else(|poisoned| poisoned.into_inner())
23850            .records
23851            .keys()
23852            .map(|record_id| record_id.as_str().to_owned())
23853            .collect()
23854    }
23855
23856    fn owned(values: &[&str]) -> std::collections::BTreeSet<String> {
23857        values.iter().map(|value| (*value).to_owned()).collect()
23858    }
23859
23860    #[tokio::test]
23861    async fn session_record_retention_keep_n_only_keeps_the_newest_per_workspace() {
23862        let (mut shared, runtime, root, _now) = retention_fixture_shared();
23863        shared.session_record_retention = SessionRecordRetentionConfig {
23864            age_ms: 0,
23865            keep_per_workspace: 1,
23866        };
23867        shared.retire_unavailable_session_records().await;
23868
23869        assert_eq!(
23870            retention_remaining_record_ids(&shared),
23871            owned(&["a3", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23872        );
23873        assert_eq!(
23874            retention_removed_record_ids(&shared),
23875            owned(&["a1", "a2", "b1", "b2"]),
23876        );
23877        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 4);
23878
23879        drop(shared);
23880        drop(runtime);
23881        std::fs::remove_dir_all(root).unwrap();
23882    }
23883
23884    /// Age-only would retire {a1, a2, b1}; keep-N-only would retire
23885    /// {a1, a2, b1, b2}. With both flags set the sweep must retire exactly
23886    /// the intersection {a1, a2, b1} -- `b2` fails the age test (it is only
23887    /// 200s old against a 250s threshold) even though it fails the keep-N
23888    /// test too, proving retirement needs BOTH, not either.
23889    #[tokio::test]
23890    async fn session_record_retention_both_set_retires_only_the_intersection() {
23891        let (mut shared, runtime, root, _now) = retention_fixture_shared();
23892        shared.session_record_retention = SessionRecordRetentionConfig {
23893            age_ms: 250_000,
23894            keep_per_workspace: 1,
23895        };
23896        shared.retire_unavailable_session_records().await;
23897
23898        assert_eq!(
23899            retention_remaining_record_ids(&shared),
23900            owned(&["a3", "b2", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23901        );
23902        assert_eq!(retention_removed_record_ids(&shared), owned(&["a1", "a2", "b1"]));
23903        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 3);
23904
23905        drop(shared);
23906        drop(runtime);
23907        std::fs::remove_dir_all(root).unwrap();
23908    }
23909
23910    #[tokio::test]
23911    async fn session_record_retention_both_disabled_is_a_no_op() {
23912        let (mut shared, runtime, root, _now) = retention_fixture_shared();
23913        shared.session_record_retention = SessionRecordRetentionConfig::default();
23914        shared.retire_unavailable_session_records().await;
23915
23916        assert_eq!(
23917            retention_remaining_record_ids(&shared),
23918            owned(&[
23919                "a1", "a2", "a3", "b1", "b2", "b3",
23920                "decoy-live", "decoy-pending", "decoy-dormant",
23921            ]),
23922        );
23923        assert!(retention_removed_record_ids(&shared).is_empty());
23924        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 0);
23925
23926        drop(shared);
23927        drop(runtime);
23928        std::fs::remove_dir_all(root).unwrap();
23929    }
23930
23931    #[test]
23932    fn session_record_context_export_selects_one_exact_history_identity() {
23933        let candidates = vec![
23934            HistoryCandidateSummary {
23935                id: "candidate-other".to_owned(),
23936                session_id_hint: "provider-other".to_owned(),
23937                modified_at_unix_ms: Some(1),
23938            },
23939            HistoryCandidateSummary {
23940                id: "candidate-exact".to_owned(),
23941                session_id_hint: "provider-exact".to_owned(),
23942                modified_at_unix_ms: Some(2),
23943            },
23944        ];
23945        assert_eq!(
23946            select_history_candidate(&candidates, Some("provider-exact")).unwrap(),
23947            "candidate-exact",
23948        );
23949    }
23950
23951    #[test]
23952    fn session_record_context_export_rejects_missing_and_ambiguous_history_identity() {
23953        let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
23954            id: id.to_owned(),
23955            session_id_hint: hint.to_owned(),
23956            modified_at_unix_ms: None,
23957        };
23958        let missing = select_history_candidate(
23959            &[candidate("candidate-other", "provider-other")],
23960            Some("provider-exact"),
23961        )
23962        .unwrap_err();
23963        assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23964        let ambiguous = select_history_candidate(
23965            &[
23966                candidate("candidate-a", "provider-exact"),
23967                candidate("candidate-b", "provider-exact"),
23968            ],
23969            Some("provider-exact"),
23970        )
23971        .unwrap_err();
23972        assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
23973    }
23974
23975    /// The PTY-sidecar-observation shape (no fleet provider today, but
23976    /// capability-keyed rather than vendor-locked) never carries a verified
23977    /// `provider_session`; its record-bound export instead falls back to
23978    /// "exactly one discovered candidate" (see
23979    /// `export_context_pack_for_session_record_inner`). This is the same
23980    /// ambiguity refusal as the identity-filtered path, just without an
23981    /// identity to filter by.
23982    #[test]
23983    fn session_record_context_export_selects_sole_history_candidate_without_identity() {
23984        let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
23985            id: id.to_owned(),
23986            session_id_hint: hint.to_owned(),
23987            modified_at_unix_ms: None,
23988        };
23989        assert_eq!(
23990            select_history_candidate(&[candidate("candidate-sole", "sidecar-session")], None)
23991                .unwrap(),
23992            "candidate-sole",
23993        );
23994        let missing = select_history_candidate(&[], None).unwrap_err();
23995        assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23996        let ambiguous = select_history_candidate(
23997            &[
23998                candidate("candidate-a", "sidecar-session-a"),
23999                candidate("candidate-b", "sidecar-session-b"),
24000            ],
24001            None,
24002        )
24003        .unwrap_err();
24004        assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
24005    }
24006
24007    #[test]
24008    fn session_record_context_export_revalidation_rejects_record_change() {
24009        let session = terminal_address(1);
24010        let identity = ProviderSessionIdentity {
24011            key: ProviderSessionKey::SessionId,
24012            id: "provider-exact".to_owned(),
24013            transcript_path: Some(r"C:\private\provider.jsonl".to_owned()),
24014        };
24015        let mut expected = record("claude", "record-context-export");
24016        expected.state = ManagedSessionState::Live;
24017        expected.provider_session = Some(identity.clone());
24018        expected.active_session = Some(session.clone());
24019        let binding = SessionBinding {
24020            workspace_id: session.workspace_id.clone(),
24021            generation: session.session.generation,
24022            runtime_policy: ProviderRuntimePolicy::raw_pty(),
24023            pending_resume: None,
24024            record_id: Some(expected.record_id.clone()),
24025            managed_worktree_lease_id: None,
24026            environment_profile: None,
24027            bundle: None,
24028            context: None,
24029            materialization_id: None,
24030        };
24031        let current_root = windows_path_text(&expected.canonical_root).to_owned();
24032        assert!(session_record_context_export_binding_is_exact(
24033            &expected,
24034            &expected,
24035            Some(&identity),
24036            &session,
24037            &binding,
24038            &expected.provider,
24039            &current_root,
24040            false,
24041        ));
24042        let mut changed = expected.clone();
24043        changed.updated_at_unix_ms += 1;
24044        assert!(!session_record_context_export_binding_is_exact(
24045            &expected,
24046            &changed,
24047            Some(&identity),
24048            &session,
24049            &binding,
24050            &expected.provider,
24051            &current_root,
24052            false,
24053        ));
24054        // A same-instant timestamp bump alone (state and active_session
24055        // otherwise unchanged) is not the clean-detachment transition
24056        // either, even with the reactive flag on: only the exact Live ->
24057        // Dormant + active_session cleared shape qualifies.
24058        assert!(!session_record_context_export_binding_is_exact(
24059            &expected,
24060            &changed,
24061            Some(&identity),
24062            &session,
24063            &binding,
24064            &expected.provider,
24065            &current_root,
24066            true,
24067        ));
24068        let mut detached = expected.clone();
24069        detached.state = ManagedSessionState::Dormant;
24070        detached.active_session = None;
24071        detached.updated_at_unix_ms += 1;
24072        assert!(!session_record_context_export_binding_is_exact(
24073            &expected,
24074            &detached,
24075            Some(&identity),
24076            &session,
24077            &binding,
24078            &expected.provider,
24079            &current_root,
24080            false,
24081        ));
24082        assert!(session_record_context_export_binding_is_exact(
24083            &expected,
24084            &detached,
24085            Some(&identity),
24086            &session,
24087            &binding,
24088            &expected.provider,
24089            &current_root,
24090            true,
24091        ));
24092    }
24093
24094    /// Mirrors `session_record_context_export_revalidation_rejects_record_change`
24095    /// for a record whose provider never carries a verified identity (the
24096    /// PTY-sidecar-observation shape -- capability-keyed, no fleet provider
24097    /// declares one today): `identity` is `None` throughout, and the
24098    /// exactness check falls back to comparing `provider_session` itself,
24099    /// which must still be `None` on both sides.
24100    #[test]
24101    fn session_record_context_export_binding_is_exact_without_identity() {
24102        let session = terminal_address(1);
24103        let mut expected = record("pty-sidecar-fixture", "record-context-export-no-identity");
24104        expected.state = ManagedSessionState::Live;
24105        expected.active_session = Some(session.clone());
24106        let binding = SessionBinding {
24107            workspace_id: session.workspace_id.clone(),
24108            generation: session.session.generation,
24109            runtime_policy: ProviderRuntimePolicy::raw_pty(),
24110            pending_resume: None,
24111            record_id: Some(expected.record_id.clone()),
24112            managed_worktree_lease_id: None,
24113            environment_profile: None,
24114            bundle: None,
24115            context: None,
24116            materialization_id: None,
24117        };
24118        let current_root = windows_path_text(&expected.canonical_root).to_owned();
24119        assert!(session_record_context_export_binding_is_exact(
24120            &expected,
24121            &expected,
24122            None,
24123            &session,
24124            &binding,
24125            &expected.provider,
24126            &current_root,
24127            false,
24128        ));
24129        // A record that gained an identity between the initial lookup and
24130        // this recheck no longer matches the `None` this shape expects to
24131        // keep seeing -- treated the same as any other mid-export drift.
24132        let mut drifted = expected.clone();
24133        drifted.provider_session = Some(ProviderSessionIdentity {
24134            key: ProviderSessionKey::SessionId,
24135            id: "unexpected-identity".to_owned(),
24136            transcript_path: None,
24137        });
24138        assert!(!session_record_context_export_binding_is_exact(
24139            &expected,
24140            &drifted,
24141            None,
24142            &session,
24143            &binding,
24144            &expected.provider,
24145            &current_root,
24146            false,
24147        ));
24148    }
24149
24150    #[test]
24151    fn session_record_context_export_preflight_requires_live_usable_source() {
24152        let mut source = record("claude", "record-context-export-preflight");
24153        source.state = ManagedSessionState::Live;
24154        assert!(session_record_context_export_source_is_usable(
24155            &source,
24156            &SessionStatus::Running,
24157            false,
24158        ));
24159        assert!(session_record_context_export_source_is_usable(
24160            &source,
24161            &SessionStatus::Exited { exit_code: Some(0) },
24162            false,
24163        ));
24164
24165        source.state = ManagedSessionState::Dormant;
24166        assert!(!session_record_context_export_source_is_usable(
24167            &source,
24168            &SessionStatus::Running,
24169            false,
24170        ));
24171        // The reactive auto-export-at-exit path (allow_clean_detachment) is
24172        // the only caller that may treat a Dormant, cleanly-exited record as
24173        // still usable — the on-demand path above never does.
24174        assert!(session_record_context_export_source_is_usable(
24175            &source,
24176            &SessionStatus::Exited { exit_code: Some(0) },
24177            true,
24178        ));
24179        assert!(!session_record_context_export_source_is_usable(
24180            &source,
24181            &SessionStatus::Exited { exit_code: Some(0) },
24182            false,
24183        ));
24184        source.state = ManagedSessionState::Live;
24185        assert!(!session_record_context_export_source_is_usable(
24186            &source,
24187            &SessionStatus::Starting,
24188            false,
24189        ));
24190    }
24191
24192    #[test]
24193    fn session_record_context_export_final_conflict_leaves_catalog_empty() {
24194        let catalog = active_registry().unwrap();
24195        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24196        let workspace = WorkspaceConfig::new(
24197            WorkspaceId::new("primary").unwrap(),
24198            std::env::current_dir().unwrap(),
24199        )
24200        .unwrap();
24201        let shared = NodeShared::new(
24202            handle,
24203            "fixture-token".to_owned(),
24204            NodeId::new("node-context-export-conflict").unwrap(),
24205            vec![workspace],
24206            vec![agent("claude")],
24207        );
24208        let history = HistorySessionRecord {
24209            session_id: "provider-exact".to_owned(),
24210            title: None,
24211            cwd: None,
24212            model: None,
24213            message_count: 1,
24214            completed_turn_count: None,
24215            total_tokens: 0,
24216            messages: vec![gate4agent_types::HistoryMessageRecord {
24217                role: gate4agent_types::HistoryMessageRole::User,
24218                text: "bounded context".to_owned(),
24219            }],
24220        };
24221        let pack = NodeContextPack::export(
24222            ContextPackLineageReceipt {
24223                source_node_id: shared.node_id.clone(),
24224                source_session: terminal_address(1),
24225                source_provider: agent("claude"),
24226            },
24227            &history,
24228        )
24229        .unwrap();
24230        let context_id = pack.receipt().id.clone();
24231        let session = terminal_address(1);
24232        let identity = ProviderSessionIdentity {
24233            key: ProviderSessionKey::SessionId,
24234            id: history.session_id.clone(),
24235            transcript_path: None,
24236        };
24237        let mut expected = record("claude", "record-context-export-final-conflict");
24238        expected.state = ManagedSessionState::Live;
24239        expected.provider_session = Some(identity.clone());
24240        expected.active_session = Some(session.clone());
24241        let mut changed = expected.clone();
24242        changed.updated_at_unix_ms += 1;
24243        shared.insert_record(changed).unwrap();
24244
24245        let error = shared
24246            .commit_context_pack_for_session_record(
24247                &expected,
24248                Some(&identity),
24249                &session,
24250                "candidate-exact",
24251                &history,
24252                pack,
24253                false,
24254            )
24255            .unwrap_err();
24256
24257        assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
24258        assert!(shared
24259            .context_catalog
24260            .read()
24261            .unwrap_or_else(|poisoned| poisoned.into_inner())
24262            .get(&context_id)
24263            .is_none());
24264    }
24265
24266    #[tokio::test]
24267    async fn session_task_binding_mints_assigns_clears_with_cas_and_persists_v9() {
24268        let root = temporary_workspace_root("session-task-binding");
24269        std::fs::create_dir_all(&root).unwrap();
24270        let state_path = root.join("node-state.json");
24271        let catalog = active_registry().unwrap();
24272        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24273        let workspace = WorkspaceConfig::new(WorkspaceId::new("primary").unwrap(), &root).unwrap();
24274        let node_id = NodeId::new("node-1").unwrap();
24275        let mut shared = NodeShared::new(
24276            handle,
24277            "fixture-token".to_owned(),
24278            node_id.clone(),
24279            vec![workspace],
24280            vec![agent("claude")],
24281        );
24282        shared.state_path = Some(state_path.clone());
24283        let record = record("claude", "sr-task");
24284        let record_id = record.record_id.clone();
24285        shared.insert_record(record).unwrap();
24286        shared.persist_state().unwrap();
24287        shared
24288            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
24289            .unwrap();
24290
24291        let minted = process_request_inner(
24292            &shared,
24293            77,
24294            ClientRole::Operator,
24295            NodeRequest::SetSessionTask {
24296                record_id: record_id.clone(),
24297                expected_revision: 0,
24298                target: SessionTaskTargetV1::New,
24299            },
24300        )
24301        .await
24302        .unwrap();
24303        let NodeResponse::SessionRecordUpdated { record: minted } = minted else {
24304            panic!("session task mutation returned another response");
24305        };
24306        let minted_binding = minted.task_binding.clone().unwrap();
24307        let minted_task_id = minted_binding.task_id.clone().unwrap();
24308        assert_eq!(minted_binding.revision, 1);
24309        assert!(minted_task_id.as_str().starts_with("task-"));
24310        assert_eq!(minted_task_id.as_str().len(), 29);
24311
24312        let event_count = shared.current_sequence();
24313        let repeated = process_request_inner(
24314            &shared,
24315            77,
24316            ClientRole::Operator,
24317            NodeRequest::SetSessionTask {
24318                record_id: record_id.clone(),
24319                expected_revision: 1,
24320                target: SessionTaskTargetV1::Existing {
24321                    task_id: minted_task_id.clone(),
24322                },
24323            },
24324        )
24325        .await
24326        .unwrap();
24327        assert_eq!(repeated, NodeResponse::SessionRecordUpdated { record: minted.clone() });
24328        assert_eq!(shared.current_sequence(), event_count);
24329
24330        let stale = process_request_inner(
24331            &shared,
24332            77,
24333            ClientRole::Operator,
24334            NodeRequest::SetSessionTask {
24335                record_id: record_id.clone(),
24336                expected_revision: 0,
24337                target: SessionTaskTargetV1::Existing {
24338                    task_id: minted_task_id,
24339                },
24340            },
24341        )
24342        .await
24343        .unwrap_err();
24344        assert_eq!(stale.code, NodeFailureCode::SessionRecordConflict);
24345
24346        let assigned_id: TaskId = "task-ffeeddccbbaa998877665544".parse().unwrap();
24347        let assigned = process_request_inner(
24348            &shared,
24349            77,
24350            ClientRole::Operator,
24351            NodeRequest::SetSessionTask {
24352                record_id: record_id.clone(),
24353                expected_revision: 1,
24354                target: SessionTaskTargetV1::Existing {
24355                    task_id: assigned_id.clone(),
24356                },
24357            },
24358        )
24359        .await
24360        .unwrap();
24361        let NodeResponse::SessionRecordUpdated { record: assigned } = assigned else {
24362            panic!("session task assignment returned another response");
24363        };
24364        assert_eq!(assigned.task_binding.as_ref().unwrap().revision, 2);
24365        assert_eq!(assigned.task_binding.as_ref().unwrap().task_id.as_ref(), Some(&assigned_id));
24366
24367        let cleared = process_request_inner(
24368            &shared,
24369            77,
24370            ClientRole::Operator,
24371            NodeRequest::SetSessionTask {
24372                record_id: record_id.clone(),
24373                expected_revision: 2,
24374                target: SessionTaskTargetV1::Clear,
24375            },
24376        )
24377        .await
24378        .unwrap();
24379        let NodeResponse::SessionRecordUpdated { record: cleared } = cleared else {
24380            panic!("session task clear returned another response");
24381        };
24382        assert_eq!(cleared.task_binding.as_ref().unwrap().revision, 3);
24383        assert!(cleared.task_binding.as_ref().unwrap().task_id.is_none());
24384        assert!(cleared.task_binding.as_ref().unwrap().changed_at_unix_ms > 0);
24385        assert_eq!(
24386            cleared.task_binding.as_ref().unwrap().changed_at_unix_ms,
24387            cleared.updated_at_unix_ms,
24388        );
24389
24390        let persisted = std::fs::read(&state_path).unwrap();
24391        assert_eq!(serde_json::from_slice::<serde_json::Value>(&persisted).unwrap()["version"], NODE_STATE_SCHEMA_V10);
24392        let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
24393        assert_eq!(loaded.records[0].task_binding, cleared.task_binding);
24394        std::fs::remove_dir_all(root).unwrap();
24395    }
24396
24397    #[test]
24398    fn session_task_capability_strips_snapshots_events_and_replies() {
24399        let mut bound = record("claude", "sr-task-projection");
24400        bound.updated_at_unix_ms = 2;
24401        bound.task_binding = Some(SessionTaskBindingV1 {
24402            revision: 1,
24403            task_id: Some("task-00112233445566778899aaff".parse().unwrap()),
24404            changed_at_unix_ms: 2,
24405        });
24406        let request = NodeRequest::SetSessionTask {
24407            record_id: bound.record_id.clone(),
24408            expected_revision: 1,
24409            target: SessionTaskTargetV1::Clear,
24410        };
24411        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
24412            capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
24413        }));
24414        assert!(!request_uses_unnegotiated_capability(
24415            &request,
24416            &baseline_capabilities().unwrap(),
24417        ));
24418        assert!(request_uses_unnegotiated_capability(&request, &[]));
24419
24420        let mut snapshot = terminal_test_shared().snapshot();
24421        snapshot.session_records.push(bound.clone());
24422        let mut reply = ResponseEnvelope {
24423            request_id: 1,
24424            result: Ok(NodeResponse::Resync {
24425                event_sequence: 1,
24426                oldest_available_sequence: 1,
24427                snapshot,
24428                events: vec![NodeEventEnvelope {
24429                    sequence: 1,
24430                    event: NodeEvent::SessionRecordUpserted {
24431                        record: bound.clone(),
24432                    },
24433                }],
24434            }),
24435        };
24436        project_response_without_session_task_binding(&mut reply);
24437        let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
24438            panic!("task projection changed the response kind");
24439        };
24440        assert!(snapshot.session_records[0].task_binding.is_none());
24441        let NodeEvent::SessionRecordUpserted { record } = &events[0].event else {
24442            panic!("task projection changed the event kind");
24443        };
24444        assert!(record.task_binding.is_none());
24445
24446        let projected = project_event_without_session_task_binding(NodeEventEnvelope {
24447            sequence: 2,
24448            event: NodeEvent::SessionRecordUpserted { record: bound.clone() },
24449        });
24450        let NodeEvent::SessionRecordUpserted { record } = projected.event else {
24451            panic!("task event projection changed the event kind");
24452        };
24453        assert!(record.task_binding.is_none());
24454
24455        let mut direct = ResponseEnvelope {
24456            request_id: 2,
24457            result: Ok(NodeResponse::SessionRecordUpdated { record: bound }),
24458        };
24459        project_response_without_session_task_binding(&mut direct);
24460        let Ok(NodeResponse::SessionRecordUpdated { record }) = direct.result else {
24461            panic!("task reply projection changed the response kind");
24462        };
24463        assert!(record.task_binding.is_none());
24464    }
24465
24466    #[test]
24467    fn windows_runtime_default_node_endpoint_is_exact_and_valid() {
24468        assert_eq!(DEFAULT_NODE_ENDPOINT, r"\\.\pipe\gate4agent-node");
24469        let workspace = WorkspaceConfig::new(
24470            WorkspaceId::new("primary").unwrap(),
24471            std::env::current_dir().unwrap(),
24472        )
24473        .unwrap();
24474        let config = NodeServerConfig::new(
24475            DEFAULT_NODE_ENDPOINT,
24476            "fixture-token",
24477            NodeId::new("node-1").unwrap(),
24478            [workspace],
24479        )
24480        .unwrap();
24481        assert_eq!(config.endpoint, DEFAULT_NODE_ENDPOINT);
24482    }
24483
24484    #[cfg(all(feature = "fixture", windows))]
24485    #[test]
24486    fn provider_bundle_argv_hold_fixture_is_parent_bounded_and_waits_after_proof() {
24487        let root = temporary_workspace_root("provider-bundle-argv-hold");
24488        let outside = temporary_workspace_root("provider-bundle-argv-hold-outside");
24489        std::fs::create_dir_all(&root).unwrap();
24490        std::fs::create_dir_all(&outside).unwrap();
24491        let proof_path = root.join("provider.proof");
24492        let release_signal = root.join("release.signal");
24493        let config = NodeServerConfig::new(
24494            format!(
24495                r"\\.\pipe\gate4agent-provider-bundle-hold-{}",
24496                std::process::id(),
24497            ),
24498            "fixture-token",
24499            NodeId::new("provider-bundle-hold-node").unwrap(),
24500            [WorkspaceConfig::new(
24501                WorkspaceId::new("primary").unwrap(),
24502                std::env::current_dir().unwrap(),
24503            )
24504            .unwrap()],
24505        )
24506        .unwrap()
24507        .with_spawn_profiles(SpawnProfileRegistry::new([
24508            crate::protocol::SpawnProfileDefaults {
24509                profile_id: crate::protocol::SpawnProfileId::new("kimi-bundle-hold").unwrap(),
24510                revision: crate::protocol::SpawnProfileRevision::new("kimi-bundle-hold-r1")
24511                    .unwrap(),
24512                provider: agent("kimi"),
24513                mode: SessionMode::Pty,
24514                terminal_size: gate4agent_types::TerminalSize {
24515                    rows: 24,
24516                    columns: 80,
24517                },
24518                prompt: None,
24519                bundle_id: None,
24520                context_id: None,
24521                environment_profile_id: None,
24522            },
24523        ])
24524        .unwrap());
24525        let server = NodeServer::new_provider_bundle_argv_hold_fixture(
24526            config,
24527            agent("kimi"),
24528            proof_path.clone(),
24529            release_signal.clone(),
24530        )
24531        .unwrap();
24532        assert_eq!(server.shared.enabled_providers, [agent("kimi")]);
24533
24534        let spec = NodeServer::provider_bundle_argv_fixture_spec(
24535            agent("kimi"),
24536            proof_path.clone(),
24537            Some(release_signal.clone()),
24538        )
24539        .unwrap();
24540        let wrapper = &spec.launch.fixed_args[spec.launch.fixed_args.len() - 3];
24541        let proof_write = wrapper.find("WriteAllLines($proofPath").unwrap();
24542        let release_wait = wrapper
24543            .find("while (-not (Test-Path -LiteralPath $releaseSignal -PathType Leaf))")
24544            .unwrap();
24545        let fixture_ready = wrapper.find("fixture-ready>").unwrap();
24546        assert!(proof_write < release_wait);
24547        assert!(release_wait < fixture_ready);
24548        assert!(wrapper.contains("[DateTime]::UtcNow.AddSeconds(45)"));
24549        assert!(wrapper.contains("Start-Sleep -Milliseconds 20"));
24550        assert_eq!(
24551            &spec.launch.fixed_args[spec.launch.fixed_args.len() - 2..],
24552            &[
24553                proof_path.to_string_lossy().into_owned(),
24554                release_signal.to_string_lossy().into_owned(),
24555            ],
24556        );
24557
24558        let error = NodeServer::provider_bundle_argv_fixture_spec(
24559            agent("kimi"),
24560            proof_path,
24561            Some(outside.join("release.signal")),
24562        )
24563        .unwrap_err();
24564        assert!(matches!(
24565            error,
24566            NodeServerError::Registry(message)
24567                if message == "provider bundle argv release path must share the proof parent"
24568        ));
24569        drop(server);
24570        std::fs::remove_dir_all(root).unwrap();
24571        std::fs::remove_dir_all(outside).unwrap();
24572    }
24573
24574    #[cfg(feature = "fixture")]
24575    #[test]
24576    fn four_provider_context_fixture_registry_is_unambiguous_and_history_bound() {
24577        let root = temporary_workspace_root("context-fixture-registry");
24578        std::fs::create_dir_all(&root).unwrap();
24579        let proof_path = root.join("context.proof");
24580        let config = NodeServerConfig::new(
24581            format!(
24582                r"\\.\pipe\gate4agent-context-fixture-registry-{}",
24583                std::process::id(),
24584            ),
24585            "fixture-token",
24586            NodeId::new("context-fixture-node").unwrap(),
24587            [WorkspaceConfig::new(
24588                WorkspaceId::new("primary").unwrap(),
24589                std::env::current_dir().unwrap(),
24590            )
24591            .unwrap()],
24592        )
24593        .unwrap();
24594        let server = NodeServer::new_context_pack_fixture(config, proof_path).unwrap();
24595        let mut enabled = server
24596            .shared
24597            .enabled_providers
24598            .iter()
24599            .map(AgentId::as_str)
24600            .collect::<Vec<_>>();
24601        enabled.sort_unstable();
24602        assert_eq!(enabled, ["claude", "codex", "grok", "kimi"]);
24603        for provider in &server.shared.enabled_providers {
24604            assert!(server
24605                .shared
24606                .provider_adapter_contracts
24607                .iter()
24608                .any(|contract| {
24609                    &contract.provider == provider
24610                        && contract.family == AdapterFamily::History
24611                }));
24612        }
24613        drop(server);
24614        std::fs::remove_dir_all(root).unwrap();
24615    }
24616
24617    #[cfg(feature = "fixture")]
24618    #[test]
24619    fn monitoring_context_fixture_combines_claude_and_codex_hooks_with_history() {
24620        let root = temporary_workspace_root("monitoring-context-fixture");
24621        std::fs::create_dir_all(&root).unwrap();
24622        let proof_path = root.join("context.proof");
24623        let standard = NodeServer::context_pack_fixture_catalog(
24624            proof_path.clone(),
24625            None,
24626            false,
24627        ).unwrap();
24628        let monitoring = NodeServer::context_pack_fixture_catalog(
24629            proof_path.clone(),
24630            None,
24631            true,
24632        ).unwrap();
24633        let claude = AgentId::new("claude").unwrap();
24634        let standard_claude = standard.get(&claude).unwrap();
24635        let monitoring_claude = monitoring.get(&claude).unwrap();
24636        // Lifecycle hooks are retired (owner ruling 2026-09-25): neither the
24637        // standard nor the monitoring fixture declares a hook adapter
24638        // anymore.
24639        assert!(monitoring_claude.capabilities.adapters.hook.is_none());
24640        assert_eq!(
24641            monitoring_claude.capabilities.adapters.history,
24642            standard_claude.capabilities.adapters.history,
24643        );
24644        assert_ne!(monitoring_claude.launch, standard_claude.launch);
24645        let codex = AgentId::new("codex").unwrap();
24646        let standard_codex = standard.get(&codex).unwrap();
24647        let monitoring_codex = monitoring.get(&codex).unwrap();
24648        assert!(monitoring_codex.capabilities.adapters.hook.is_none());
24649        assert_eq!(
24650            monitoring_codex.capabilities.adapters.history,
24651            standard_codex.capabilities.adapters.history,
24652        );
24653        assert_ne!(monitoring_codex.launch, standard_codex.launch);
24654        let codex_validation = monitoring_codex.launch.fixed_args.iter()
24655            .find(|argument| argument.contains("F7_CODEX_BUNDLE_CONTEXT_VALIDATED"))
24656            .unwrap();
24657        for required in [
24658            "$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')",
24659            "$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')",
24660            "@('role', 'text')",
24661            "g4a-private-provider-session-canary",
24662            "private-provider-login-identity",
24663            "$rawProof",
24664            "$env:GATE4AGENT_HOOK_URL",
24665            "g4a-private-provider-session-canary",
24666        ] {
24667            assert!(codex_validation.contains(required), "missing privacy validation: {required}");
24668        }
24669        for provider in ["grok", "kimi"] {
24670            let provider = AgentId::new(provider).unwrap();
24671            assert_eq!(monitoring.get(&provider), standard.get(&provider));
24672        }
24673
24674        let config = NodeServerConfig::new(
24675            format!(
24676                r"\\.\pipe\gate4agent-monitoring-context-fixture-{}",
24677                std::process::id(),
24678            ),
24679            "fixture-token",
24680            NodeId::new("monitoring-context-fixture-node").unwrap(),
24681            [WorkspaceConfig::new(
24682                WorkspaceId::new("primary").unwrap(),
24683                std::env::current_dir().unwrap(),
24684            )
24685            .unwrap()],
24686        )
24687        .unwrap();
24688        let server = NodeServer::new_monitoring_context_pack_fixture(config, proof_path).unwrap();
24689        assert!(server.shared.fixture_semantic_hook_policy);
24690        drop(server);
24691        std::fs::remove_dir_all(root).unwrap();
24692    }
24693
24694    #[cfg(feature = "fixture")]
24695    #[test]
24696    fn kimi_context_only_fixture_is_bounded_and_preserves_codex_validation() {
24697        let root = temporary_workspace_root("kimi-context-only-fixture");
24698        std::fs::create_dir_all(&root).unwrap();
24699        let proof_path = root.join("context.proof");
24700        let standard = NodeServer::context_pack_fixture_catalog(
24701            proof_path.clone(),
24702            None,
24703            false,
24704        ).unwrap();
24705        let kimi = AgentId::new("kimi").unwrap();
24706        let context_only = NodeServer::context_pack_fixture_catalog(
24707            proof_path,
24708            Some(&kimi),
24709            false,
24710        )
24711        .unwrap();
24712
24713        let codex = AgentId::new("codex").unwrap();
24714        assert_eq!(
24715            standard.get(&codex).unwrap().launch.fixed_args,
24716            context_only.get(&codex).unwrap().launch.fixed_args,
24717        );
24718        let script = context_only
24719            .get(&kimi)
24720            .unwrap()
24721            .launch
24722            .fixed_args
24723            .iter()
24724            .find(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED"))
24725            .expect("Kimi context-only validation script is missing");
24726        for required in [
24727            "$bundleArgs.Count -ne 0",
24728            "GATE4AGENT_CONTEXT_ROOT",
24729            "context-pack.json",
24730            "$entries.Count -ne 1",
24731            "g4a-context-pack-v1",
24732            "$document.PSObject.Properties['cwd']",
24733            "'claude', 'codex', 'grok', 'kimi'",
24734            "$hasUser",
24735            "$hasAssistant",
24736            "SHA256",
24737            "context-only",
24738        ] {
24739            assert!(script.contains(required), "missing validation: {required}");
24740        }
24741        for forbidden in ["CODEX_HOME", "USERPROFILE", "auth", "token", "global config"] {
24742            assert!(!script.contains(forbidden), "forbidden home/auth access: {forbidden}");
24743        }
24744        assert!(!standard
24745            .get(&kimi)
24746            .unwrap()
24747            .launch
24748            .fixed_args
24749            .iter()
24750            .any(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED")));
24751
24752        std::fs::remove_dir_all(root).unwrap();
24753    }
24754
24755    #[cfg(all(feature = "fixture", windows))]
24756    #[tokio::test]
24757    async fn kimi_context_only_fixture_spawns_catalogued_context_pack_through_spawn_spec() {
24758        let root = temporary_workspace_root("kimi-context-only-spawn");
24759        let workspace_root = root.join("workspace");
24760        std::fs::create_dir_all(&workspace_root).unwrap();
24761        let materialization_root = root.join("private-materializations");
24762        let proof_path = root.join("context.proof");
24763        let node_id = NodeId::new("node-kimi-context-only-spawn").unwrap();
24764        let workspace_id = WorkspaceId::new("primary").unwrap();
24765        let profile_id = crate::protocol::SpawnProfileId::new("target-kimi").unwrap();
24766        let endpoint = format!(
24767            r"\\.\pipe\gate4agent-kimi-context-only-spawn-{}-{}",
24768            std::process::id(),
24769            unix_time_ms(),
24770        );
24771        let spawn_profiles = SpawnProfileRegistry::new([
24772            crate::protocol::SpawnProfileDefaults {
24773                profile_id: profile_id.clone(),
24774                revision: crate::protocol::SpawnProfileRevision::new("target-kimi-r1").unwrap(),
24775                provider: agent("kimi"),
24776                mode: SessionMode::Pty,
24777                terminal_size: gate4agent_types::TerminalSize {
24778                    rows: 30,
24779                    columns: 120,
24780                },
24781                prompt: None,
24782                bundle_id: None,
24783                context_id: None,
24784                environment_profile_id: None,
24785            },
24786        ])
24787        .unwrap();
24788        let mut config = NodeServerConfig::new(
24789            &endpoint,
24790            "fixture-token",
24791            node_id.clone(),
24792            [WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap()],
24793        )
24794        .unwrap()
24795        .with_spawn_profiles(spawn_profiles)
24796        .with_session_environment_materialization(
24797            materialization_root,
24798            Arc::new(FixtureSecretResolver {
24799                deny: Arc::new(AtomicBool::new(false)),
24800            }),
24801        )
24802        .unwrap();
24803        config.fixture_raw_pty_runtime = true;
24804        let server = NodeServer::new_context_only_proof_fixture(
24805            config,
24806            agent("kimi"),
24807            proof_path.clone(),
24808        )
24809        .unwrap();
24810        let shared = Arc::clone(&server.shared);
24811        let shutdown = server.shutdown_handle();
24812
24813        let history = HistorySessionRecord {
24814            session_id: "context-only-source-history".to_owned(),
24815            title: None,
24816            cwd: None,
24817            model: None,
24818            message_count: 2,
24819            completed_turn_count: None,
24820            total_tokens: 0,
24821            messages: vec![
24822                gate4agent_types::HistoryMessageRecord {
24823                    role: gate4agent_types::HistoryMessageRole::User,
24824                    text: "continue the bounded parent run through native C2".to_owned(),
24825                },
24826                gate4agent_types::HistoryMessageRecord {
24827                    role: gate4agent_types::HistoryMessageRole::Assistant,
24828                    text: "the exact parent context is ready for the child run".to_owned(),
24829                },
24830            ],
24831        };
24832        let pack = NodeContextPack::export(
24833            ContextPackLineageReceipt {
24834                source_node_id: node_id.clone(),
24835                source_session: SessionAddress {
24836                    workspace_id: workspace_id.clone(),
24837                    session: SessionKey {
24838                        instance_id: AgentInstanceId(71),
24839                        generation: SessionGeneration(1),
24840                    },
24841                },
24842                source_provider: agent("codex"),
24843            },
24844            &history,
24845        )
24846        .unwrap();
24847        let expected_context_hash = digest(&SHA256, pack.bytes())
24848            .as_ref()
24849            .iter()
24850            .map(|byte| format!("{byte:02x}"))
24851            .collect::<String>();
24852        let context = shared
24853            .context_catalog
24854            .write()
24855            .unwrap_or_else(|poisoned| poisoned.into_inner())
24856            .insert(pack)
24857            .unwrap();
24858        let spec = SpawnSpec {
24859            target: crate::protocol::SpawnTarget {
24860                node_id,
24861                workspace_id: workspace_id.clone(),
24862                worktree_id: None,
24863            },
24864            profile_id,
24865            expected_profile_revision: crate::protocol::SpawnProfileRevision::new(
24866                "target-kimi-r1",
24867            ).unwrap(),
24868            overrides: crate::protocol::SpawnOverrides {
24869                context_id: crate::protocol::SpawnOverride::Set {
24870                    value: context.id.clone(),
24871                },
24872                ..crate::protocol::SpawnOverrides::default()
24873            },
24874            deadline_ms: crate::protocol::SpawnDeadlineMs::new(10_000).unwrap(),
24875            idempotency_key: SpawnIdempotencyKey::new("kimi-context-only-spawn").unwrap(),
24876            required_capabilities: SpawnRequiredCapabilities::default(),
24877        };
24878        let resolved = match shared.resolve_spawn_spec(&spec) {
24879            Ok(resolved) => resolved,
24880            Err(error) => panic!(
24881                "Kimi context-only resolve rejected: exact_code={:?} message={}",
24882                error.code,
24883                error.message,
24884            ),
24885        };
24886        let resolved_context = match shared.resolve_context(&resolved) {
24887            Ok(Some(resolved_context)) => resolved_context,
24888            Ok(None) => panic!("Kimi context-only resolve omitted the exact ContextPack"),
24889            Err(error) => panic!(
24890                "Kimi context catalog resolve rejected: exact_code={:?} message={}",
24891                error.code,
24892                error.message,
24893            ),
24894        };
24895        assert_eq!(resolved_context, context);
24896        let runtime_policy = match shared
24897            .admit_provider_runtime(&agent("kimi"), ProviderRuntimeRequirement::RawPty)
24898            .await
24899        {
24900            Ok(runtime_policy) => runtime_policy,
24901            Err(error) => panic!(
24902                "Kimi RawPty admission rejected: exact_code={:?} message={}",
24903                error.code,
24904                error.message,
24905            ),
24906        };
24907        assert!(runtime_policy.raw_pty_lifecycle);
24908
24909        let launch_control = shared.native_launch_profile_control.as_ref().unwrap();
24910        let generic_overlay = NativeLaunchEnvironmentOverlay::new(
24911            agent("kimi"),
24912            TransportKind::Pty,
24913            vec![EnvMutation {
24914                key: OsString::from("GATE4AGENT_UNPROFILED_GENERIC"),
24915                value: Some(OsString::from("must-remain-rejected")),
24916            }],
24917        )
24918        .unwrap();
24919        assert_eq!(
24920            launch_control
24921                .install_native_launch_environment_overlay(
24922                    AgentInstanceId(72),
24923                    generic_overlay,
24924                )
24925                .unwrap_err(),
24926            gate4agent_runtime_native::NativeLaunchProfileError::EnvironmentOverlaySelectionMissing,
24927        );
24928
24929        let diagnostic_address = SessionAddress {
24930            workspace_id: workspace_id.clone(),
24931            session: SessionKey {
24932                instance_id: AgentInstanceId(73),
24933                generation: SessionGeneration(1),
24934            },
24935        };
24936        let (diagnostic_overlay, diagnostic_materialization) = match shared
24937            .prepare_session_materialization(
24938                &diagnostic_address,
24939                &agent("kimi"),
24940                SessionMode::Pty,
24941                None,
24942                None,
24943                Some(&resolved_context),
24944                None,
24945            )
24946        {
24947            Ok(Some(prepared)) => prepared,
24948            Ok(None) => panic!("Kimi context-only materialization produced no launch overlay"),
24949            Err(error) => panic!(
24950                "Kimi context-only materialization rejected: exact_code={:?} message={}",
24951                error.code,
24952                error.message,
24953            ),
24954        };
24955        let diagnostic_overlay = diagnostic_overlay
24956            .expect("Kimi context-only materialization omitted its environment overlay");
24957        assert!(matches!(
24958            &diagnostic_overlay,
24959            PreparedNativeLaunchOverlay::Environment(_),
24960        ));
24961        match shared.install_prepared_launch_overlay(
24962            diagnostic_address.session.instance_id,
24963            diagnostic_overlay,
24964        ) {
24965            Ok(None) => {}
24966            Ok(Some(_)) => panic!("Kimi context-only overlay unexpectedly retained argv authority"),
24967            Err(error) => panic!(
24968                "Kimi context-only overlay installation rejected: exact_code={:?} message={}",
24969                error.code,
24970                error.message,
24971            ),
24972        }
24973        assert!(launch_control.clear_native_instance_launch_overlay(
24974            diagnostic_address.session.instance_id,
24975        ));
24976        drop(diagnostic_materialization);
24977        assert!(shared
24978            .materializations
24979            .lock()
24980            .unwrap_or_else(|poisoned| poisoned.into_inner())
24981            .is_empty());
24982
24983        let node_task = tokio::spawn(server.run());
24984        let ready_client = timeout(Duration::from_secs(5), async {
24985            loop {
24986                match gate4agent_node_wire::LocalNodeClient::connect(
24987                    &endpoint,
24988                    &shared.node_id,
24989                    ClientRole::Observer,
24990                    "fixture-token",
24991                )
24992                .await
24993                {
24994                    Ok(client) => break client,
24995                    Err(_) if !node_task.is_finished() => sleep(Duration::from_millis(10)).await,
24996                    Err(error) => panic!("Kimi context-only Node stopped before readiness: {error}"),
24997                }
24998            }
24999        })
25000        .await
25001        .expect("Kimi context-only Node did not become ready");
25002        assert_eq!(
25003            ready_client
25004                .hello()
25005                .snapshot
25006                .provider_runtime_statuses
25007                .iter()
25008                .find(|status| status.provider().as_str() == "kimi")
25009                .expect("Kimi runtime status is missing")
25010                .mode(),
25011            crate::protocol::ProviderRuntimeMode::RawPassthrough,
25012        );
25013        drop(ready_client);
25014
25015        let receipt = match shared.spawn_from_spec(spec).await {
25016            Ok(receipt) => receipt,
25017            Err(error) => panic!(
25018                "Kimi context-only SpawnSpec rejected: exact_code={:?} message={}",
25019                error.code,
25020                error.message,
25021            ),
25022        };
25023        assert_eq!(receipt.provider, agent("kimi"));
25024        assert_eq!(receipt.context.as_ref(), Some(&context));
25025        assert!(receipt.context_binding_is_valid());
25026        timeout(Duration::from_secs(5), async {
25027            while !proof_path.is_file() {
25028                sleep(Duration::from_millis(10)).await;
25029            }
25030        })
25031        .await
25032        .expect("Kimi context-only fixture did not write its proof");
25033
25034        let proof = std::fs::read_to_string(&proof_path).unwrap();
25035        let lines = proof.lines().collect::<Vec<_>>();
25036        assert_eq!(lines.len(), 7);
25037        assert_eq!(lines[0], "context-only");
25038        assert!(Path::new(lines[1]).is_absolute());
25039        assert_ne!(
25040            std::fs::canonicalize(Path::new(lines[1])).unwrap(),
25041            std::fs::canonicalize(&workspace_root).unwrap(),
25042        );
25043        assert_eq!(
25044            std::fs::canonicalize(Path::new(lines[2])).unwrap(),
25045            std::fs::canonicalize(&workspace_root).unwrap(),
25046        );
25047        assert_eq!(lines[3], expected_context_hash);
25048        assert_eq!(lines[4], "g4a-context-pack-v1");
25049        assert_eq!(lines[5], "codex");
25050        assert_eq!(lines[6], "2");
25051        assert!(shared.handle.snapshot().sessions.iter().any(|session| {
25052            session.instance_id == receipt.session.session.instance_id
25053                && session.agent_id.as_str() == "kimi"
25054        }));
25055
25056        shutdown.request_shutdown().await.unwrap();
25057        timeout(Duration::from_secs(5), node_task)
25058            .await
25059            .unwrap()
25060            .unwrap()
25061            .unwrap();
25062        drop(shutdown);
25063        drop(shared);
25064        std::fs::remove_dir_all(root).unwrap();
25065    }
25066
25067    #[cfg(feature = "fixture")]
25068    #[test]
25069    fn clean_exit_fixture_constructor_is_fixture_scoped_and_path_bounded() {
25070        let root = temporary_workspace_root("clean-exit-constructor");
25071        std::fs::create_dir_all(&root).unwrap();
25072        let config = construction_test_config("clean-exit-constructor")
25073            .with_state_path(root.join("node-state.json"))
25074            .unwrap();
25075        let server = NodeServer::new_clean_exit_fixture(
25076            config,
25077            root.clone(),
25078            root.join("started.marker"),
25079            root.join("release.signal"),
25080        )
25081        .unwrap();
25082        assert_eq!(
25083            server
25084                .shared
25085                .enabled_providers
25086                .iter()
25087                .map(AgentId::as_str)
25088                .collect::<Vec<_>>(),
25089            ["claude"],
25090        );
25091        drop(server);
25092        std::fs::remove_dir_all(root).unwrap();
25093    }
25094
25095    #[test]
25096    fn production_registry_provider_contract_manifest_is_exact() {
25097        let registry = active_registry().unwrap();
25098        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
25099        assert_eq!(
25100            providers
25101                .iter()
25102                .map(|contract| (contract.provider.as_str(), contract.revision.as_str()))
25103                .collect::<Vec<_>>(),
25104            vec![
25105                ("claude", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25106                ("codex", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25107                ("grok", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25108                ("kimi", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25109            ]
25110        );
25111        assert_eq!(
25112            adapters
25113                .iter()
25114                .map(|contract| (
25115                    contract.provider.as_str(),
25116                    contract.family,
25117                    contract.adapter_id.as_str(),
25118                    contract.revision.as_str(),
25119                ))
25120                .collect::<Vec<_>>(),
25121            vec![
25122                ("claude", AdapterFamily::PtySemantic, "claude-code", "gate4agent-adapter/v1"),
25123                ("claude", AdapterFamily::Pipe, "claude-code", "gate4agent-adapter/v1"),
25124                ("claude", AdapterFamily::Acp, "claude-code", "gate4agent-adapter/v1"),
25125                ("claude", AdapterFamily::OneShot, "claude", "gate4agent-inline/claude-code-2.1/v1"),
25126                ("claude", AdapterFamily::History, "claude-code", "gate4agent-adapter/v1"),
25127                ("claude", AdapterFamily::Resume, "claude-code", "gate4agent-adapter/v1"),
25128                ("claude", AdapterFamily::SessionOptions, "claude-code", "gate4agent-session-options/orca-d8629c4/v1"),
25129                ("codex", AdapterFamily::PtySemantic, "codex", "gate4agent-adapter/v1"),
25130                ("codex", AdapterFamily::Pipe, "codex", "gate4agent-adapter/v1"),
25131                ("codex", AdapterFamily::Acp, "codex", "gate4agent-adapter/v1"),
25132                ("codex", AdapterFamily::OneShot, "codex", "gate4agent-inline/codex-cli-0.144/v1"),
25133                ("codex", AdapterFamily::History, "codex", "gate4agent-adapter/v1"),
25134                ("codex", AdapterFamily::Resume, "codex", "gate4agent-adapter/v1"),
25135                ("codex", AdapterFamily::SessionOptions, "codex", "gate4agent-session-options/orca-d8629c4/v1"),
25136                ("grok", AdapterFamily::Acp, "grok", "gate4agent-adapter/v1"),
25137                ("grok", AdapterFamily::History, "grok", "gate4agent-adapter/v1"),
25138                ("grok", AdapterFamily::Resume, "grok", "gate4agent-adapter/v1"),
25139                ("kimi", AdapterFamily::PtySemantic, "kimi", "gate4agent-adapter/v1"),
25140                ("kimi", AdapterFamily::Pipe, "kimi", "gate4agent-adapter/v1"),
25141                ("kimi", AdapterFamily::Acp, "kimi", "gate4agent-adapter/v1"),
25142                ("kimi", AdapterFamily::OneShot, "kimi", "gate4agent-inline/kimi-code-0.31/v1"),
25143                ("kimi", AdapterFamily::History, "kimi", "gate4agent-adapter/v1"),
25144                ("kimi", AdapterFamily::Resume, "kimi", "gate4agent-adapter/v1"),
25145            ]
25146        );
25147    }
25148
25149    fn construction_test_config(name: &str) -> NodeServerConfig {
25150        let workspace = WorkspaceConfig::new(
25151            WorkspaceId::new("primary").unwrap(),
25152            std::env::current_dir().unwrap(),
25153        )
25154        .unwrap();
25155        NodeServerConfig::new(
25156            format!(r"\\.\pipe\gate4agent-{name}"),
25157            "fixture-token",
25158            NodeId::new(name).unwrap(),
25159            [workspace],
25160        )
25161        .unwrap()
25162    }
25163
25164    fn maximum_size_provider_manifest_registry() -> AgentRegistry {
25165        let adapter = AdapterBinding::new(
25166            gate4agent_types::AdapterId::new("a".repeat(96)).unwrap(),
25167            "r".repeat(crate::protocol::MAX_ADAPTER_CONTRACT_REVISION_BYTES),
25168            gate4agent_types::AdapterVerification::SyntheticFixture,
25169        )
25170        .unwrap();
25171        let agent_ids = ["claude", "codex", "kimi"]
25172            .into_iter()
25173            .map(|agent_id| AgentId::new(agent_id).unwrap())
25174            .collect::<Vec<_>>();
25175        let adapters = gate4agent_catalog::AdapterRegistry::new(
25176            [
25177                AdapterFamily::PtySemantic,
25178                AdapterFamily::Pipe,
25179                AdapterFamily::OneShot,
25180                AdapterFamily::Acp,
25181                AdapterFamily::Hook,
25182                AdapterFamily::ManagedHook,
25183                AdapterFamily::History,
25184                AdapterFamily::Resume,
25185                AdapterFamily::SessionOptions,
25186                AdapterFamily::CapabilityProbe,
25187            ]
25188            .into_iter()
25189            .map(|family| gate4agent_catalog::AdapterDescriptor {
25190                family,
25191                binding: adapter.clone(),
25192                agents: agent_ids.clone(),
25193            }),
25194        )
25195        .unwrap();
25196        let specs = agent_ids
25197            .iter()
25198            .map(|agent_id| {
25199                let agent_id = agent_id.as_str();
25200                let mut spec = builtin_registry().get_by_id(agent_id).unwrap().clone();
25201                spec.revision =
25202                    "r".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES);
25203                spec.capabilities.transports.pty_adapter = Some(adapter.clone());
25204                spec.capabilities.transports.pipe = Some(gate4agent_types::PipeTransportSpec {
25205                    adapter: adapter.clone(),
25206                    protocol: gate4agent_types::PipeProtocol::SemanticNdjson,
25207                    launch_override: None,
25208                    prompt_delivery: gate4agent_types::PipePromptDelivery::None,
25209                });
25210                spec.capabilities.transports.acp = Some(gate4agent_types::AcpTransportSpec {
25211                    adapter: adapter.clone(),
25212                    launch_override: None,
25213                });
25214                spec.capabilities.adapters.hook = Some(adapter.clone());
25215                spec.capabilities.adapters.managed_hook = Some(adapter.clone());
25216                spec.capabilities.adapters.one_shot = Some(adapter.clone());
25217                spec.capabilities.adapters.history = Some(adapter.clone());
25218                spec.capabilities.adapters.resume = Some(adapter.clone());
25219                spec.capabilities.adapters.session_options = Some(adapter.clone());
25220                spec.capabilities.adapters.capability_probe = Some(adapter.clone());
25221                spec
25222            })
25223            .collect::<Vec<_>>();
25224        AgentRegistry::new_with_adapters(specs, &adapters).unwrap()
25225    }
25226
25227    /// The real fleet's manifest is accepted by a real server construction.
25228    ///
25229    /// The count is asserted against `active_registry` itself rather than a
25230    /// literal, so this stays a test that construction succeeds for whatever
25231    /// the fleet is -- which is the property worth guarding -- instead of a
25232    /// second place the fleet size is written down and has to be kept in
25233    /// step with the first.
25234    #[test]
25235    fn node_server_construction_accepts_the_production_manifest() {
25236        let registry = active_registry().unwrap();
25237        let (providers, _) = provider_contract_manifest(&registry).unwrap();
25238        assert_eq!(providers.len(), registry.iter().count());
25239        assert!(!providers.is_empty());
25240        let server = NodeServer::new_with_registry(
25241            construction_test_config("manifest-production-bound"),
25242            registry,
25243        )
25244        .unwrap();
25245        drop(server);
25246    }
25247
25248    #[test]
25249    fn node_server_construction_rejects_count_valid_manifest_over_handshake_capacity() {
25250        let registry = maximum_size_provider_manifest_registry();
25251        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
25252        assert_eq!(providers.len(), 3);
25253        assert_eq!(adapters.len(), 30);
25254        assert!(adapters.len() <= crate::protocol::MAX_PROVIDER_ADAPTER_CONTRACTS);
25255        let error = match NodeServer::new_with_registry(
25256            construction_test_config("manifest-handshake-overflow"),
25257            registry,
25258        ) {
25259            Ok(_) => panic!("oversized negotiated manifest was accepted"),
25260            Err(error) => error,
25261        };
25262        assert!(matches!(
25263            error,
25264            NodeServerError::ProviderContractManifest(message)
25265                if message.contains("authentication binding length")
25266                    && message.contains("8192-byte limit")
25267        ));
25268    }
25269
25270    #[test]
25271    fn provider_contract_manifest_rejects_wire_revision_overflow_before_startup() {
25272        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
25273        spec.revision = "x".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES + 1);
25274        let registry = AgentRegistry::new([spec]).unwrap();
25275        assert!(matches!(
25276            provider_contract_manifest(&registry),
25277            Err(NodeServerError::ProviderContractManifest(message))
25278                if message.contains("exceeds the 128-byte limit")
25279        ));
25280    }
25281
25282    #[test]
25283    fn provider_contract_manifest_rejects_invalid_wire_revision_before_startup() {
25284        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
25285        spec.revision = "invalid revision".to_owned();
25286        let registry = AgentRegistry::new([spec]).unwrap();
25287        assert!(matches!(
25288            provider_contract_manifest(&registry),
25289            Err(NodeServerError::ProviderContractManifest(message))
25290                if message.contains("bounded lowercase ASCII")
25291        ));
25292    }
25293
25294    #[test]
25295    fn provider_contract_manifest_rejects_conflicting_duplicate_family_before_startup() {
25296        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
25297        let transport_binding = spec.capabilities.adapters.one_shot.clone().unwrap();
25298        let conflicting_binding = builtin_registry()
25299            .get_by_id("codex")
25300            .unwrap()
25301            .capabilities
25302            .adapters
25303            .one_shot
25304            .clone()
25305            .unwrap();
25306        let pipe = spec.capabilities.transports.pipe.as_mut().unwrap();
25307        pipe.protocol = gate4agent_types::PipeProtocol::OneShotText;
25308        pipe.adapter = transport_binding;
25309        spec.capabilities.adapters.one_shot = Some(conflicting_binding);
25310        let registry = AgentRegistry::new([spec]).unwrap();
25311        assert!(matches!(
25312            provider_contract_manifest(&registry),
25313            Err(NodeServerError::ProviderContractManifest(message))
25314                if message.contains("conflicting OneShot adapter bindings")
25315        ));
25316    }
25317
25318    #[test]
25319    fn public_node_failures_expose_only_fixed_categories() {
25320        let provider = failure(
25321            NodeFailureCode::BackendOperationFailed,
25322            r"provider bearer-secret failed at C:\private\session.jsonl",
25323        );
25324        assert_eq!(provider.message, "backend-operation-failed");
25325        assert!(!provider.message.contains("bearer-secret"));
25326        assert!(!provider.message.contains(r"C:\private"));
25327        let persistence = persistence_failure(io::Error::new(
25328            io::ErrorKind::Other,
25329            r"state write failed at C:\private\state-v1.json",
25330        ));
25331        assert_eq!(persistence.message, DURABLE_STATE_COMMIT_FAILED_ERROR);
25332        assert!(!persistence.message.contains(r"C:\private"));
25333
25334        for kind in [io::ErrorKind::PermissionDenied, io::ErrorKind::Unsupported] {
25335            let generic = durable_state_load_error(io::Error::new(kind, "host I/O failure"));
25336            assert_eq!(
25337                generic.to_string(),
25338                "node durable state failed: durable-state-load-failed",
25339            );
25340        }
25341    }
25342
25343    #[test]
25344    fn terminal_byte_sequences_are_bounded_before_dispatch() {
25345        let sequence = b"\x1b[1;5D".to_vec();
25346        assert_eq!(
25347            terminal_bytes_action(sequence.clone()).unwrap(),
25348            InputAction::TerminalBytes(sequence),
25349        );
25350        assert_eq!(
25351            terminal_bytes_action(Vec::new()).unwrap_err().code,
25352            NodeFailureCode::InvalidRequest,
25353        );
25354        assert_eq!(
25355            terminal_bytes_action(vec![0; MAX_NODE_TERMINAL_BYTES + 1])
25356                .unwrap_err()
25357                .code,
25358            NodeFailureCode::InvalidRequest,
25359        );
25360    }
25361
25362    #[test]
25363    fn node_server_rejects_a_second_owner_of_the_same_durable_state_path() {
25364        let root = temporary_workspace_root("exclusive-state-owner");
25365        std::fs::create_dir_all(&root).unwrap();
25366        let workspace = WorkspaceConfig::new(
25367            WorkspaceId::new("primary").unwrap(),
25368            &root,
25369        )
25370        .unwrap();
25371        let state_path = root.join("state-v1.json");
25372        let endpoint = format!(
25373            r"\\.\pipe\gate4agent-node-state-lock-{}-{}",
25374            std::process::id(),
25375            std::time::SystemTime::now()
25376                .duration_since(std::time::UNIX_EPOCH)
25377                .unwrap()
25378                .as_nanos(),
25379        );
25380        let config = NodeServerConfig::new(
25381            endpoint,
25382            "fixture-token",
25383            NodeId::new("node-1").unwrap(),
25384            [workspace],
25385        )
25386        .unwrap()
25387        .with_state_path(&state_path)
25388        .unwrap();
25389
25390        let first = NodeServer::new(config.clone()).unwrap();
25391        let error = match NodeServer::new(config.clone()) {
25392            Ok(_) => panic!("a second node server acquired the same durable state path"),
25393            Err(error) => error,
25394        };
25395        assert!(matches!(
25396            &error,
25397            NodeServerError::DurableState(error)
25398                if error.kind() == io::ErrorKind::WouldBlock
25399        ));
25400        assert_eq!(
25401            error.to_string(),
25402            "node durable state failed: durable-state-lock-failed",
25403        );
25404        assert!(!error.to_string().contains(&state_path.to_string_lossy().into_owned()));
25405        drop(first);
25406        let second = NodeServer::new(config).unwrap();
25407        drop(second);
25408        std::fs::remove_dir_all(root).unwrap();
25409    }
25410
25411    #[test]
25412    fn session_binding_is_workspace_scoped_and_removed_fail_closed() {
25413        let catalog = active_registry().unwrap();
25414        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25415        let workspace_id = WorkspaceId::new("primary").unwrap();
25416        let workspace = WorkspaceConfig::new(
25417            workspace_id.clone(),
25418            std::env::current_dir().unwrap(),
25419        )
25420        .unwrap();
25421        let shared = NodeShared::new(
25422            handle,
25423            "fixture-token".to_owned(),
25424            NodeId::new("node-1").unwrap(),
25425            vec![workspace.clone()],
25426            vec![agent("claude"), agent("codex"), agent("kimi")],
25427        );
25428        let instance_id = AgentInstanceId(17);
25429        let generation = SessionGeneration(3);
25430        assert!(shared.address_for(instance_id, generation).is_none());
25431        shared.bind_session(instance_id, workspace_id.clone(), generation);
25432        let address = SessionAddress {
25433            workspace_id,
25434            session: SessionKey { instance_id, generation },
25435        };
25436        assert_eq!(
25437            shared.workspace_root(&address.workspace_id).unwrap(),
25438            workspace.canonical_root,
25439        );
25440        assert_eq!(shared.address_for(instance_id, generation), Some(address.clone()));
25441        shared.remove_binding(&address);
25442        assert!(shared.address_for(instance_id, generation).is_none());
25443    }
25444
25445    #[test]
25446    fn open_provider_request_gate_is_state_aware_and_fail_closed() {
25447        let address = SessionAddress {
25448            workspace_id: WorkspaceId::new("primary").unwrap(),
25449            session: SessionKey {
25450                instance_id: AgentInstanceId(7),
25451                generation: SessionGeneration(2),
25452            },
25453        };
25454        let record_id = SessionRecordId::new("record-a").unwrap();
25455        let size = gate4agent_types::TerminalSize {
25456            rows: 24,
25457            columns: 80,
25458        };
25459        let address_requests = [
25460            NodeRequest::Resume {
25461                session: address.clone(),
25462                terminal_size: size,
25463                initial_prompt: None,
25464            },
25465            NodeRequest::Prompt { session: address.clone(), text: "p".to_owned() },
25466            NodeRequest::Paste { session: address.clone(), text: "p".to_owned() },
25467            NodeRequest::Input { session: address.clone(), text: "p".to_owned() },
25468            NodeRequest::TerminalBytes { session: address.clone(), bytes: vec![1] },
25469            NodeRequest::TerminalControl {
25470                session: address.clone(),
25471                control: TerminalControl::Enter,
25472            },
25473            NodeRequest::Resize { session: address.clone(), size },
25474            NodeRequest::Interrupt { session: address.clone() },
25475            NodeRequest::Stop { session: address.clone(), force: false },
25476            NodeRequest::Remove { session: address },
25477        ];
25478        for request in &address_requests {
25479            assert!(request_requires_open_provider_ids_with(
25480                request,
25481                |_| Some(agent("third-party-agent")),
25482                |_| Some(agent("claude")),
25483            ));
25484            assert!(!request_requires_open_provider_ids_with(
25485                request,
25486                |_| Some(agent("claude")),
25487                |_| Some(agent("claude")),
25488            ));
25489            assert!(request_requires_open_provider_ids_with(
25490                request,
25491                |_| None,
25492                |_| Some(agent("claude")),
25493            ));
25494        }
25495
25496        let record_requests = [
25497            NodeRequest::RenameSessionRecord {
25498                record_id: record_id.clone(),
25499                display_name: "renamed".to_owned(),
25500            },
25501            NodeRequest::ResumeSessionRecord {
25502                record_id: record_id.clone(),
25503                terminal_size: size,
25504                initial_prompt: None,
25505            },
25506            NodeRequest::ForgetSessionRecord { record_id },
25507        ];
25508        for request in &record_requests {
25509            assert!(request_requires_open_provider_ids_with(
25510                request,
25511                |_| Some(agent("claude")),
25512                |_| Some(agent("grok")),
25513            ));
25514            assert!(!request_requires_open_provider_ids_with(
25515                request,
25516                |_| Some(agent("claude")),
25517                |_| Some(agent("kimi")),
25518            ));
25519            assert!(request_requires_open_provider_ids_with(
25520                request,
25521                |_| Some(agent("claude")),
25522                |_| None,
25523            ));
25524        }
25525
25526        assert!(request_requires_open_provider_ids_with(
25527            &NodeRequest::Spawn {
25528                workspace_id: WorkspaceId::new("primary").unwrap(),
25529                provider: agent("third-party-agent"),
25530                mode: SessionMode::Pty,
25531                terminal_size: size,
25532                initial_prompt: None,
25533            },
25534            |_| None,
25535            |_| None,
25536        ));
25537        assert!(!request_requires_open_provider_ids_with(
25538            &NodeRequest::Snapshot,
25539            |_| None,
25540            |_| None,
25541        ));
25542        assert!(request_requires_open_provider_ids_with(
25543            &NodeRequest::ForgetContextPack {
25544                context_id: SpawnContextId::new("ctx-forget").unwrap(),
25545            },
25546            |_| None,
25547            |_| None,
25548        ));
25549    }
25550
25551    #[test]
25552    fn legacy_projection_filters_open_record_replies_and_event_history() {
25553        let catalog = active_registry().unwrap();
25554        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25555        let workspace = WorkspaceConfig::new(
25556            WorkspaceId::new("primary").unwrap(),
25557            std::env::current_dir().unwrap(),
25558        )
25559        .unwrap();
25560        let shared = NodeShared::new(
25561            handle,
25562            "fixture-token".to_owned(),
25563            NodeId::new("node-1").unwrap(),
25564            vec![workspace],
25565            vec![agent("claude"), agent("grok")],
25566        );
25567        let legacy = record("claude", "legacy-record");
25568        let open = record("grok", "open-record");
25569        let legacy_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
25570            record: legacy.clone(),
25571        });
25572        let open_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
25573            record: open.clone(),
25574        });
25575        let legacy_removed = shared.publish(NodeEvent::SessionRecordRemoved {
25576            record_id: legacy.record_id.clone(),
25577        });
25578        let open_removed = shared.publish(NodeEvent::SessionRecordRemoved {
25579            record_id: open.record_id.clone(),
25580        });
25581        assert!(project_event_legacy_provider_ids(&shared, legacy_upsert.clone()).is_some());
25582        assert!(project_event_legacy_provider_ids(&shared, open_upsert.clone()).is_none());
25583        assert!(project_event_legacy_provider_ids(&shared, legacy_removed.clone()).is_some());
25584        assert!(project_event_legacy_provider_ids(&shared, open_removed.clone()).is_none());
25585
25586        let mut record_reply = ResponseEnvelope {
25587            request_id: 1,
25588            result: Ok(NodeResponse::SessionRecordUpdated { record: open }),
25589        };
25590        project_response_legacy_provider_ids(&shared, &mut record_reply);
25591        assert!(matches!(
25592            record_reply.result,
25593            Err(NodeFailure { code: NodeFailureCode::UnsupportedCapability, .. })
25594        ));
25595
25596        let mut resync = ResponseEnvelope {
25597            request_id: 2,
25598            result: Ok(NodeResponse::Resync {
25599                event_sequence: open_removed.sequence,
25600                oldest_available_sequence: 1,
25601                snapshot: shared.snapshot(),
25602                events: vec![legacy_upsert, open_upsert, legacy_removed, open_removed],
25603            }),
25604        };
25605        project_response_legacy_provider_ids(&shared, &mut resync);
25606        let Ok(NodeResponse::Resync { snapshot, events, .. }) = resync.result else {
25607            panic!("legacy projection changed the resync response kind");
25608        };
25609        assert!(snapshot.session_records.iter().all(|item| {
25610            provider_id_is_legacy(&item.provider)
25611        }));
25612        assert_eq!(events.len(), 2);
25613        assert!(events.iter().all(|event| matches!(
25614            &event.event,
25615            NodeEvent::SessionRecordUpserted { record }
25616                if provider_id_is_legacy(&record.provider)
25617        ) || matches!(&event.event, NodeEvent::SessionRecordRemoved { .. })));
25618    }
25619
25620    #[test]
25621    fn session_binding_capacity_is_bounded_by_the_control_session_limit() {
25622        let catalog = active_registry().unwrap();
25623        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25624        let workspace_id = WorkspaceId::new("primary").unwrap();
25625        let workspace = WorkspaceConfig::new(
25626            workspace_id.clone(),
25627            std::env::current_dir().unwrap(),
25628        )
25629        .unwrap();
25630        let shared = NodeShared::new(
25631            handle,
25632            "fixture-token".to_owned(),
25633            NodeId::new("node-1").unwrap(),
25634            vec![workspace],
25635            vec![agent("claude")],
25636        );
25637        for index in 0..CONTROL_SESSIONS_MAX {
25638            shared.bind_session(
25639                AgentInstanceId((index + 1) as u64),
25640                workspace_id.clone(),
25641                SessionGeneration(1),
25642            );
25643        }
25644        let error = shared.ensure_binding_capacity().unwrap_err();
25645        assert_eq!(error.code, NodeFailureCode::BackendBusy);
25646    }
25647
25648    #[tokio::test]
25649    async fn provider_spawn_admission_rejects_before_session_mutation() {
25650        let catalog = active_registry().unwrap();
25651        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25652        let workspace = WorkspaceConfig::new(
25653            WorkspaceId::new("primary").unwrap(),
25654            std::env::current_dir().unwrap(),
25655        )
25656        .unwrap();
25657        let mut shared = NodeShared::new(
25658            handle,
25659            "fixture-token".to_owned(),
25660            NodeId::new("node-provider-admission").unwrap(),
25661            vec![workspace],
25662            vec![agent("claude")],
25663        );
25664        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25665            crate::protocol::ProviderRuntimeStatus::unavailable(agent("claude")),
25666        ])
25667        .unwrap();
25668        let terminal_size = gate4agent_types::TerminalSize {
25669            rows: 24,
25670            columns: 80,
25671        };
25672
25673        let unavailable = shared
25674            .spawn_session(
25675                WorkspaceId::new("primary").unwrap(),
25676                agent("claude"),
25677                SessionMode::Pty,
25678                terminal_size,
25679                None,
25680            )
25681            .await
25682            .unwrap_err();
25683        assert_eq!(unavailable.code, NodeFailureCode::BackendOperationFailed);
25684        assert_eq!(unavailable.message, "backend-operation-failed");
25685
25686        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25687            crate::protocol::ProviderRuntimeStatus::raw_passthrough(
25688                agent("claude"),
25689                Some(crate::protocol::ProviderRuntimeVersion::new("999.0.0").unwrap()),
25690            ),
25691        ])
25692        .unwrap();
25693        let semantic = shared
25694            .spawn_session(
25695                WorkspaceId::new("primary").unwrap(),
25696                agent("claude"),
25697                SessionMode::Inline,
25698                terminal_size,
25699                None,
25700            )
25701            .await
25702            .unwrap_err();
25703        assert_eq!(semantic.code, NodeFailureCode::UnsupportedCapability);
25704        assert_eq!(semantic.message, "unsupported-capability");
25705
25706        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
25707        assert!(shared
25708            .session_bindings
25709            .lock()
25710            .unwrap_or_else(|poisoned| poisoned.into_inner())
25711            .is_empty());
25712        assert!(shared
25713            .session_records
25714            .lock()
25715            .unwrap_or_else(|poisoned| poisoned.into_inner())
25716            .records
25717            .is_empty());
25718        assert!(shared.handle.snapshot().sessions.is_empty());
25719    }
25720
25721    #[tokio::test]
25722    async fn raw_spawn_binding_is_visible_with_a_durable_live_record() {
25723        let catalog = active_registry().unwrap();
25724        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25725        let workspace_id = WorkspaceId::new("primary").unwrap();
25726        let workspace = WorkspaceConfig::new(
25727            workspace_id.clone(),
25728            std::env::current_dir().unwrap(),
25729        )
25730        .unwrap();
25731        let shared = NodeShared::new(
25732            handle,
25733            "fixture-token".to_owned(),
25734            NodeId::new("node-raw-spawn-record").unwrap(),
25735            vec![workspace],
25736            vec![agent("claude")],
25737        );
25738        let address = SessionAddress {
25739            workspace_id,
25740            session: SessionKey {
25741                instance_id: AgentInstanceId(71),
25742                generation: SessionGeneration::default(),
25743            },
25744        };
25745
25746        let record_id = shared
25747            .bind_spawn_session(
25748                &address,
25749                agent("claude"),
25750                SessionMode::Pty,
25751                ProviderRuntimePolicy::raw_pty(),
25752                SpawnRecordPolicy::Always,
25753                None,
25754            )
25755            .unwrap()
25756            .unwrap();
25757        shared
25758            .handle
25759            .dispatch(shared.prepare_command(ControlCommand::Register {
25760                instance_id: address.session.instance_id,
25761                agent_id: agent("claude"),
25762                transport: TransportKind::Pty,
25763            }))
25764            .unwrap();
25765        runtime.tick().await;
25766
25767        assert!(shared.handle.snapshot().sessions.iter().any(|session| {
25768            session.instance_id == address.session.instance_id
25769                && session.generation == address.session.generation
25770        }));
25771        let record = shared.record(&record_id).unwrap();
25772        assert_eq!(record.display_name, "claude #1");
25773        assert_eq!(record.state, ManagedSessionState::Live);
25774        assert_eq!(record.active_session.as_ref(), Some(&address));
25775        assert!(record.provider_session.is_none());
25776        let binding_record_id = shared
25777            .session_bindings
25778            .lock()
25779            .unwrap_or_else(|poisoned| poisoned.into_inner())
25780            .get(&address.session.instance_id)
25781            .and_then(|binding| binding.record_id.clone());
25782        assert_eq!(binding_record_id.as_ref(), Some(&record_id));
25783
25784        let renamed = shared
25785            .rename_session_record(&record_id, "raw review".to_owned())
25786            .unwrap();
25787        assert_eq!(renamed.record_id, record_id);
25788        assert_eq!(renamed.display_name, "raw review");
25789        shared.reconcile_managed_record(
25790            &record_id,
25791            &address,
25792            &ControlEvent {
25793                sequence: 1,
25794                command_id: None,
25795                instance_id: address.session.instance_id,
25796                generation: address.session.generation,
25797                event: ControlEventKind::Running { process_id: None },
25798            },
25799            false,
25800            false,
25801        );
25802        let running = shared.record(&record_id).unwrap();
25803        assert_eq!(running.state, ManagedSessionState::Live);
25804        assert_eq!(running.active_session.as_ref(), Some(&address));
25805        assert_eq!(running.display_name, "raw review");
25806
25807        shared.reconcile_managed_record(
25808            &record_id,
25809            &address,
25810            &ControlEvent {
25811                sequence: 2,
25812                command_id: None,
25813                instance_id: address.session.instance_id,
25814                generation: address.session.generation,
25815                event: ControlEventKind::Exited {
25816                    exit_code: Some(0),
25817                    forced: false,
25818                },
25819            },
25820            false,
25821            false,
25822        );
25823        let detached = shared.record(&record_id).unwrap();
25824        assert_eq!(detached.state, ManagedSessionState::Unavailable);
25825        assert!(detached.active_session.is_none());
25826        assert_eq!(detached.display_name, "raw review");
25827        let renamed_detached = shared
25828            .rename_session_record(&record_id, "raw review detached".to_owned())
25829            .unwrap();
25830        assert_eq!(renamed_detached.record_id, record_id);
25831        assert_eq!(renamed_detached.state, ManagedSessionState::Unavailable);
25832        assert!(renamed_detached.active_session.is_none());
25833        assert_eq!(renamed_detached.display_name, "raw review detached");
25834        assert_eq!(shared.snapshot().session_records, vec![renamed_detached]);
25835    }
25836
25837    #[test]
25838    fn legacy_raw_spawn_binding_remains_untracked() {
25839        let catalog = active_registry().unwrap();
25840        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25841        let workspace_id = WorkspaceId::new("primary").unwrap();
25842        let workspace = WorkspaceConfig::new(
25843            workspace_id.clone(),
25844            std::env::current_dir().unwrap(),
25845        )
25846        .unwrap();
25847        let shared = NodeShared::new(
25848            handle,
25849            "fixture-token".to_owned(),
25850            NodeId::new("node-legacy-raw-spawn").unwrap(),
25851            vec![workspace],
25852            vec![agent("claude")],
25853        );
25854        let address = SessionAddress {
25855            workspace_id,
25856            session: SessionKey {
25857                instance_id: AgentInstanceId(72),
25858                generation: SessionGeneration::default(),
25859            },
25860        };
25861
25862        let record_id = shared
25863            .bind_spawn_session(
25864                &address,
25865                agent("claude"),
25866                SessionMode::Pty,
25867                ProviderRuntimePolicy::raw_pty(),
25868                SpawnRecordPolicy::ProviderIdentityOnly,
25869                None,
25870            )
25871            .unwrap();
25872
25873        assert!(record_id.is_none());
25874        let binding_record_id = shared
25875            .session_bindings
25876            .lock()
25877            .unwrap_or_else(|poisoned| poisoned.into_inner())
25878            .get(&address.session.instance_id)
25879            .and_then(|binding| binding.record_id.clone());
25880        assert!(binding_record_id.is_none());
25881        assert!(shared.snapshot().session_records.is_empty());
25882    }
25883
25884    #[tokio::test]
25885    async fn raw_runtime_policy_rejects_prompt_paste_and_resume_before_mutation() {
25886        let catalog = active_registry().unwrap();
25887        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25888        let workspace_id = WorkspaceId::new("primary").unwrap();
25889        let workspace = WorkspaceConfig::new(
25890            workspace_id.clone(),
25891            std::env::current_dir().unwrap(),
25892        )
25893        .unwrap();
25894        let mut shared = NodeShared::new(
25895            handle,
25896            "fixture-token".to_owned(),
25897            NodeId::new("node-raw-semantic-gate").unwrap(),
25898            vec![workspace],
25899            vec![agent("claude")],
25900        );
25901        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25902            crate::protocol::ProviderRuntimeStatus::raw_passthrough(agent("claude"), None),
25903        ])
25904        .unwrap();
25905        shared
25906            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25907            .unwrap();
25908        let address = SessionAddress {
25909            workspace_id,
25910            session: SessionKey {
25911                instance_id: AgentInstanceId(72),
25912                generation: SessionGeneration::default(),
25913            },
25914        };
25915        shared.bind_session_with_policy(
25916            &address,
25917            ProviderRuntimePolicy::raw_pty(),
25918            None,
25919        );
25920        shared
25921            .handle
25922            .dispatch(shared.prepare_command(ControlCommand::Register {
25923                instance_id: address.session.instance_id,
25924                agent_id: agent("claude"),
25925                transport: TransportKind::Pty,
25926            }))
25927            .unwrap();
25928        runtime.tick().await;
25929        let command_id_before = shared.next_command_id.load(Ordering::Acquire);
25930        let revision_before = shared.handle.snapshot().revision;
25931        let history_sequence_before = shared.current_sequence();
25932        let terminal_size = gate4agent_types::TerminalSize {
25933            rows: 24,
25934            columns: 80,
25935        };
25936
25937        for request in [
25938            NodeRequest::Prompt {
25939                session: address.clone(),
25940                text: "prompt".to_owned(),
25941            },
25942            NodeRequest::Paste {
25943                session: address.clone(),
25944                text: "paste".to_owned(),
25945            },
25946            // `initial_prompt: Some(_)` selects `ResumeWithPrompt`, which needs
25947            // the same semantic capabilities as `Prompt`/`Paste`. A bare
25948            // (promptless) resume is a raw PTY relaunch and IS admitted under
25949            // `raw_pty()` — see `raw_pty_admits_provider_native_resume_without_prompt_only`
25950            // in `provider_runtime.rs`; it is deliberately not exercised here.
25951            NodeRequest::Resume {
25952                session: address.clone(),
25953                terminal_size,
25954                initial_prompt: Some("resume".to_owned()),
25955            },
25956        ] {
25957            let error = process_request_inner(
25958                &shared,
25959                77,
25960                ClientRole::Operator,
25961                request,
25962            )
25963            .await
25964            .unwrap_err();
25965            assert_eq!(error.code, NodeFailureCode::UnsupportedCapability);
25966        }
25967
25968        assert_eq!(shared.next_command_id.load(Ordering::Acquire), command_id_before);
25969        assert_eq!(shared.handle.snapshot().revision, revision_before);
25970        assert_eq!(shared.current_sequence(), history_sequence_before);
25971        let bindings = shared
25972            .session_bindings
25973            .lock()
25974            .unwrap_or_else(|poisoned| poisoned.into_inner());
25975        let binding = bindings.get(&address.session.instance_id).unwrap();
25976        assert_eq!(binding.generation, address.session.generation);
25977        assert!(binding.pending_resume.is_none());
25978        assert!(binding.record_id.is_none());
25979    }
25980
25981    #[tokio::test]
25982    async fn windows_rejects_unix_bytes_workspace_path_before_filesystem_access() {
25983        let catalog = active_registry().unwrap();
25984        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25985        let primary = WorkspaceConfig::new(
25986            WorkspaceId::new("primary").unwrap(),
25987            std::env::current_dir().unwrap(),
25988        )
25989        .unwrap();
25990        let shared = NodeShared::new(
25991            handle,
25992            "fixture-token".to_owned(),
25993            NodeId::new("node-1").unwrap(),
25994            vec![primary],
25995            vec![agent("claude")],
25996        );
25997        shared
25998            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25999            .unwrap();
26000        let before = shared.snapshot();
26001        let error = process_request_inner(
26002            &shared,
26003            77,
26004            ClientRole::Operator,
26005            NodeRequest::RegisterWorkspace {
26006                workspace_id: WorkspaceId::new("foreign").unwrap(),
26007                root: OpaqueHostPath::unix_bytes(b"/srv/repo".to_vec()).unwrap(),
26008            },
26009        )
26010        .await
26011        .unwrap_err();
26012        assert_eq!(error.code, NodeFailureCode::InvalidRequest);
26013        assert_eq!(error.message, "invalid-request");
26014        assert_eq!(shared.snapshot().workspaces, before.workspaces);
26015    }
26016
26017    #[tokio::test]
26018    async fn dynamic_workspaces_reject_duplicates_busy_and_last_then_publish_ordered_events() {
26019        let catalog = active_registry().unwrap();
26020        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26021        let primary_id = WorkspaceId::new("primary").unwrap();
26022        let primary = WorkspaceConfig::new(
26023            primary_id.clone(),
26024            std::env::current_dir().unwrap(),
26025        )
26026        .unwrap();
26027        let shared = NodeShared::new(
26028            handle,
26029            "fixture-token".to_owned(),
26030            NodeId::new("node-1").unwrap(),
26031            vec![primary.clone()],
26032            vec![agent("claude")],
26033        );
26034        let secondary_id = WorkspaceId::new("secondary").unwrap();
26035        let secondary = shared
26036            .register_workspace(
26037                secondary_id.clone(),
26038                std::env::temp_dir().to_string_lossy().into_owned(),
26039            )
26040            .await
26041            .unwrap();
26042        assert_eq!(secondary.workspace_id, secondary_id);
26043        assert!(secondary.sessions.is_empty());
26044        assert_eq!(shared.snapshot().workspaces.len(), 2);
26045
26046        let duplicate_id = shared
26047            .register_workspace(
26048                secondary_id.clone(),
26049                primary.canonical_root().to_owned(),
26050            )
26051            .await
26052            .unwrap_err();
26053        assert_eq!(duplicate_id.code, NodeFailureCode::DuplicateWorkspaceId);
26054        let duplicate_root = shared
26055            .register_workspace(
26056                WorkspaceId::new("duplicate-root").unwrap(),
26057                windows_path_text(&secondary.canonical_root).to_owned(),
26058            )
26059            .await
26060            .unwrap_err();
26061        assert_eq!(duplicate_root.code, NodeFailureCode::DuplicateWorkspaceRoot);
26062
26063        let address = SessionAddress {
26064            workspace_id: secondary_id.clone(),
26065            session: SessionKey {
26066                instance_id: AgentInstanceId(41),
26067                generation: SessionGeneration(1),
26068            },
26069        };
26070        shared.bind_session(
26071            address.session.instance_id,
26072            secondary_id.clone(),
26073            address.session.generation,
26074        );
26075        let busy = shared.unregister_workspace(&secondary_id).unwrap_err();
26076        assert_eq!(busy.code, NodeFailureCode::WorkspaceBusy);
26077        shared.remove_binding(&address);
26078        shared.unregister_workspace(&secondary_id).unwrap();
26079        assert_eq!(shared.snapshot().workspaces.len(), 1);
26080
26081        let last = shared.unregister_workspace(&primary_id).unwrap_err();
26082        assert_eq!(last.code, NodeFailureCode::LastWorkspace);
26083        let history = shared
26084            .history
26085            .lock()
26086            .unwrap_or_else(|poisoned| poisoned.into_inner());
26087        assert!(matches!(
26088            &history.events[0],
26089            NodeEventEnvelope {
26090                sequence: 1,
26091                event: NodeEvent::WorkspaceAdded { workspace },
26092            } if workspace.workspace_id == secondary_id
26093        ));
26094        assert!(matches!(
26095            &history.events[1],
26096            NodeEventEnvelope {
26097                sequence: 2,
26098                event: NodeEvent::WorkspaceRemoved { workspace_id },
26099            } if workspace_id == &secondary_id
26100        ));
26101    }
26102
26103    #[tokio::test]
26104    async fn provider_session_reference_index_is_controller_gated_and_idempotent() {
26105        let secondary_root = temporary_workspace_root("provider-session-reference-index");
26106        std::fs::create_dir_all(&secondary_root).unwrap();
26107        let catalog = active_registry().unwrap();
26108        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26109        let primary_id = WorkspaceId::new("primary").unwrap();
26110        let secondary_id = WorkspaceId::new("secondary").unwrap();
26111        let shared = NodeShared::new(
26112            handle,
26113            "fixture-token".to_owned(),
26114            NodeId::new("node-session-reference-index").unwrap(),
26115            vec![
26116                WorkspaceConfig::new(primary_id.clone(), std::env::current_dir().unwrap())
26117                    .unwrap(),
26118                WorkspaceConfig::new(secondary_id.clone(), &secondary_root).unwrap(),
26119            ],
26120            vec![agent("claude"), agent("codex")],
26121        );
26122        let identity = ProviderSessionIdentity {
26123            key: gate4agent_types::ProviderSessionKey::SessionId,
26124            id: "provider-session-42".to_owned(),
26125            transcript_path: None,
26126        };
26127        let index_request = |workspace_id: WorkspaceId,
26128                             provider: AgentId,
26129                             identity: ProviderSessionIdentity,
26130                             display_name: &str| {
26131            NodeRequest::IndexProviderSession {
26132                workspace_id,
26133                provider,
26134                identity,
26135                display_name: display_name.to_owned(),
26136            }
26137        };
26138        let request = index_request(
26139            primary_id.clone(),
26140            agent("claude"),
26141            identity.clone(),
26142            "release shepherd",
26143        );
26144        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
26145            capability.as_str() == NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY
26146        }));
26147        let observer = process_request_inner(
26148            &shared,
26149            77,
26150            ClientRole::Observer,
26151            request.clone(),
26152        )
26153        .await
26154        .unwrap_err();
26155        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26156        let uncontrolled = process_request_inner(
26157            &shared,
26158            77,
26159            ClientRole::Operator,
26160            request.clone(),
26161        )
26162        .await
26163        .unwrap_err();
26164        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
26165        shared
26166            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
26167            .unwrap();
26168
26169        let indexed = process_request_inner(
26170            &shared,
26171            77,
26172            ClientRole::Operator,
26173            request.clone(),
26174        )
26175        .await
26176        .unwrap();
26177        let NodeResponse::ProviderSessionIndexed { record } = indexed else {
26178            panic!("provider session reference index returned another response");
26179        };
26180        assert_eq!(record.state, ManagedSessionState::Dormant);
26181        assert_eq!(record.mode, SessionMode::Pty);
26182        assert_eq!(record.workspace_id, primary_id);
26183        assert_eq!(record.provider_session.as_ref(), Some(&identity));
26184        assert!(record.active_session.is_none());
26185        assert!(record.environment_profile.is_none());
26186        assert!(record.bundle.is_none());
26187        assert!(record.context.is_none());
26188
26189        let repeated = process_request_inner(
26190            &shared,
26191            77,
26192            ClientRole::Operator,
26193            index_request(
26194                primary_id.clone(),
26195                agent("claude"),
26196                identity.clone(),
26197                "ignored replacement",
26198            ),
26199        )
26200        .await
26201        .unwrap();
26202        assert!(matches!(
26203            repeated,
26204            NodeResponse::ProviderSessionIndexed { record: ref existing }
26205                if existing.record_id == record.record_id
26206                    && existing.display_name == "release shepherd"
26207        ));
26208        let conflicting_workspace = process_request_inner(
26209            &shared,
26210            77,
26211            ClientRole::Operator,
26212            index_request(
26213                secondary_id,
26214                agent("claude"),
26215                identity.clone(),
26216                "release shepherd",
26217            ),
26218        )
26219        .await
26220        .unwrap_err();
26221        assert_eq!(conflicting_workspace.code, NodeFailureCode::SessionRecordConflict);
26222        let conflicting_provider = process_request_inner(
26223            &shared,
26224            77,
26225            ClientRole::Operator,
26226            index_request(
26227                primary_id.clone(),
26228                agent("codex"),
26229                identity.clone(),
26230                "release shepherd",
26231            ),
26232        )
26233        .await
26234        .unwrap_err();
26235        assert_eq!(conflicting_provider.code, NodeFailureCode::SessionRecordConflict);
26236        let transcript_rejected = process_request_inner(
26237            &shared,
26238            77,
26239            ClientRole::Operator,
26240            index_request(
26241                primary_id,
26242                agent("claude"),
26243                ProviderSessionIdentity {
26244                    transcript_path: Some("C:/provider/transcript.jsonl".to_owned()),
26245                    ..identity
26246                },
26247                "release shepherd",
26248            ),
26249        )
26250        .await
26251        .unwrap_err();
26252        assert_eq!(transcript_rejected.code, NodeFailureCode::InvalidRequest);
26253        let upserts = shared
26254            .history
26255            .lock()
26256            .unwrap_or_else(|poisoned| poisoned.into_inner())
26257            .events
26258            .iter()
26259            .filter(|event| matches!(
26260                &event.event,
26261                NodeEvent::SessionRecordUpserted { record: published }
26262                    if published.record_id == record.record_id
26263            ))
26264            .count();
26265        assert_eq!(upserts, 1);
26266        std::fs::remove_dir_all(secondary_root).unwrap();
26267    }
26268
26269    #[tokio::test]
26270    async fn standalone_workspace_is_independent_registered_and_controller_gated() {
26271        let fixture_root = temporary_workspace_root("standalone-independent");
26272        let target = fixture_root.join("independent");
26273        std::fs::create_dir_all(&fixture_root).unwrap();
26274        let catalog = active_registry().unwrap();
26275        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26276        let selected = WorkspaceConfig::new(
26277            WorkspaceId::new("primary").unwrap(),
26278            std::env::current_dir().unwrap(),
26279        )
26280        .unwrap();
26281        let selected_root = selected.canonical_root().to_owned();
26282        let shared = NodeShared::new(
26283            handle,
26284            "fixture-token".to_owned(),
26285            NodeId::new("node-standalone").unwrap(),
26286            vec![selected],
26287            vec![agent("claude")],
26288        );
26289        let workspace_id = WorkspaceId::new("independent").unwrap();
26290        let request = NodeRequest::CreateStandaloneWorkspace {
26291            workspace_id: workspace_id.clone(),
26292            root: opaque_windows_path(target.to_string_lossy().into_owned()),
26293            initial_branch: Some("main".to_owned()),
26294        };
26295        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
26296            capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
26297        }));
26298        assert!(!request_uses_unnegotiated_capability(
26299            &request,
26300            &baseline_capabilities().unwrap(),
26301        ));
26302        assert!(request_uses_unnegotiated_capability(&request, &[]));
26303
26304        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
26305            .await
26306            .unwrap_err();
26307        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26308        assert!(!target.exists());
26309        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
26310            .await
26311            .unwrap_err();
26312        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
26313        assert!(!target.exists());
26314        shared
26315            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
26316            .unwrap();
26317
26318        let response = process_request_inner(&shared, 77, ClientRole::Operator, request)
26319            .await
26320            .unwrap();
26321        let NodeResponse::StandaloneWorkspaceCreated { workspace } = response else {
26322            panic!("standalone workspace creation returned another response");
26323        };
26324        assert_eq!(workspace.workspace_id, workspace_id);
26325        assert!(target.join(".git").is_dir());
26326        let branch = run_git_bounded(
26327            target.to_str().unwrap(),
26328            &["branch", "--show-current"],
26329            GIT_OUTPUT_MAX_BYTES,
26330        )
26331        .await
26332        .unwrap();
26333        assert!(branch.success);
26334        assert_eq!(String::from_utf8(branch.stdout).unwrap().trim(), "main");
26335        let worktrees = list_git_worktrees(target.to_str().unwrap()).await.unwrap();
26336        assert_eq!(worktrees.len(), 1);
26337        let listed_root = WorkspaceConfig::new(
26338            WorkspaceId::new("listed-independent").unwrap(),
26339            &worktrees[0].path,
26340        )
26341        .unwrap();
26342        assert!(platform::roots_equal(
26343            listed_root.canonical_root(),
26344            windows_path_text(&workspace.canonical_root),
26345        ));
26346        assert!(!platform::roots_equal(listed_root.canonical_root(), &selected_root));
26347        assert!(shared.snapshot().workspaces.iter().any(|snapshot| {
26348            snapshot.workspace_id == workspace_id
26349                && snapshot.canonical_root == workspace.canonical_root
26350        }));
26351        assert!(shared
26352            .history
26353            .lock()
26354            .unwrap_or_else(|poisoned| poisoned.into_inner())
26355            .events
26356            .iter()
26357            .any(|event| matches!(
26358                &event.event,
26359                NodeEvent::WorkspaceAdded { workspace }
26360                    if workspace.workspace_id == workspace_id
26361            )));
26362        std::fs::remove_dir_all(fixture_root).unwrap();
26363    }
26364
26365    #[tokio::test]
26366    async fn standalone_workspace_registration_failure_preserves_guarded_repository_for_recovery() {
26367        let fixture_root = temporary_workspace_root("standalone-compensation");
26368        let created_target = fixture_root.join("created-target");
26369        let existing_target = fixture_root.join("existing-target");
26370        let invalid_state_parent = fixture_root.join("state-parent-is-a-file");
26371        let invalid_state_path = invalid_state_parent.join("state.json");
26372        std::fs::create_dir_all(&existing_target).unwrap();
26373        std::fs::write(&invalid_state_parent, "not a directory").unwrap();
26374        let catalog = active_registry().unwrap();
26375        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26376        let primary = WorkspaceConfig::new(
26377            WorkspaceId::new("primary").unwrap(),
26378            std::env::current_dir().unwrap(),
26379        )
26380        .unwrap();
26381        let mut shared = NodeShared::new(
26382            handle,
26383            "fixture-token".to_owned(),
26384            NodeId::new("node-standalone-compensation").unwrap(),
26385            vec![primary],
26386            vec![agent("claude")],
26387        );
26388        shared.state_path = Some(invalid_state_path);
26389
26390        let created_recovery = shared
26391            .create_standalone_workspace(
26392                WorkspaceId::new("compensated").unwrap(),
26393                created_target.to_string_lossy().into_owned(),
26394                Some("main".to_owned()),
26395            )
26396            .await
26397            .unwrap_err();
26398        assert_eq!(
26399            created_recovery.code,
26400            NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
26401        );
26402        assert!(created_target.join(".git").is_dir());
26403        assert!(shared.snapshot().workspaces.iter().all(|workspace| {
26404            workspace.workspace_id.as_str() != "compensated"
26405        }));
26406
26407        let existing_recovery = shared
26408            .create_standalone_workspace(
26409                WorkspaceId::new("restored").unwrap(),
26410                existing_target.to_string_lossy().into_owned(),
26411                None,
26412            )
26413            .await
26414            .unwrap_err();
26415        assert_eq!(
26416            existing_recovery.code,
26417            NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
26418        );
26419        assert!(existing_target.is_dir());
26420        assert!(existing_target.join(".git").is_dir());
26421        assert!(shared.snapshot().workspaces.iter().all(|workspace| {
26422            workspace.workspace_id.as_str() != "restored"
26423        }));
26424
26425        let registered_empty = fixture_root.join("registered-empty");
26426        std::fs::create_dir(&registered_empty).unwrap();
26427        let catalog = active_registry().unwrap();
26428        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26429        let registered = WorkspaceConfig::new(
26430            WorkspaceId::new("registered-empty").unwrap(),
26431            &registered_empty,
26432        )
26433        .unwrap();
26434        let duplicate_shared = NodeShared::new(
26435            handle,
26436            "fixture-token".to_owned(),
26437            NodeId::new("node-standalone-duplicate-root").unwrap(),
26438            vec![registered],
26439            vec![agent("claude")],
26440        );
26441        let duplicate = duplicate_shared
26442            .create_standalone_workspace(
26443                WorkspaceId::new("duplicate-root").unwrap(),
26444                registered_empty.to_string_lossy().into_owned(),
26445                None,
26446            )
26447            .await
26448            .unwrap_err();
26449        assert_eq!(duplicate.code, NodeFailureCode::DuplicateWorkspaceRoot);
26450        assert!(std::fs::read_dir(&registered_empty).unwrap().next().is_none());
26451        std::fs::remove_dir_all(fixture_root).unwrap();
26452    }
26453
26454    #[test]
26455    fn managed_record_id_collision_preserves_the_existing_record() {
26456        let catalog = active_registry().unwrap();
26457        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26458        let workspace = WorkspaceConfig::new(
26459            WorkspaceId::new("primary").unwrap(),
26460            std::env::current_dir().unwrap(),
26461        )
26462        .unwrap();
26463        let shared = NodeShared::new(
26464            handle,
26465            "fixture-token".to_owned(),
26466            NodeId::new("node-1").unwrap(),
26467            vec![workspace.clone()],
26468            vec![agent("claude")],
26469        );
26470        let record_id = SessionRecordId::new("sr-collision").unwrap();
26471        let original = ManagedSessionRecord {
26472            record_id: record_id.clone(),
26473            display_name: "original".to_owned(),
26474            provider: agent("claude"),
26475            mode: SessionMode::Pty,
26476            state: ManagedSessionState::Dormant,
26477            workspace_id: workspace.workspace_id,
26478            canonical_root: opaque_windows_path(workspace.canonical_root),
26479            provider_session: None,
26480            active_session: None,
26481            environment_profile: None,
26482            bundle: None,
26483            context_id: None,
26484            context: None,
26485            exported_context: None,
26486            task_binding: None,
26487            created_at_unix_ms: 1,
26488            updated_at_unix_ms: 1,
26489            last_error: None,
26490        };
26491        shared.insert_record(original.clone()).unwrap();
26492        let mut collision = original.clone();
26493        collision.display_name = "replacement".to_owned();
26494        let error = shared.insert_record(collision).unwrap_err();
26495        assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
26496        assert_eq!(shared.record(&record_id).unwrap(), original);
26497    }
26498
26499    #[test]
26500    fn managed_record_runtime_errors_are_exposed_only_as_allowlisted_categories() {
26501        let catalog = active_registry().unwrap();
26502        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26503        let workspace = WorkspaceConfig::new(
26504            WorkspaceId::new("primary").unwrap(),
26505            std::env::current_dir().unwrap(),
26506        )
26507        .unwrap();
26508        let shared = NodeShared::new(
26509            handle,
26510            "fixture-token".to_owned(),
26511            NodeId::new("node-1").unwrap(),
26512            vec![workspace.clone()],
26513            vec![agent("claude")],
26514        );
26515        let record_id = SessionRecordId::new("sr-runtime-error").unwrap();
26516        shared
26517            .insert_record(ManagedSessionRecord {
26518                record_id: record_id.clone(),
26519                display_name: "runtime error".to_owned(),
26520                provider: agent("claude"),
26521                mode: SessionMode::Pty,
26522                state: ManagedSessionState::IdentityPending,
26523                workspace_id: workspace.workspace_id,
26524                canonical_root: opaque_windows_path(workspace.canonical_root),
26525                provider_session: None,
26526                active_session: None,
26527                environment_profile: None,
26528                bundle: None,
26529                context_id: None,
26530                context: None,
26531                exported_context: None,
26532                task_binding: None,
26533                created_at_unix_ms: 1,
26534                updated_at_unix_ms: 1,
26535                last_error: None,
26536            })
26537            .unwrap();
26538
26539        shared
26540            .mark_record_error(
26541                &record_id,
26542                r"provider failed with bearer-secret-123 at C:\private\session.jsonl",
26543            )
26544            .unwrap();
26545        let record = shared.record(&record_id).unwrap();
26546        assert_eq!(record.state, ManagedSessionState::Unavailable);
26547        assert_eq!(
26548            record.last_error.as_deref(),
26549            Some("provider-runtime-failed"),
26550        );
26551        let snapshot = shared.snapshot();
26552        assert_eq!(snapshot.session_records, vec![record]);
26553    }
26554
26555    /// Regression coverage for the "operator sees `deadline` instead of the
26556    /// kernel's own named rejection" defect: a spawn-dispatch waiter must be
26557    /// able to learn a command's asynchronous kernel rejection (here,
26558    /// specifically classified as `UnsupportedTransport`) exactly once, and
26559    /// must not still find it after that -- proving the fast path this
26560    /// mechanism exists for (`spawn_session_with_deadline`'s commit-wait
26561    /// loop) drains the reason instead of leaving it to be rediscovered (or
26562    /// missed) on a later poll.
26563    #[test]
26564    fn rejected_command_reason_is_classified_and_consumed_exactly_once() {
26565        let catalog = active_registry().unwrap();
26566        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26567        let workspace = WorkspaceConfig::new(
26568            WorkspaceId::new("primary").unwrap(),
26569            std::env::current_dir().unwrap(),
26570        )
26571        .unwrap();
26572        let shared = NodeShared::new(
26573            handle,
26574            "fixture-token".to_owned(),
26575            NodeId::new("node-1").unwrap(),
26576            vec![workspace],
26577            vec![agent("claude")],
26578        );
26579
26580        assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
26581
26582        shared.record_rejected_command(
26583            CommandId(1),
26584            "agent 'claude' does not support transport Acp".to_owned(),
26585            true,
26586        );
26587        // An unrelated command_id must not see another command's reason.
26588        assert!(shared.take_rejected_command_reason(CommandId(2)).is_none());
26589
26590        let reason = shared.take_rejected_command_reason(CommandId(1)).unwrap();
26591        assert_eq!(reason.message, "agent 'claude' does not support transport Acp");
26592        assert!(reason.unsupported_transport);
26593
26594        // Consumed: a second read for the same command_id finds nothing.
26595        assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
26596
26597        // A rejection unrelated to transport support keeps its own
26598        // classification distinct, so the reader can choose the generic
26599        // `BackendOperationFailed` code instead of misreporting
26600        // `UnsupportedTransport`.
26601        shared.record_rejected_command(
26602            CommandId(3),
26603            "backend busy".to_owned(),
26604            false,
26605        );
26606        let unclassified = shared.take_rejected_command_reason(CommandId(3)).unwrap();
26607        assert!(!unclassified.unsupported_transport);
26608    }
26609
26610    #[test]
26611    fn provider_identity_update_is_ignored_without_exact_session_policy() {
26612        let catalog = active_registry().unwrap();
26613        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26614        let workspace = WorkspaceConfig::new(
26615            WorkspaceId::new("primary").unwrap(),
26616            std::env::current_dir().unwrap(),
26617        )
26618        .unwrap();
26619        let shared = NodeShared::new(
26620            handle,
26621            "fixture-token".to_owned(),
26622            NodeId::new("node-identity-policy").unwrap(),
26623            vec![workspace.clone()],
26624            vec![agent("claude")],
26625        );
26626        let address = SessionAddress {
26627            workspace_id: workspace.workspace_id.clone(),
26628            session: SessionKey {
26629                instance_id: AgentInstanceId(90),
26630                generation: SessionGeneration(1),
26631            },
26632        };
26633        let record_id = SessionRecordId::new("sr-policy-denied").unwrap();
26634        shared
26635            .insert_record(ManagedSessionRecord {
26636                record_id: record_id.clone(),
26637                display_name: "policy denied".to_owned(),
26638                provider: agent("claude"),
26639                mode: SessionMode::Pty,
26640                state: ManagedSessionState::IdentityPending,
26641                workspace_id: workspace.workspace_id,
26642                canonical_root: opaque_windows_path(workspace.canonical_root),
26643                provider_session: None,
26644                active_session: Some(address.clone()),
26645                environment_profile: None,
26646                bundle: None,
26647                context_id: None,
26648                context: None,
26649                exported_context: None,
26650                task_binding: None,
26651                created_at_unix_ms: 1,
26652                updated_at_unix_ms: 1,
26653                last_error: None,
26654            })
26655            .unwrap();
26656        let event = ControlEvent {
26657            sequence: 1,
26658            command_id: None,
26659            instance_id: address.session.instance_id,
26660            generation: address.session.generation,
26661            event: ControlEventKind::ProviderEvent {
26662                sequence: 1,
26663                source: gate4agent_types::ProviderSource {
26664                    family: AdapterFamily::PtySemantic,
26665                    binding: AdapterBinding::new(
26666                        gate4agent_types::AdapterId::new("claude").unwrap(),
26667                        "fixture/v1",
26668                        gate4agent_types::AdapterVerification::SyntheticFixture,
26669                    )
26670                    .unwrap(),
26671                },
26672                source_sequence: 1,
26673                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26674                    identity: gate4agent_types::ProviderSessionIdentity {
26675                        key: gate4agent_types::ProviderSessionKey::SessionId,
26676                        id: "provider-session".to_owned(),
26677                        transcript_path: None,
26678                    },
26679                },
26680            },
26681        };
26682
26683        shared.reconcile_managed_record(&record_id, &address, &event, false, false);
26684
26685        let record = shared.record(&record_id).unwrap();
26686        assert!(record.provider_session.is_none());
26687        assert_eq!(record.state, ManagedSessionState::IdentityPending);
26688        assert_eq!(record.active_session.as_ref(), Some(&address));
26689    }
26690
26691    #[test]
26692    fn provider_identity_cannot_rebind_a_record_across_workspaces() {
26693        let secondary_root = temporary_workspace_root("identity-scope");
26694        std::fs::create_dir_all(&secondary_root).unwrap();
26695        let catalog = active_registry().unwrap();
26696        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26697        let primary = WorkspaceConfig::new(
26698            WorkspaceId::new("primary").unwrap(),
26699            std::env::current_dir().unwrap(),
26700        )
26701        .unwrap();
26702        let secondary = WorkspaceConfig::new(
26703            WorkspaceId::new("secondary").unwrap(),
26704            &secondary_root,
26705        )
26706        .unwrap();
26707        let shared = NodeShared::new(
26708            handle,
26709            "fixture-token".to_owned(),
26710            NodeId::new("node-1").unwrap(),
26711            vec![primary.clone(), secondary.clone()],
26712            vec![agent("claude")],
26713        );
26714        let identity = gate4agent_types::ProviderSessionIdentity {
26715            key: gate4agent_types::ProviderSessionKey::SessionId,
26716            id: "provider-session".to_owned(),
26717            transcript_path: None,
26718        };
26719        let dormant_id = SessionRecordId::new("sr-dormant").unwrap();
26720        shared
26721            .insert_record(ManagedSessionRecord {
26722                record_id: dormant_id.clone(),
26723                display_name: "dormant".to_owned(),
26724                provider: agent("claude"),
26725                mode: SessionMode::Pty,
26726                state: ManagedSessionState::Dormant,
26727                workspace_id: primary.workspace_id,
26728                canonical_root: opaque_windows_path(primary.canonical_root),
26729                provider_session: Some(identity.clone()),
26730                active_session: None,
26731                environment_profile: None,
26732                bundle: None,
26733                context_id: None,
26734                context: None,
26735                exported_context: None,
26736                task_binding: None,
26737                created_at_unix_ms: 1,
26738                updated_at_unix_ms: 1,
26739                last_error: None,
26740            })
26741            .unwrap();
26742        let address = SessionAddress {
26743            workspace_id: secondary.workspace_id.clone(),
26744            session: SessionKey {
26745                instance_id: AgentInstanceId(91),
26746                generation: SessionGeneration(1),
26747            },
26748        };
26749        let current_id = SessionRecordId::new("sr-current").unwrap();
26750        shared
26751            .insert_record(ManagedSessionRecord {
26752                record_id: current_id.clone(),
26753                display_name: "current".to_owned(),
26754                provider: agent("claude"),
26755                mode: SessionMode::Pty,
26756                state: ManagedSessionState::IdentityPending,
26757                workspace_id: secondary.workspace_id,
26758                canonical_root: opaque_windows_path(secondary.canonical_root),
26759                provider_session: None,
26760                active_session: Some(address.clone()),
26761                environment_profile: None,
26762                bundle: None,
26763                context_id: None,
26764                context: None,
26765                exported_context: None,
26766                task_binding: None,
26767                created_at_unix_ms: 2,
26768                updated_at_unix_ms: 2,
26769                last_error: None,
26770            })
26771            .unwrap();
26772        shared.bind_managed_session(
26773            &address,
26774            current_id.clone(),
26775            ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26776            None,
26777        );
26778        let event = ControlEvent {
26779            sequence: 1,
26780            command_id: None,
26781            instance_id: address.session.instance_id,
26782            generation: address.session.generation,
26783            event: ControlEventKind::ProviderEvent {
26784                sequence: 1,
26785                source: gate4agent_types::ProviderSource {
26786                    family: gate4agent_types::AdapterFamily::PtySemantic,
26787                    binding: gate4agent_types::AdapterBinding::new(
26788                        gate4agent_types::AdapterId::new("claude").unwrap(),
26789                        "fixture/v1",
26790                        gate4agent_types::AdapterVerification::SyntheticFixture,
26791                    )
26792                    .unwrap(),
26793                },
26794                source_sequence: 1,
26795                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26796                    identity,
26797                },
26798            },
26799        };
26800        shared.reconcile_managed_record(&current_id, &address, &event, true, false);
26801
26802        let binding = shared
26803            .session_bindings
26804            .lock()
26805            .unwrap_or_else(|poisoned| poisoned.into_inner())
26806            .get(&address.session.instance_id)
26807            .cloned()
26808            .unwrap();
26809        assert_eq!(binding.record_id.as_ref(), Some(&current_id));
26810        assert_eq!(shared.record(&dormant_id).unwrap().state, ManagedSessionState::Dormant);
26811        let current = shared.record(&current_id).unwrap();
26812        assert_eq!(current.state, ManagedSessionState::Unavailable);
26813        assert_eq!(current.active_session.as_ref(), Some(&address));
26814        assert_eq!(
26815            current.last_error.as_deref(),
26816            Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR),
26817        );
26818        std::fs::remove_dir_all(secondary_root).unwrap();
26819    }
26820
26821    #[tokio::test]
26822    async fn semantic_identity_with_transcript_path_reuses_record_and_survives_reload() {
26823        let root = temporary_workspace_root("semantic-provider-session");
26824        std::fs::create_dir_all(&root).unwrap();
26825        let state_path = root.join("state-v1.json");
26826        let catalog = active_registry().unwrap();
26827        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26828        let workspace = WorkspaceConfig::new(
26829            WorkspaceId::new("primary").unwrap(),
26830            &root,
26831        )
26832        .unwrap();
26833        let node_id = NodeId::new("node-semantic-identity").unwrap();
26834        let shared = NodeShared::new_with_incarnation(
26835            handle,
26836            "fixture-token".to_owned(),
26837            node_id.clone(),
26838            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
26839            vec![workspace.clone()],
26840            vec![agent("claude")],
26841            ProviderRuntimeStatuses::default(),
26842            None,
26843            Vec::new(),
26844            Vec::new(),
26845            SpawnProfileRegistry::default(),
26846            None,
26847            None,
26848            None,
26849            Some(state_path.clone()),
26850            Vec::new(),
26851            Vec::new(),
26852            Vec::new(),
26853            Vec::new(),
26854            None,
26855            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
26856        );
26857        let persisted_identity = gate4agent_types::ProviderSessionIdentity {
26858            key: gate4agent_types::ProviderSessionKey::SessionId,
26859            id: "provider-session".to_owned(),
26860            transcript_path: None,
26861        };
26862        let durable_id = SessionRecordId::new("sr-durable").unwrap();
26863        shared
26864            .insert_record(ManagedSessionRecord {
26865                record_id: durable_id.clone(),
26866                display_name: "durable".to_owned(),
26867                provider: agent("claude"),
26868                mode: SessionMode::Pty,
26869                state: ManagedSessionState::Dormant,
26870                workspace_id: workspace.workspace_id.clone(),
26871                canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26872                provider_session: Some(persisted_identity.clone()),
26873                active_session: None,
26874                environment_profile: None,
26875                bundle: None,
26876                context_id: None,
26877                context: None,
26878                exported_context: None,
26879                task_binding: None,
26880                created_at_unix_ms: 1,
26881                updated_at_unix_ms: 1,
26882                last_error: None,
26883            })
26884            .unwrap();
26885        let address = SessionAddress {
26886            workspace_id: workspace.workspace_id.clone(),
26887            session: SessionKey {
26888                instance_id: AgentInstanceId(92),
26889                generation: SessionGeneration(2),
26890            },
26891        };
26892        let pending_id = SessionRecordId::new("sr-pending").unwrap();
26893        shared
26894            .insert_record(ManagedSessionRecord {
26895                record_id: pending_id.clone(),
26896                display_name: "pending".to_owned(),
26897                provider: agent("claude"),
26898                mode: SessionMode::Pty,
26899                state: ManagedSessionState::IdentityPending,
26900                workspace_id: workspace.workspace_id.clone(),
26901                canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26902                provider_session: None,
26903                active_session: Some(address.clone()),
26904                environment_profile: None,
26905                bundle: None,
26906                context_id: None,
26907                context: None,
26908                exported_context: None,
26909                task_binding: None,
26910                created_at_unix_ms: 2,
26911                updated_at_unix_ms: 2,
26912                last_error: None,
26913            })
26914            .unwrap();
26915        shared.bind_managed_session(
26916            &address,
26917            pending_id.clone(),
26918            ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26919            None,
26920        );
26921        let observed_identity = gate4agent_types::ProviderSessionIdentity {
26922            transcript_path: Some(r"C:\private\provider-transcript.jsonl".to_owned()),
26923            ..persisted_identity
26924        };
26925        let event = ControlEvent {
26926            sequence: 1,
26927            command_id: None,
26928            instance_id: address.session.instance_id,
26929            generation: address.session.generation,
26930            event: ControlEventKind::ProviderEvent {
26931                sequence: 1,
26932                source: gate4agent_types::ProviderSource {
26933                    family: gate4agent_types::AdapterFamily::PtySemantic,
26934                    binding: gate4agent_types::AdapterBinding::new(
26935                        gate4agent_types::AdapterId::new("claude").unwrap(),
26936                        "fixture/v1",
26937                        gate4agent_types::AdapterVerification::SyntheticFixture,
26938                    )
26939                    .unwrap(),
26940                },
26941                source_sequence: 1,
26942                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26943                    identity: observed_identity.clone(),
26944                },
26945            },
26946        };
26947        shared.reconcile_managed_record(&pending_id, &address, &event, true, false);
26948
26949        let snapshot = shared.snapshot();
26950        assert_eq!(snapshot.session_records.len(), 1);
26951        let live = &snapshot.session_records[0];
26952        assert_eq!(live.record_id, durable_id);
26953        assert_eq!(live.state, ManagedSessionState::Live);
26954        assert_eq!(live.active_session.as_ref(), Some(&address));
26955        assert_eq!(live.provider_session.as_ref(), Some(&observed_identity));
26956        let binding = shared
26957            .session_bindings
26958            .lock()
26959            .unwrap_or_else(|poisoned| poisoned.into_inner())
26960            .get(&address.session.instance_id)
26961            .cloned()
26962            .unwrap();
26963        assert_eq!(binding.record_id.as_ref(), Some(&durable_id));
26964
26965        let mut duplicate = live.clone();
26966        duplicate.record_id = SessionRecordId::new("sr-duplicate").unwrap();
26967        duplicate.display_name = "duplicate".to_owned();
26968        duplicate.state = ManagedSessionState::Dormant;
26969        duplicate.active_session = None;
26970        duplicate.provider_session.as_mut().unwrap().transcript_path = None;
26971        let duplicate_error = shared.insert_record(duplicate).unwrap_err();
26972        assert_eq!(duplicate_error.code, NodeFailureCode::SessionRecordConflict);
26973        assert_eq!(shared.snapshot().session_records.len(), 1);
26974
26975        let terminal_size = gate4agent_types::TerminalSize {
26976            rows: 24,
26977            columns: 80,
26978        };
26979        let (first_busy, second_busy) = tokio::join!(
26980            shared.resume_session_record(&durable_id, terminal_size, None),
26981            shared.resume_session_record(&durable_id, terminal_size, None),
26982        );
26983        for busy in [first_busy.unwrap_err(), second_busy.unwrap_err()] {
26984            assert_eq!(busy.code, NodeFailureCode::SessionRecordBusy);
26985            assert_eq!(busy.message, "session-record-busy");
26986        }
26987
26988        let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
26989        assert_eq!(loaded.records.len(), 1);
26990        assert_eq!(loaded.records[0].record_id, durable_id);
26991        assert_eq!(loaded.records[0].state, ManagedSessionState::Dormant);
26992        assert_eq!(
26993            loaded.records[0]
26994                .provider_session
26995                .as_ref()
26996                .unwrap()
26997                .transcript_path,
26998            None,
26999        );
27000        let catalog = active_registry().unwrap();
27001        let (reloaded_handle, _reloaded_runtime) =
27002            NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27003        let reloaded = NodeShared::new_with_incarnation(
27004            reloaded_handle,
27005            "fixture-token".to_owned(),
27006            node_id,
27007            NodeIncarnationId::from_bytes([1; crate::protocol::NODE_INCARNATION_ID_BYTES]),
27008            vec![workspace],
27009            vec![agent("claude")],
27010            ProviderRuntimeStatuses::default(),
27011            None,
27012            Vec::new(),
27013            Vec::new(),
27014            SpawnProfileRegistry::default(),
27015            None,
27016            None,
27017            None,
27018            None,
27019            loaded.records,
27020            loaded.managed_worktrees,
27021            loaded.managed_worktree_tombstones,
27022            loaded.materializations,
27023            loaded.warning,
27024            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
27025        );
27026        assert_eq!(reloaded.snapshot().session_records.len(), 1);
27027        assert_eq!(reloaded.snapshot().session_records[0].record_id, durable_id);
27028        std::fs::remove_dir_all(root).unwrap();
27029    }
27030
27031    #[tokio::test]
27032    async fn workspace_reregistration_requires_the_recorded_root_and_restores_records() {
27033        let secondary_root = temporary_workspace_root("workspace-restore");
27034        let other_root = temporary_workspace_root("workspace-conflict");
27035        std::fs::create_dir_all(&secondary_root).unwrap();
27036        std::fs::create_dir_all(&other_root).unwrap();
27037        let catalog = active_registry().unwrap();
27038        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27039        let primary = WorkspaceConfig::new(
27040            WorkspaceId::new("primary").unwrap(),
27041            std::env::current_dir().unwrap(),
27042        )
27043        .unwrap();
27044        let secondary = WorkspaceConfig::new(
27045            WorkspaceId::new("secondary").unwrap(),
27046            &secondary_root,
27047        )
27048        .unwrap();
27049        let shared = NodeShared::new(
27050            handle,
27051            "fixture-token".to_owned(),
27052            NodeId::new("node-1").unwrap(),
27053            vec![primary, secondary.clone()],
27054            vec![agent("claude")],
27055        );
27056        let record_id = SessionRecordId::new("sr-workspace").unwrap();
27057        shared
27058            .insert_record(ManagedSessionRecord {
27059                record_id: record_id.clone(),
27060                display_name: "workspace record".to_owned(),
27061                provider: agent("claude"),
27062                mode: SessionMode::Pty,
27063                state: ManagedSessionState::Dormant,
27064                workspace_id: secondary.workspace_id.clone(),
27065                canonical_root: opaque_windows_path(secondary.canonical_root.clone()),
27066                provider_session: Some(gate4agent_types::ProviderSessionIdentity {
27067                    key: gate4agent_types::ProviderSessionKey::SessionId,
27068                    id: "provider-session".to_owned(),
27069                    transcript_path: None,
27070                }),
27071                active_session: None,
27072                environment_profile: None,
27073                bundle: None,
27074                context_id: None,
27075                context: None,
27076                exported_context: None,
27077                task_binding: None,
27078                created_at_unix_ms: 1,
27079                updated_at_unix_ms: 1,
27080                last_error: None,
27081            })
27082            .unwrap();
27083        shared.unregister_workspace(&secondary.workspace_id).unwrap();
27084        let unavailable = shared.record(&record_id).unwrap();
27085        assert_eq!(unavailable.state, ManagedSessionState::Unavailable);
27086        assert_eq!(windows_path_text(&unavailable.canonical_root), secondary.canonical_root);
27087
27088        let conflict = shared
27089            .register_workspace(
27090                secondary.workspace_id.clone(),
27091                other_root.to_string_lossy().into_owned(),
27092            )
27093            .await
27094            .unwrap_err();
27095        assert_eq!(conflict.code, NodeFailureCode::SessionRecordConflict);
27096        shared
27097            .register_workspace(
27098                secondary.workspace_id,
27099                secondary_root.to_string_lossy().into_owned(),
27100            )
27101            .await
27102            .unwrap();
27103        let restored = shared.record(&record_id).unwrap();
27104        assert_eq!(restored.state, ManagedSessionState::Dormant);
27105        assert!(restored.last_error.is_none());
27106        std::fs::remove_dir_all(secondary_root).unwrap();
27107        std::fs::remove_dir_all(other_root).unwrap();
27108    }
27109
27110    #[test]
27111    fn dynamic_workspace_root_is_rejected_before_filesystem_canonicalization() {
27112        let error = validate_workspace_request_root(
27113            &"x".repeat(MAX_WORKSPACE_ROOT_BYTES + 1),
27114        )
27115        .unwrap_err();
27116        assert_eq!(error.code, NodeFailureCode::InvalidWorkspaceRoot);
27117        let control = validate_workspace_request_root("C:\\repo\nchild").unwrap_err();
27118        assert_eq!(control.code, NodeFailureCode::InvalidWorkspaceRoot);
27119    }
27120
27121    #[test]
27122    fn concurrent_publish_and_resync_never_acknowledge_an_unappended_event() {
27123        let catalog = active_registry().unwrap();
27124        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27125        let workspace = WorkspaceConfig::new(
27126            WorkspaceId::new("primary").unwrap(),
27127            std::env::current_dir().unwrap(),
27128        )
27129        .unwrap();
27130        let shared = NodeShared::new(
27131            handle,
27132            "fixture-token".to_owned(),
27133            NodeId::new("node-1").unwrap(),
27134            vec![workspace],
27135            vec![agent("claude")],
27136        );
27137        let publishers = 4_u64;
27138        let events_per_publisher = 256_u64;
27139        let start = std::sync::Barrier::new(publishers as usize + 1);
27140        let finished = AtomicU64::new(0);
27141
27142        std::thread::scope(|scope| {
27143            for _ in 0..publishers {
27144                scope.spawn(|| {
27145                    start.wait();
27146                    for _ in 0..events_per_publisher {
27147                        shared.publish(NodeEvent::ControllerChanged { controller: None });
27148                    }
27149                    finished.fetch_add(1, Ordering::Release);
27150                });
27151            }
27152            start.wait();
27153            while finished.load(Ordering::Acquire) != publishers {
27154                assert_resync_history_is_linearized(shared.resync(0));
27155                std::thread::yield_now();
27156            }
27157        });
27158        let response = shared.resync(0);
27159        assert_resync_history_is_linearized(response.clone());
27160        let NodeResponse::Resync { event_sequence, events, .. } = response else {
27161            unreachable!("resync helper returned another response");
27162        };
27163        assert_eq!(event_sequence, publishers * events_per_publisher);
27164        assert_eq!(events.len() as u64, event_sequence);
27165    }
27166
27167    fn assert_resync_history_is_linearized(response: NodeResponse) {
27168        let NodeResponse::Resync { event_sequence, events, .. } = response else {
27169            unreachable!("resync helper returned another response");
27170        };
27171        assert!(events.windows(2).all(|pair| {
27172            pair[0].sequence.checked_add(1) == Some(pair[1].sequence)
27173        }));
27174        assert_eq!(events.last().map_or(0, |event| event.sequence), event_sequence);
27175    }
27176
27177    #[test]
27178    fn canonical_workspace_roots_are_safe_for_cmd_exe() {
27179        assert_eq!(
27180            platform::normalize_canonical_root(r"\\?\C:\repo\worktree".to_owned()),
27181            r"C:\repo\worktree",
27182        );
27183        assert_eq!(
27184            platform::normalize_canonical_root(r"\\?\UNC\server\share\repo".to_owned()),
27185            r"\\server\share\repo",
27186        );
27187        assert!(validate_workspace_root(
27188            &WorkspaceId::new("network").unwrap(),
27189            r"\\server\share\repo",
27190        )
27191        .is_err());
27192        let workspace = WorkspaceConfig::new(
27193            WorkspaceId::new("primary").unwrap(),
27194            std::env::current_dir().unwrap(),
27195        )
27196        .unwrap();
27197        assert!(!workspace.canonical_root.starts_with(r"\\?\"));
27198    }
27199
27200    #[test]
27201    fn resume_rebinds_only_on_the_expected_authorized_generation() {
27202        let catalog = active_registry().unwrap();
27203        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27204        let workspace_id = WorkspaceId::new("primary").unwrap();
27205        let workspace = WorkspaceConfig::new(
27206            workspace_id.clone(),
27207            std::env::current_dir().unwrap(),
27208        )
27209        .unwrap();
27210        let shared = NodeShared::new(
27211            handle,
27212            "fixture-token".to_owned(),
27213            NodeId::new("node-1").unwrap(),
27214            vec![workspace],
27215            vec![agent("claude")],
27216        );
27217        let instance_id = AgentInstanceId(17);
27218        let original = SessionAddress {
27219            workspace_id,
27220            session: SessionKey {
27221                instance_id,
27222                generation: SessionGeneration(3),
27223            },
27224        };
27225        shared.bind_session(
27226            instance_id,
27227            original.workspace_id.clone(),
27228            original.session.generation,
27229        );
27230        shared
27231            .arm_resume(
27232                &original,
27233                CommandId(41),
27234                ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
27235            )
27236            .unwrap();
27237
27238        shared.publish_control(ControlEvent {
27239            sequence: 1,
27240            command_id: None,
27241            instance_id,
27242            generation: SessionGeneration(4),
27243            event: ControlEventKind::Running { process_id: None },
27244        });
27245        assert_eq!(
27246            shared.address_for(instance_id, SessionGeneration(3)),
27247            Some(original.clone()),
27248        );
27249        assert!(shared.address_for(instance_id, SessionGeneration(4)).is_none());
27250
27251        shared.publish_control(ControlEvent {
27252            sequence: 2,
27253            command_id: Some(CommandId(42)),
27254            instance_id,
27255            generation: SessionGeneration(3),
27256            event: ControlEventKind::CommandRejected {
27257                message: "unrelated command".to_owned(),
27258            },
27259        });
27260
27261        shared.publish_control(ControlEvent {
27262            sequence: 3,
27263            command_id: None,
27264            instance_id,
27265            generation: SessionGeneration(4),
27266            event: ControlEventKind::ResumeAuthorized {
27267                session: gate4agent_types::ResumeSessionSummary {
27268                    key: gate4agent_types::ProviderSessionKey::SessionId,
27269                    id: "fixture-session".to_owned(),
27270                },
27271            },
27272        });
27273        assert!(shared.address_for(instance_id, SessionGeneration(3)).is_none());
27274        let rebound = shared.address_for(instance_id, SessionGeneration(4)).unwrap();
27275        assert_eq!(rebound.workspace_id, original.workspace_id);
27276        let history = shared
27277            .history
27278            .lock()
27279            .unwrap_or_else(|poisoned| poisoned.into_inner());
27280        assert!(history.events.iter().any(|envelope| matches!(
27281            &envelope.event,
27282            NodeEvent::Control { address, event }
27283                if address == &rebound
27284                    && matches!(&event.event, ControlEventKind::ResumeAuthorized { .. })
27285        )));
27286    }
27287
27288    #[test]
27289    fn all_node_pty_prompts_use_bracketed_paste_framing() {
27290        for agent in ["claude", "codex", "kimi"] {
27291            assert_eq!(
27292                prompt_framing(&AgentId::new(agent).unwrap()),
27293                PromptFraming::BracketedPaste
27294            );
27295        }
27296    }
27297
27298    fn generous_walk_deadline() -> Instant {
27299        Instant::now() + Duration::from_secs(30)
27300    }
27301
27302    #[test]
27303    fn workspace_tree_is_relative_and_skips_heavy_directories() {
27304        let root = temporary_workspace_root("tree");
27305        std::fs::create_dir_all(root.join("src")).unwrap();
27306        std::fs::create_dir_all(root.join(".git/objects")).unwrap();
27307        std::fs::create_dir_all(root.join("target/debug")).unwrap();
27308        std::fs::create_dir_all(root.join("node_modules/package")).unwrap();
27309        std::fs::write(root.join("src/lib.rs"), b"pub fn fixture() {}\n").unwrap();
27310        std::fs::write(root.join("README.md"), b"fixture\n").unwrap();
27311        // A renamed/agent-scoped build directory (`target-a2`-style, per the
27312        // real repository's own build layout) that carries the Cache
27313        // Directory Tagging Specification marker cargo writes into every
27314        // `--target-dir` it creates: must be skipped even though its name
27315        // does not exactly match `target`.
27316        std::fs::create_dir_all(root.join("target-a2/debug")).unwrap();
27317        std::fs::write(root.join("target-a2/CACHEDIR.TAG"), b"Signature: 8a477f597d28d172789f06886806bc55\n").unwrap();
27318        // A directory that merely *starts with* `target` but carries no
27319        // CACHEDIR.TAG: the new prefix-adjacent check must not false-match
27320        // on the name alone, so this stays in the response.
27321        std::fs::create_dir_all(root.join("target-notes")).unwrap();
27322        std::fs::write(root.join("target-notes/plan.md"), b"not a build directory\n").unwrap();
27323
27324        let (entries, truncated, _budget) =
27325            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
27326        assert!(!truncated);
27327        assert!(entries.iter().any(|entry| {
27328            entry.relative_path.as_utf8() == Some("src")
27329                && entry.kind == WorkspaceEntryKind::Directory
27330        }));
27331        assert!(entries.iter().any(|entry| {
27332            entry.relative_path.as_utf8() == Some("src/lib.rs")
27333                && entry.kind == WorkspaceEntryKind::File
27334        }));
27335        assert!(entries.iter().any(|entry| {
27336            entry.relative_path.as_utf8() == Some("target-notes")
27337                && entry.kind == WorkspaceEntryKind::Directory
27338        }));
27339        assert!(entries.iter().any(|entry| {
27340            entry.relative_path.as_utf8() == Some("target-notes/plan.md")
27341                && entry.kind == WorkspaceEntryKind::File
27342        }));
27343        assert!(entries.iter().all(|entry| {
27344            let path = entry.relative_path.as_utf8().unwrap();
27345            !path.starts_with(".git")
27346                && !(path == "target" || path.starts_with("target/"))
27347                && !(path == "target-a2" || path.starts_with("target-a2/"))
27348                && !path.starts_with("node_modules")
27349        }));
27350
27351        std::fs::remove_dir_all(&root).unwrap();
27352    }
27353
27354    #[test]
27355    fn workspace_walk_stops_at_its_entry_cap_and_marks_a_partial_result() {
27356        let root = temporary_workspace_root("tree-entry-cap");
27357        std::fs::create_dir_all(&root).unwrap();
27358        for index in 0..20 {
27359            std::fs::write(root.join(format!("file-{index:02}.txt")), b"fixture\n").unwrap();
27360        }
27361
27362        let (entries, truncated, budget) =
27363            collect_workspace_entries(&root, generous_walk_deadline(), 5);
27364        assert!(truncated);
27365        assert!(budget.entry_cap_exceeded);
27366        assert!(!budget.time_budget_exceeded);
27367        assert!(entries.len() <= 5);
27368
27369        let inspection_truncation = workspace_inspection_truncation(&budget, false, Duration::from_millis(1))
27370            .expect("an entry-cap-truncated walk must report a truncation marker");
27371        assert!(inspection_truncation.walk_entry_cap_exceeded);
27372        assert!(!inspection_truncation.walk_time_budget_exceeded);
27373        assert!(!inspection_truncation.git_time_budget_exceeded);
27374        assert!(inspection_truncation.entries_visited >= 5);
27375
27376        std::fs::remove_dir_all(&root).unwrap();
27377    }
27378
27379    #[test]
27380    fn workspace_walk_within_budget_reports_no_truncation() {
27381        let root = temporary_workspace_root("tree-no-truncation");
27382        std::fs::create_dir_all(&root).unwrap();
27383        std::fs::write(root.join("only-file.txt"), b"fixture\n").unwrap();
27384
27385        let (_entries, truncated, budget) =
27386            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
27387        assert!(!truncated);
27388        assert!(workspace_inspection_truncation(&budget, false, Duration::from_millis(1)).is_none());
27389
27390        std::fs::remove_dir_all(&root).unwrap();
27391    }
27392
27393    #[test]
27394    fn workspace_walk_emits_strict_global_byte_order_for_prefix_named_siblings() {
27395        let root = temporary_workspace_root("tree-prefix-siblings");
27396        std::fs::create_dir_all(root.join("gate4agent-c2/src")).unwrap();
27397        std::fs::write(root.join("gate4agent-c2/src/lib.rs"), b"fixture\n").unwrap();
27398        std::fs::create_dir_all(root.join("gate4agent-c2-client")).unwrap();
27399        std::fs::write(root.join("gate4agent-c2-client/lib.rs"), b"fixture\n").unwrap();
27400
27401        let (entries, truncated, _budget) =
27402            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
27403        assert!(!truncated);
27404        let paths = entries
27405            .iter()
27406            .map(|entry| entry.relative_path.as_utf8().unwrap().to_owned())
27407            .collect::<Vec<_>>();
27408        assert!(paths.windows(2).all(|pair| pair[0] < pair[1]));
27409        assert_eq!(
27410            paths,
27411            vec![
27412                "gate4agent-c2".to_owned(),
27413                "gate4agent-c2-client".to_owned(),
27414                "gate4agent-c2-client/lib.rs".to_owned(),
27415                "gate4agent-c2/src".to_owned(),
27416                "gate4agent-c2/src/lib.rs".to_owned(),
27417            ],
27418        );
27419
27420        std::fs::remove_dir_all(&root).unwrap();
27421    }
27422
27423    #[test]
27424    fn git_parsers_cap_status_and_commit_results() {
27425        let mut status_output = Vec::new();
27426        for index in 0..=GIT_STATUS_MAX_ENTRIES {
27427            status_output.extend_from_slice(format!(" M src/file-{index}.rs\0").as_bytes());
27428        }
27429        let mut snapshot = git_snapshot_for_parser();
27430        parse_git_status(&status_output, &mut snapshot);
27431        assert_eq!(snapshot.status.len(), GIT_STATUS_MAX_ENTRIES);
27432        assert!(snapshot.truncated);
27433        assert_eq!(snapshot.status[0].index_status, " ");
27434        assert_eq!(snapshot.status[0].worktree_status, "M");
27435
27436        snapshot.truncated = false;
27437        let commit_output = (0..=GIT_COMMIT_MAX_ENTRIES)
27438            .map(|index| format!("abc{index}\u{1f}commit {index}\n"))
27439            .collect::<String>();
27440        parse_git_commits(commit_output.as_bytes(), &mut snapshot);
27441        assert_eq!(snapshot.recent_commits.len(), GIT_COMMIT_MAX_ENTRIES);
27442        assert!(snapshot.truncated);
27443    }
27444
27445    #[test]
27446    fn porcelain_v1_z_parser_preserves_current_then_previous_rename_and_copy_order() {
27447        let mut snapshot = git_snapshot_for_parser();
27448        parse_git_status(
27449            b"R  src/current name.rs\0src/previous name.rs\0 C src/copy.rs\0src/original.rs\0?? src/line\nname.rs\0",
27450            &mut snapshot,
27451        );
27452
27453        assert!(!snapshot.truncated);
27454        assert_eq!(snapshot.status.len(), 3);
27455        assert_eq!(snapshot.status[0].index_status, "R");
27456        assert_eq!(snapshot.status[0].worktree_status, " ");
27457        assert_eq!(snapshot.status[0].path.as_utf8(), Some("src/current name.rs"));
27458        assert_eq!(
27459            snapshot.status[0]
27460                .previous_path
27461                .as_ref()
27462                .and_then(RepositoryPath::as_utf8),
27463            Some("src/previous name.rs"),
27464        );
27465        assert_eq!(snapshot.status[1].index_status, " ");
27466        assert_eq!(snapshot.status[1].worktree_status, "C");
27467        assert_eq!(snapshot.status[1].path.as_utf8(), Some("src/copy.rs"));
27468        assert_eq!(
27469            snapshot.status[1]
27470                .previous_path
27471                .as_ref()
27472                .and_then(RepositoryPath::as_utf8),
27473            Some("src/original.rs"),
27474        );
27475        assert_eq!(snapshot.status[2].path.as_utf8(), Some("src/line\nname.rs"));
27476        assert!(snapshot.status[2].previous_path.is_none());
27477    }
27478
27479    #[test]
27480    fn porcelain_v1_z_parser_never_invents_invalid_or_incomplete_path_identity() {
27481        let mut snapshot = git_snapshot_for_parser();
27482        parse_git_status(
27483            b"?? valid.rs\0?? invalid-\xff.rs\0?? invalid\\windows.rs\0R  current.rs\0",
27484            &mut snapshot,
27485        );
27486
27487        assert!(snapshot.truncated);
27488        assert_eq!(snapshot.status.len(), 1);
27489        assert_eq!(snapshot.status[0].path.as_utf8(), Some("valid.rs"));
27490        assert!(snapshot.status[0].previous_path.is_none());
27491    }
27492
27493    #[tokio::test]
27494    async fn observer_can_browse_host_directories_without_controller() {
27495        let root = temporary_workspace_root("host-directory-observer");
27496        std::fs::create_dir_all(root.join("directory-b")).unwrap();
27497        std::fs::create_dir_all(root.join("directory-a")).unwrap();
27498        std::fs::write(root.join("ordinary-file"), b"not a directory").unwrap();
27499        let workspace_id = WorkspaceId::new("primary").unwrap();
27500        let workspace = WorkspaceConfig::new(workspace_id, &root).unwrap();
27501        let catalog = active_registry().unwrap();
27502        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27503        let shared = NodeShared::new(
27504            handle,
27505            "fixture-token".to_owned(),
27506            NodeId::new("node-1").unwrap(),
27507            vec![workspace],
27508            vec![agent("claude")],
27509        );
27510        let request = NodeRequest::BrowseHostDirectories {
27511            directory: Some(OpaqueHostPath::utf8(root.to_string_lossy().into_owned()).unwrap()),
27512            after: None,
27513        };
27514        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
27515            capability.as_str() == CAPABILITY_HOST_DIRECTORY_BROWSE_V1
27516        }));
27517        assert!(!request_uses_unnegotiated_capability(
27518            &request,
27519            &baseline_capabilities().unwrap(),
27520        ));
27521        assert!(request_uses_unnegotiated_capability(&request, &[]));
27522
27523        let response = process_request_inner(&shared, 77, ClientRole::Observer, request)
27524            .await
27525            .unwrap();
27526        let NodeResponse::HostDirectoriesBrowsed { listing } = response else {
27527            panic!("host directory browse returned another response");
27528        };
27529        assert_eq!(
27530            listing
27531                .entries
27532                .iter()
27533                .map(|entry| entry.display_name.as_str())
27534                .collect::<Vec<_>>(),
27535            vec!["directory-a", "directory-b"],
27536        );
27537        assert!(listing.entries.iter().all(|entry| !entry.is_link));
27538        assert!(!listing.incomplete);
27539        std::fs::remove_dir_all(root).unwrap();
27540    }
27541
27542    #[tokio::test]
27543    async fn observer_can_inspect_only_a_registered_workspace() {
27544        let root = temporary_workspace_root("observer");
27545        std::fs::create_dir_all(root.join("src")).unwrap();
27546        std::fs::write(root.join("src/main.rs"), b"fn main() {}\n").unwrap();
27547        let workspace_id = WorkspaceId::new("primary").unwrap();
27548        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27549        let catalog = active_registry().unwrap();
27550        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27551        let shared = NodeShared::new(
27552            handle,
27553            "fixture-token".to_owned(),
27554            NodeId::new("node-1").unwrap(),
27555            vec![workspace],
27556            vec![agent("claude")],
27557        );
27558
27559        let response = process_request_inner(
27560            &shared,
27561            77,
27562            ClientRole::Observer,
27563            NodeRequest::InspectWorkspace {
27564                workspace_id: workspace_id.clone(),
27565            },
27566        )
27567        .await
27568        .unwrap();
27569        let NodeResponse::WorkspaceInspected { inspection } = response else {
27570            panic!("workspace inspection returned another response");
27571        };
27572        assert_eq!(inspection.workspace_id, workspace_id);
27573        assert!(inspection.entries.iter().any(|entry| {
27574            entry.relative_path.as_utf8() == Some("src/main.rs")
27575        }));
27576
27577        let unknown = process_request_inner(
27578            &shared,
27579            77,
27580            ClientRole::Observer,
27581            NodeRequest::InspectWorkspace {
27582                workspace_id: WorkspaceId::new("unknown").unwrap(),
27583            },
27584        )
27585        .await
27586        .unwrap_err();
27587        assert_eq!(unknown.code, NodeFailureCode::UnknownWorkspace);
27588        std::fs::remove_dir_all(&root).unwrap();
27589    }
27590
27591    #[test]
27592    fn context_pack_source_accepts_only_running_or_successful_exit() {
27593        assert!(context_pack_source_status_is_usable(&SessionStatus::Running));
27594        assert!(context_pack_source_status_is_usable(&SessionStatus::Exited {
27595            exit_code: Some(0),
27596        }));
27597        assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
27598            exit_code: Some(1),
27599        }));
27600        assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
27601            exit_code: None,
27602        }));
27603        assert!(!context_pack_source_status_is_usable(&SessionStatus::Failed {
27604            message: "provider failed".to_owned(),
27605        }));
27606        assert!(!context_pack_source_status_is_usable(&SessionStatus::Registered));
27607        assert!(!context_pack_source_status_is_usable(&SessionStatus::Starting));
27608        assert!(!context_pack_source_status_is_usable(&SessionStatus::Stopping));
27609    }
27610
27611    #[test]
27612    fn context_pack_repository_rejects_changed_head_with_same_git_snapshot() {
27613        let git = git_snapshot_for_parser();
27614        let first = ContextPackRepositoryObservation {
27615            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27616            git: git.clone(),
27617            files: Vec::new(),
27618        };
27619        let second = ContextPackRepositoryObservation {
27620            head: ContextPackRepositoryHead::Commit("b".repeat(40)),
27621            git,
27622            files: Vec::new(),
27623        };
27624
27625        let error = stable_context_pack_repository(first, second).unwrap_err();
27626
27627        assert_eq!(
27628            error.code,
27629            NodeFailureCode::ContextPackMaterializationFailed,
27630        );
27631    }
27632
27633    #[test]
27634    fn context_pack_repository_rejects_changed_file_with_same_git_snapshot() {
27635        let git = git_snapshot_for_parser();
27636        let first = ContextPackRepositoryObservation {
27637            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27638            git: git.clone(),
27639            files: vec![ContextPackRepositoryFileSource::utf8(
27640                "README.md",
27641                "first\n".to_owned(),
27642                6,
27643            )],
27644        };
27645        let second = ContextPackRepositoryObservation {
27646            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27647            git,
27648            files: vec![ContextPackRepositoryFileSource::utf8(
27649                "README.md",
27650                "other\n".to_owned(),
27651                6,
27652            )],
27653        };
27654
27655        let error = stable_context_pack_repository(first, second).unwrap_err();
27656
27657        assert_eq!(
27658            error.code,
27659            NodeFailureCode::ContextPackMaterializationFailed,
27660        );
27661    }
27662
27663    #[tokio::test]
27664    async fn native_session_catalog_zero_active_session_fixture() {
27665        let workspace_root = temporary_workspace_root("native-catalog-workspace");
27666        let history_root = temporary_workspace_root("native-catalog-history");
27667        let outside_root = temporary_workspace_root("native-catalog-outside");
27668        std::fs::create_dir_all(&workspace_root).unwrap();
27669        std::fs::create_dir_all(&history_root).unwrap();
27670        std::fs::create_dir_all(&outside_root).unwrap();
27671        let transcript = |session_id: &str, cwd: &Path, title: &str| {
27672            [
27673                serde_json::json!({
27674                    "type": "user",
27675                    "sessionId": session_id,
27676                    "cwd": cwd.to_str().unwrap(),
27677                    "message": { "content": "raw user text must not cross the catalog wire" }
27678                }),
27679                serde_json::json!({
27680                    "type": "assistant",
27681                    "sessionId": session_id,
27682                    "cwd": cwd.to_str().unwrap(),
27683                    "message": { "model": "claude-sonnet", "content": "raw answer" }
27684                }),
27685                serde_json::json!({
27686                    "type": "ai-title",
27687                    "sessionId": session_id,
27688                    "aiTitle": title
27689                }),
27690            ]
27691            .into_iter()
27692            .map(|record| record.to_string())
27693            .collect::<Vec<_>>()
27694            .join("\n")
27695        };
27696        std::fs::write(
27697            history_root.join("stable-session.jsonl"),
27698            transcript("stable-session", &workspace_root, "Workspace review"),
27699        )
27700        .unwrap();
27701        std::fs::write(
27702            history_root.join("outside-session.jsonl"),
27703            transcript("outside-session", &outside_root, "Outside review"),
27704        )
27705        .unwrap();
27706        let history = NativeHistoryConfig::new(vec![
27707            NativeHistoryRoot::new(
27708                gate4agent_types::AdapterId::new("claude-code").unwrap(),
27709                HistorySourceLayout::SingleNdjson,
27710                history_root.clone(),
27711            )
27712            .unwrap(),
27713        ])
27714        .unwrap();
27715        let catalog = active_registry().unwrap();
27716        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27717        assert!(handle.snapshot().sessions.is_empty());
27718        let workspace_id = WorkspaceId::new("primary").unwrap();
27719        let workspace = WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap();
27720        let mut shared = NodeShared::new(
27721            handle,
27722            "fixture-token".to_owned(),
27723            NodeId::new("node-native-catalog").unwrap(),
27724            vec![workspace],
27725            vec![agent("claude")],
27726        );
27727        shared.native_session_catalog = Some(Arc::new(Mutex::new(
27728            NativeSessionCatalogAuthority::new(history),
27729        )));
27730        let route = NativeSessionCatalogRoute::workspace(
27731            workspace_id.clone(),
27732            agent("claude"),
27733        );
27734        let request = NodeRequest::CatalogNativeSessions {
27735            route: route.clone(),
27736            limit: 2,
27737        };
27738        let observer = process_request_inner(&shared, 7, ClientRole::Observer, request.clone())
27739            .await
27740            .unwrap_err();
27741        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27742        let response = process_request_inner(&shared, 7, ClientRole::Operator, request)
27743            .await
27744            .unwrap();
27745        let NodeResponse::NativeSessionsCataloged { entries, summary, .. } = response else {
27746            panic!("native catalog returned a different response");
27747        };
27748        let summary = summary.expect("node must emit native session catalog summary metadata");
27749        assert_eq!(entries.len(), 1);
27750        assert!(gate4agent_types::validate_candidate_id(&entries[0].selection_id).is_ok());
27751        assert_eq!(entries[0].external_group, None);
27752        assert_eq!(entries[0].record_id, None);
27753        assert_eq!(entries[0].title, None);
27754        assert_eq!(entries[0].model, None);
27755        assert_eq!(entries[0].message_count, 0);
27756        let encoded_entries = serde_json::to_string(&entries).unwrap();
27757        assert!(!encoded_entries.contains("stable-session"));
27758        assert!(!encoded_entries.contains(history_root.to_string_lossy().as_ref()));
27759        let page_request = NodeRequest::PageNativeSessions {
27760            route: route.clone(),
27761            window: crate::protocol::NativeSessionCatalogWindow::Recent,
27762            catalog_revision: summary.catalog_revision,
27763            recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27764            after_selection_id: None,
27765            limit: 2,
27766        };
27767        let observer = process_request_inner(
27768            &shared,
27769            7,
27770            ClientRole::Observer,
27771            page_request.clone(),
27772        )
27773        .await
27774        .unwrap_err();
27775        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27776        let page_response = process_request_inner(
27777            &shared,
27778            7,
27779            ClientRole::Operator,
27780            page_request.clone(),
27781        )
27782        .await
27783        .unwrap();
27784        assert!(matches!(
27785            page_response,
27786            NodeResponse::NativeSessionsPaged { page, .. }
27787                if page.entries.len() == 1 && !page.has_more && page.remaining_count == 0
27788        ));
27789        let stale = process_request_inner(
27790            &shared,
27791            7,
27792            ClientRole::Operator,
27793            NodeRequest::PageNativeSessions {
27794                route: route.clone(),
27795                window: crate::protocol::NativeSessionCatalogWindow::Recent,
27796                catalog_revision: summary.catalog_revision.wrapping_add(1),
27797                recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27798                after_selection_id: None,
27799                limit: 2,
27800            },
27801        )
27802        .await
27803        .unwrap_err();
27804        assert_eq!(stale.code, NodeFailureCode::StaleNativeSessionCatalog);
27805        let selection = NativeSessionSelection {
27806            route: route.clone(),
27807            catalog_revision: summary.catalog_revision,
27808            recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27809            selection_id: entries[0].selection_id.clone(),
27810        };
27811        let preview_response = process_request_inner(
27812            &shared,
27813            7,
27814            ClientRole::Operator,
27815            NodeRequest::PreviewNativeSession {
27816                selection: selection.clone(),
27817                message_limit: 1,
27818            },
27819        )
27820        .await
27821        .unwrap();
27822        let NodeResponse::NativeSessionPreviewed { preview, .. } = preview_response else {
27823            panic!("native preview returned a different response");
27824        };
27825        assert_eq!(preview.messages.len(), 1);
27826        assert!(preview.message_count_exact);
27827        assert_eq!(preview.messages[0].text, "raw answer");
27828        assert!(preview.truncated);
27829
27830        let index_request = NodeRequest::IndexNativeSession {
27831            selection: selection.clone(),
27832            display_name: "Saved Claude".to_owned(),
27833        };
27834        let observer = process_request_inner(
27835            &shared,
27836            7,
27837            ClientRole::Observer,
27838            index_request.clone(),
27839        )
27840        .await
27841        .unwrap_err();
27842        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27843        let without_controller = process_request_inner(
27844            &shared,
27845            7,
27846            ClientRole::Operator,
27847            index_request.clone(),
27848        )
27849        .await
27850        .unwrap_err();
27851        assert_eq!(without_controller.code, NodeFailureCode::ControllerRequired);
27852        let acquired = process_request_inner(
27853            &shared,
27854            7,
27855            ClientRole::Operator,
27856            NodeRequest::AcquireController { lease_ms: 5_000 },
27857        )
27858        .await
27859        .unwrap();
27860        assert!(matches!(acquired, NodeResponse::Controller { controller: Some(_) }));
27861        let indexed = process_request_inner(
27862            &shared,
27863            7,
27864            ClientRole::Operator,
27865            index_request,
27866        )
27867        .await
27868        .unwrap();
27869        assert!(indexed.native_session_index_contract_is_valid());
27870        let NodeResponse::NativeSessionIndexed {
27871            selection: echoed_selection,
27872            record,
27873        } = indexed else {
27874            panic!("native session index returned a different response");
27875        };
27876        assert_eq!(echoed_selection, selection);
27877        let recataloged = process_request_inner(
27878            &shared,
27879            7,
27880            ClientRole::Operator,
27881            NodeRequest::CatalogNativeSessions {
27882                route,
27883                limit: 2,
27884            },
27885        )
27886        .await
27887        .unwrap();
27888        let NodeResponse::NativeSessionsCataloged { entries, .. } = recataloged else {
27889            panic!("native catalog returned a different response after indexing");
27890        };
27891        assert_eq!(entries[0].record_id.as_ref(), Some(&record.record_id));
27892
27893        let unregistered_route = NativeSessionCatalogRoute::unregistered(agent("claude"));
27894        let external_catalog = process_request_inner(
27895            &shared,
27896            7,
27897            ClientRole::Operator,
27898            NodeRequest::CatalogNativeSessions {
27899                route: unregistered_route.clone(),
27900                limit: 2,
27901            },
27902        )
27903        .await
27904        .unwrap();
27905        let external_json = serde_json::to_string(&external_catalog).unwrap();
27906        assert!(!external_json.contains("outside-session"));
27907        assert!(!external_json.contains(outside_root.to_string_lossy().as_ref()));
27908        assert!(!external_json.contains(history_root.to_string_lossy().as_ref()));
27909        assert!(!external_json.contains("\"session_id\""));
27910        assert!(!external_json.contains("\"cwd\""));
27911        assert!(!external_json.contains("\"path\""));
27912        let NodeResponse::NativeSessionsCataloged {
27913            route: echoed_route,
27914            entries: external_entries,
27915            summary: Some(external_summary),
27916        } = external_catalog
27917        else {
27918            panic!("unregistered native catalog returned a different response");
27919        };
27920        assert_eq!(echoed_route, unregistered_route);
27921        assert_eq!(external_entries.len(), 1);
27922        assert_eq!(external_entries[0].record_id, None);
27923        let external_group = external_entries[0]
27924            .external_group
27925            .as_ref()
27926            .expect("unregistered session must have an external group");
27927        assert_eq!(
27928            external_group.kind,
27929            gate4agent_types::NativeSessionExternalGroupKind::Project,
27930        );
27931        assert!(external_group.group_id.starts_with("external-"));
27932        assert_eq!(
27933            external_group.display_name,
27934            outside_root.file_name().unwrap().to_string_lossy(),
27935        );
27936        let external_selection = NativeSessionSelection {
27937            route: unregistered_route,
27938            catalog_revision: external_summary.catalog_revision,
27939            recent_cutoff_unix_ms: external_summary.recent_cutoff_unix_ms,
27940            selection_id: external_entries[0].selection_id.clone(),
27941        };
27942        let external_preview = process_request_inner(
27943            &shared,
27944            7,
27945            ClientRole::Operator,
27946            NodeRequest::PreviewNativeSession {
27947                selection: external_selection.clone(),
27948                message_limit: 1,
27949            },
27950        )
27951        .await
27952        .unwrap();
27953        let external_preview_json = serde_json::to_string(&external_preview).unwrap();
27954        assert!(!external_preview_json.contains("outside-session"));
27955        assert!(!external_preview_json.contains(outside_root.to_string_lossy().as_ref()));
27956        assert!(!external_preview_json.contains("\"session_id\""));
27957        let NodeResponse::NativeSessionPreviewed { preview, .. } = external_preview else {
27958            panic!("unregistered native preview returned a different response");
27959        };
27960        assert_eq!(preview.messages.len(), 1);
27961        let external_index = process_request_inner(
27962            &shared,
27963            7,
27964            ClientRole::Operator,
27965            NodeRequest::IndexNativeSession {
27966                selection: external_selection,
27967                display_name: "External Claude".to_owned(),
27968            },
27969        )
27970        .await
27971        .unwrap_err();
27972        assert_eq!(
27973            external_index.code,
27974            NodeFailureCode::WorkspaceRegistrationRequired,
27975        );
27976        let record_response = process_request_inner(
27977            &shared,
27978            7,
27979            ClientRole::Operator,
27980            NodeRequest::PreviewSessionRecord {
27981                record_id: record.record_id.clone(),
27982                message_limit: 2,
27983            },
27984        )
27985        .await
27986        .unwrap();
27987        let NodeResponse::SessionRecordPreviewed { record_id, preview } = record_response else {
27988            panic!("record preview returned a different response");
27989        };
27990        assert_eq!(record_id, record.record_id);
27991        assert_eq!(preview.messages.len(), 2);
27992        let encoded = serde_json::to_string(&preview).unwrap();
27993        assert!(!encoded.contains("stable-session"));
27994        assert!(!encoded.contains(history_root.to_string_lossy().as_ref()));
27995        let NodeResponse::Resync { events, .. } = shared.resync(0) else {
27996            panic!("record preview history summary returned a different response");
27997        };
27998        let summaries = events
27999            .iter()
28000            .filter_map(|envelope| match &envelope.event {
28001                NodeEvent::SessionRecordHistorySummarized { record_id, summary }
28002                    if record_id == &record.record_id =>
28003                {
28004                    Some(*summary)
28005                }
28006                _ => None,
28007            })
28008            .collect::<Vec<_>>();
28009        assert_eq!(summaries, vec![SessionHistorySummaryV1::from(&preview)]);
28010        let summary_json = serde_json::to_string(&summaries).unwrap();
28011        for private in [
28012            "stable-session",
28013            "raw user text must not cross the catalog wire",
28014            "raw answer",
28015            "Workspace review",
28016            "claude-sonnet",
28017        ] {
28018            assert!(!summary_json.contains(private), "{private}");
28019        }
28020        let identity = record.provider_session.clone().unwrap();
28021        assert_eq!(
28022            shared.revalidate_session_record_preview(&record, &identity),
28023            Ok(())
28024        );
28025        shared.remove_record_memory(&record.record_id);
28026        assert_eq!(
28027            shared
28028                .revalidate_session_record_preview(&record, &identity)
28029                .unwrap_err()
28030                .code,
28031            NodeFailureCode::UnknownSessionRecord
28032        );
28033        let released = process_request_inner(
28034            &shared,
28035            7,
28036            ClientRole::Operator,
28037            NodeRequest::ReleaseController,
28038        )
28039        .await
28040        .unwrap();
28041        assert!(matches!(released, NodeResponse::Controller { controller: None }));
28042        assert!(shared.controller_state().is_none());
28043        std::fs::remove_dir_all(workspace_root).unwrap();
28044        std::fs::remove_dir_all(history_root).unwrap();
28045        std::fs::remove_dir_all(outside_root).unwrap();
28046    }
28047
28048    #[tokio::test]
28049    async fn native_session_catalog_concurrent_operation_fails_fast_without_waiting_on_mutex() {
28050        let history_root = temporary_workspace_root("native-catalog-concurrent-operation");
28051        std::fs::create_dir_all(&history_root).unwrap();
28052        let history = NativeHistoryConfig::new(vec![
28053            NativeHistoryRoot::new(
28054                gate4agent_types::AdapterId::new("claude-code").unwrap(),
28055                HistorySourceLayout::SingleNdjson,
28056                history_root.clone(),
28057            )
28058            .unwrap(),
28059        ])
28060        .unwrap();
28061        let catalog = Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)));
28062        let held = catalog.lock().unwrap();
28063        let result = timeout(
28064            Duration::from_secs(1),
28065            run_native_session_catalog_operation(
28066                Arc::clone(&catalog),
28067                "native session catalog",
28068                |_| Ok::<(), std::convert::Infallible>(()),
28069            ),
28070        )
28071        .await
28072        .expect("concurrent native catalog request must not wait for the operation deadline")
28073        .unwrap_err();
28074        assert_eq!(result.code, NodeFailureCode::BackendBusy);
28075        drop(held);
28076        std::fs::remove_dir_all(history_root).unwrap();
28077    }
28078
28079    fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
28080        let unique = std::time::SystemTime::now()
28081            .duration_since(std::time::UNIX_EPOCH)
28082            .unwrap()
28083            .as_nanos();
28084        std::env::temp_dir().join(format!(
28085            "gate4agent-node-{label}-{}-{unique}",
28086            std::process::id(),
28087        ))
28088    }
28089
28090    fn git_snapshot_for_parser() -> GitSnapshot {
28091        GitSnapshot {
28092            is_repository: true,
28093            branch: Some("main".to_owned()),
28094            status: Vec::new(),
28095            recent_commits: Vec::new(),
28096            worktrees: Vec::new(),
28097            managed_worktree: None,
28098            truncated: false,
28099            diagnostic: None,
28100        }
28101    }
28102
28103    #[tokio::test]
28104    async fn workspace_inspection_scopes_only_exact_active_managed_branch() {
28105        let root = temporary_workspace_root("managed-git-scope");
28106        std::fs::create_dir_all(&root).unwrap();
28107        run_test_git(&root, &["init"]);
28108        run_test_git(&root, &["config", "user.email", "gate4agent@example.invalid"]);
28109        run_test_git(&root, &["config", "user.name", "Gate4Agent Test"]);
28110        std::fs::write(root.join("seed.txt"), "seed\n").unwrap();
28111        run_test_git(&root, &["add", "seed.txt"]);
28112        run_test_git(&root, &["commit", "-m", "seed"]);
28113        run_test_git(&root, &["checkout", "-b", "gate4agent/a"]);
28114
28115        let workspace_id = WorkspaceId::new("managed-a").unwrap();
28116        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
28117        let catalog = active_registry().unwrap();
28118        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28119        let shared = NodeShared::new(
28120            handle,
28121            "fixture-token".to_owned(),
28122            NodeId::new("node-1").unwrap(),
28123            vec![workspace],
28124            vec![agent("claude")],
28125        );
28126        let mut lease = ManagedWorktreeLeaseRecord {
28127            lease_id: ManagedWorktreeLeaseId::new("mw-a").unwrap(),
28128            source_workspace_id: WorkspaceId::new("primary").unwrap(),
28129            workspace_id: workspace_id.clone(),
28130            profile_id: WorktreeProfileId::new("default").unwrap(),
28131            profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
28132            target_root: root.to_string_lossy().into_owned(),
28133            branch: "gate4agent/a".to_owned(),
28134            base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
28135            expected_head: None,
28136            retention: ManagedWorktreeRetention::Retain,
28137            state: ManagedWorktreeLeaseState::InUse,
28138            session_holders: vec![ManagedWorktreeSessionHolder {
28139                incarnation_id: shared.incarnation_id,
28140                instance_id: AgentInstanceId(7),
28141                generation: SessionGeneration(1),
28142            }],
28143            record_holders: Vec::new(),
28144            cleanup_failure: None,
28145            created_at_unix_ms: 1,
28146            updated_at_unix_ms: 1,
28147        };
28148        *shared
28149            .managed_worktrees
28150            .lock()
28151            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
28152            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
28153
28154        let inspection = shared.inspect_workspace(workspace_id.clone()).await.unwrap();
28155        let scope = inspection.git.managed_worktree.unwrap();
28156        assert_eq!(scope.lease_id, lease.lease_id);
28157        assert_eq!(scope.branch, "gate4agent/a");
28158        assert_eq!(scope.active_session_count, 1);
28159
28160        lease.branch = "gate4agent/b".to_owned();
28161        *shared
28162            .managed_worktrees
28163            .lock()
28164            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
28165            ManagedWorktreeRegistry::from_records(vec![lease], Vec::new()).unwrap();
28166        let mismatch = shared.inspect_workspace(workspace_id).await.unwrap();
28167        assert!(mismatch.git.managed_worktree.is_none());
28168        std::fs::remove_dir_all(root).unwrap();
28169    }
28170
28171    #[tokio::test]
28172    async fn workspace_file_write_requires_controller_and_rejects_stale_revision() {
28173        let root = temporary_workspace_root("workspace-file-write-controller");
28174        std::fs::create_dir_all(root.join("src")).unwrap();
28175        std::fs::write(root.join("src/lib.rs"), b"old\n").unwrap();
28176        let workspace_id = WorkspaceId::new("primary").unwrap();
28177        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
28178        let catalog = active_registry().unwrap();
28179        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28180        let shared = NodeShared::new(
28181            handle,
28182            "fixture-token".to_owned(),
28183            NodeId::new("node-1").unwrap(),
28184            vec![workspace],
28185            vec![agent("claude")],
28186        );
28187        let path = RepositoryPath::utf8("src/lib.rs".to_owned()).unwrap();
28188        let expected_revision = workspace_file_revision(b"old\n");
28189        let request = NodeRequest::WriteWorkspaceFile {
28190            workspace_id: workspace_id.clone(),
28191            path: path.clone(),
28192            expected_revision: expected_revision.clone(),
28193            text: "new\n".to_owned(),
28194        };
28195        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
28196            .await
28197            .unwrap_err();
28198        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
28199        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
28200            .await
28201            .unwrap_err();
28202        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
28203        shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
28204        let response = process_request_inner(&shared, 77, ClientRole::Operator, request).await.unwrap();
28205        let NodeResponse::WorkspaceFileWritten { file } = response else {
28206            panic!("workspace file write returned another response");
28207        };
28208        assert_eq!(file.revision, Some(workspace_file_revision(b"new\n")));
28209        assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
28210        let stale = process_request_inner(
28211            &shared,
28212            77,
28213            ClientRole::Operator,
28214            NodeRequest::WriteWorkspaceFile {
28215                workspace_id,
28216                path,
28217                expected_revision,
28218                text: "lost update\n".to_owned(),
28219            },
28220        )
28221        .await
28222        .unwrap_err();
28223        assert_eq!(stale.code, NodeFailureCode::RepositoryFileRevisionConflict);
28224        assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
28225        std::fs::remove_dir_all(root).unwrap();
28226    }
28227
28228    #[tokio::test]
28229    async fn workspace_entry_create_requires_controller_and_never_overwrites() {
28230        let root = temporary_workspace_root("workspace-entry-create-controller");
28231        std::fs::create_dir_all(root.join("src")).unwrap();
28232        let workspace_id = WorkspaceId::new("primary").unwrap();
28233        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
28234        let catalog = active_registry().unwrap();
28235        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28236        let shared = NodeShared::new(
28237            handle,
28238            "fixture-token".to_owned(),
28239            NodeId::new("node-1").unwrap(),
28240            vec![workspace],
28241            vec![agent("claude")],
28242        );
28243        let path = RepositoryPath::utf8("src/new.rs".to_owned()).unwrap();
28244        let request = NodeRequest::CreateWorkspaceFile {
28245            workspace_id: workspace_id.clone(),
28246            path: path.clone(),
28247        };
28248        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
28249            capability.as_str() == NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY
28250        }));
28251        assert!(!request_uses_unnegotiated_capability(
28252            &request,
28253            &baseline_capabilities().unwrap(),
28254        ));
28255        assert!(request_uses_unnegotiated_capability(&request, &[]));
28256        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
28257            .await
28258            .unwrap_err();
28259        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
28260        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
28261            .await
28262            .unwrap_err();
28263        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
28264        shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
28265
28266        let response = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
28267            .await
28268            .unwrap();
28269        let NodeResponse::WorkspaceFileCreated { file } = response else {
28270            panic!("workspace file create returned another response");
28271        };
28272        assert_eq!(file.workspace_id, workspace_id);
28273        assert_eq!(file.path, path);
28274        assert_eq!(file.revision, Some(workspace_file_revision(&[])));
28275        assert_eq!(std::fs::read(root.join("src/new.rs")).unwrap(), Vec::<u8>::new());
28276
28277        let duplicate = process_request_inner(&shared, 77, ClientRole::Operator, request)
28278            .await
28279            .unwrap_err();
28280        assert_eq!(duplicate.code, NodeFailureCode::RepositoryEntryAlreadyExists);
28281
28282        let directory_path = RepositoryPath::utf8("src/nested".to_owned()).unwrap();
28283        let response = process_request_inner(
28284            &shared,
28285            77,
28286            ClientRole::Operator,
28287            NodeRequest::CreateWorkspaceDirectory {
28288                workspace_id: workspace_id.clone(),
28289                path: directory_path.clone(),
28290            },
28291        )
28292        .await
28293        .unwrap();
28294        assert_eq!(
28295            response,
28296            NodeResponse::WorkspaceDirectoryCreated {
28297                workspace_id: workspace_id.clone(),
28298                entry: WorkspaceEntry {
28299                    relative_path: directory_path,
28300                    kind: WorkspaceEntryKind::Directory,
28301                },
28302            },
28303        );
28304        assert!(root.join("src/nested").is_dir());
28305
28306        let missing_parent = process_request_inner(
28307            &shared,
28308            77,
28309            ClientRole::Operator,
28310            NodeRequest::CreateWorkspaceFile {
28311                workspace_id: workspace_id.clone(),
28312                path: RepositoryPath::utf8("missing/new.rs".to_owned()).unwrap(),
28313            },
28314        )
28315        .await
28316        .unwrap_err();
28317        assert_eq!(missing_parent.code, NodeFailureCode::RepositoryParentNotFound);
28318        let non_directory_parent = process_request_inner(
28319            &shared,
28320            77,
28321            ClientRole::Operator,
28322            NodeRequest::CreateWorkspaceFile {
28323                workspace_id,
28324                path: RepositoryPath::utf8("src/new.rs/child".to_owned()).unwrap(),
28325            },
28326        )
28327        .await
28328        .unwrap_err();
28329        assert_eq!(
28330            non_directory_parent.code,
28331            NodeFailureCode::RepositoryParentNotDirectory,
28332        );
28333        std::fs::remove_dir_all(root).unwrap();
28334    }
28335
28336    #[tokio::test]
28337    async fn workspace_entry_create_timeout_cancels_before_commit_without_late_mutation() {
28338        let root = temporary_workspace_root("workspace-entry-create-timeout-cancel");
28339        std::fs::create_dir_all(root.join("src")).unwrap();
28340        #[cfg(windows)]
28341        let root = std::fs::canonicalize(root).unwrap();
28342        let path = RepositoryPath::utf8("src/late.rs".to_owned()).unwrap();
28343        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
28344        let barrier = Arc::new(std::sync::Barrier::new(2));
28345        let done = Arc::new(AtomicBool::new(false));
28346        let task_commit_state = Arc::clone(&commit_state);
28347        let task_barrier = Arc::clone(&barrier);
28348        let task_done = Arc::clone(&done);
28349        let task_root = root.clone();
28350        let task = tokio::task::spawn_blocking(move || {
28351            task_barrier.wait();
28352            let result = create_workspace_file_on_disk(
28353                &task_root,
28354                &path,
28355                &task_commit_state,
28356            );
28357            task_done.store(true, Ordering::Release);
28358            result
28359        });
28360
28361        let error = settle_workspace_entry_create(
28362            task,
28363            Arc::clone(&commit_state),
28364            Duration::from_millis(10),
28365            "fixture deadline",
28366            "fixture task failed",
28367        )
28368        .await
28369        .unwrap_err();
28370        assert_eq!(error.code, NodeFailureCode::RepositoryEntryCreateTimedOut);
28371        assert_eq!(
28372            commit_state.load(Ordering::Acquire),
28373            WORKSPACE_ENTRY_CREATE_CANCELED,
28374        );
28375
28376        barrier.wait();
28377        timeout(Duration::from_secs(1), async {
28378            while !done.load(Ordering::Acquire) {
28379                tokio::task::yield_now().await;
28380            }
28381        })
28382        .await
28383        .expect("canceled blocking create did not settle");
28384        assert!(!root.join("src/late.rs").exists());
28385        std::fs::remove_dir_all(root).unwrap();
28386    }
28387}
28388
28389#[cfg(test)]
28390mod provisional_spawn_tests {
28391    use super::*;
28392    use std::sync::Arc;
28393
28394    fn shared() -> NodeShared {
28395        let catalog = active_registry().unwrap();
28396        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28397        let workspace = WorkspaceConfig::new(
28398            WorkspaceId::new("primary").unwrap(),
28399            std::env::current_dir().unwrap(),
28400        )
28401        .unwrap()
28402        .with_worktree_service_mode(WorktreeServiceMode::Manual);
28403        NodeShared::new(
28404            handle,
28405            "fixture-token".to_owned(),
28406            NodeId::new("node-provisional-test").unwrap(),
28407            vec![workspace],
28408            vec![AgentId::new("claude").unwrap()],
28409        )
28410    }
28411
28412    /// Provisional draft map: sessionize by instance id only; 50 sequential
28413    /// mark/claim cycles never collide, and a claimed draft is never reaped.
28414    #[test]
28415    fn provisional_spawn_keys_are_unique_and_sessionize_prevents_reap() {
28416        let shared = shared();
28417        let ws = WorkspaceId::new("primary").unwrap();
28418        let mut ids = Vec::with_capacity(50);
28419        for i in 1u64..=50 {
28420            let id = AgentInstanceId(i);
28421            ids.push(id);
28422            let address = SessionAddress {
28423                workspace_id: ws.clone(),
28424                session: SessionKey {
28425                    instance_id: id,
28426                    generation: SessionGeneration(1),
28427                },
28428            };
28429            shared.mark_spawn_provisional(address);
28430        }
28431        assert_eq!(
28432            shared
28433                .provisional_spawns
28434                .lock()
28435                .unwrap_or_else(|p| p.into_inner())
28436                .len(),
28437            50
28438        );
28439        for id in ids.iter().step_by(2) {
28440            shared.sessionize_spawn(*id);
28441        }
28442        assert_eq!(
28443            shared
28444                .provisional_spawns
28445                .lock()
28446                .unwrap_or_else(|p| p.into_inner())
28447                .len(),
28448            25
28449        );
28450        assert!(shared.take_due_provisional_spawns().is_empty());
28451        {
28452            let mut map = shared
28453                .provisional_spawns
28454                .lock()
28455                .unwrap_or_else(|p| p.into_inner());
28456            let past = Instant::now() - Duration::from_secs(91);
28457            for (_, (_, at)) in map.iter_mut() {
28458                *at = past;
28459            }
28460        }
28461        let due = shared.take_due_provisional_spawns();
28462        assert_eq!(due.len(), 25);
28463        let due_ids: std::collections::BTreeSet<_> =
28464            due.iter().map(|a| a.session.instance_id.0).collect();
28465        let expected: std::collections::BTreeSet<_> =
28466            ids.iter().skip(1).step_by(2).map(|id| id.0).collect();
28467        assert_eq!(due_ids, expected);
28468        assert!(shared
28469            .provisional_spawns
28470            .lock()
28471            .unwrap_or_else(|p| p.into_inner())
28472            .is_empty());
28473    }
28474
28475    /// Twenty parallel provisional marks must each get their own instance key;
28476    /// killing/sessionizing one id must not touch the others.
28477    #[test]
28478    fn provisional_spawn_parallel_marks_isolate_kills_by_instance_id() {
28479        let shared = Arc::new(shared());
28480        let ws = WorkspaceId::new("primary").unwrap();
28481        let mut handles = Vec::new();
28482        for i in 1u64..=20 {
28483            let shared = Arc::clone(&shared);
28484            let ws = ws.clone();
28485            handles.push(std::thread::spawn(move || {
28486                let id = AgentInstanceId(i);
28487                let address = SessionAddress {
28488                    workspace_id: ws,
28489                    session: SessionKey {
28490                        instance_id: id,
28491                        generation: SessionGeneration(1),
28492                    },
28493                };
28494                shared.mark_spawn_provisional(address);
28495                id
28496            }));
28497        }
28498        let mut ids = Vec::new();
28499        for h in handles {
28500            ids.push(h.join().unwrap());
28501        }
28502        ids.sort_by_key(|id| id.0);
28503        assert_eq!(ids.len(), 20);
28504        ids.dedup();
28505        assert_eq!(ids.len(), 20, "parallel marks must not share instance keys");
28506        assert_eq!(
28507            shared
28508                .provisional_spawns
28509                .lock()
28510                .unwrap_or_else(|p| p.into_inner())
28511                .len(),
28512            20
28513        );
28514        shared.sessionize_spawn(AgentInstanceId(7));
28515        let left: Vec<_> = shared
28516            .provisional_spawns
28517            .lock()
28518            .unwrap_or_else(|p| p.into_inner())
28519            .keys()
28520            .map(|id| id.0)
28521            .collect();
28522        assert!(!left.contains(&7));
28523        assert_eq!(left.len(), 19);
28524    }
28525}
28526
28527
28528#[cfg(unix)]
28529extern "C" {
28530    #[link_name = "geteuid"]
28531    fn libc_geteuid() -> u32;
28532}
28533
28534#[cfg(test)]
28535mod call_home_link_tests {
28536    use super::*;
28537
28538    #[test]
28539    fn call_home_backoff_is_capped_and_monotonic() {
28540        let mut last = Duration::ZERO;
28541        for attempt in 1..20 {
28542            let next = call_home_backoff(attempt);
28543            assert!(next >= last);
28544            assert!(next <= Duration::from_secs(8));
28545            last = next;
28546        }
28547        assert_eq!(call_home_backoff(0), Duration::from_millis(500));
28548    }
28549
28550    #[tokio::test]
28551    async fn call_home_socket_gets_keepalive() {
28552        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
28553        let addr = listener.local_addr().unwrap();
28554        let (client, _server) = tokio::join!(tokio::net::TcpStream::connect(addr), listener.accept());
28555        let client = client.unwrap();
28556        enable_call_home_keepalive(&client).unwrap();
28557        let sock = socket2::SockRef::from(&client);
28558        assert!(sock.keepalive().unwrap());
28559        #[cfg(any(target_os = "linux", target_os = "macos"))]
28560        assert_eq!(sock.tcp_keepalive_time().unwrap(), CALL_HOME_KEEPALIVE_IDLE);
28561    }
28562}
28563
28564/// A provider transcript for this session id exists in a known provider home.
28565fn provider_session_exists(provider: &str, id: &str) -> bool {
28566    if provider != "claude" { return false; }
28567    let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
28568    if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") { homes.extend(std::env::split_paths(&extra)); }
28569    let name = format!("{id}.jsonl");
28570    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())))
28571}
28572
28573/// Append launch args to a prepared overlay (or create an instance overlay for them).
28574fn overlay_with_extra_args(
28575    provider: &AgentId,
28576    mode: SessionMode,
28577    overlay: Option<PreparedNativeLaunchOverlay>,
28578    args: Vec<OsString>,
28579) -> Result<Option<PreparedNativeLaunchOverlay>, NodeFailure> {
28580    let bad = || failure(NodeFailureCode::BackendOperationFailed, "resume launch args overlay is invalid");
28581    Ok(match (overlay, args) {
28582        (prepared, args) if args.is_empty() => prepared,
28583        (Some(PreparedNativeLaunchOverlay::Instance(mut o)), args) => { o.append_extra_args(args).map_err(|_| bad())?; Some(PreparedNativeLaunchOverlay::Instance(o)) }
28584        (Some(PreparedNativeLaunchOverlay::Environment(o)), args) => Some(PreparedNativeLaunchOverlay::Instance(o.into_instance_with_extra_args(args).map_err(|_| bad())?)),
28585        (None, args) => Some(PreparedNativeLaunchOverlay::Instance(NativeInstanceLaunchOverlay::new(
28586            provider.clone(),
28587            match mode { SessionMode::Pty => TransportKind::Pty, SessionMode::Acp => TransportKind::Acp, SessionMode::Inline => TransportKind::Pipe },
28588            Vec::new(),
28589            args,
28590        ).map_err(|_| bad())?)),
28591    })
28592}