Skip to main content

hatchery_node/
server.rs

1use crate::git_worktree::{
2    create_worktree as create_git_worktree,
3    create_worktree_with_timeout as create_git_worktree_with_timeout,
4    list_worktrees as list_git_worktrees,
5    list_worktrees_with_deadline as list_git_worktrees_with_deadline,
6    paths_equal as worktree_paths_equal, remove_worktree as remove_git_worktree,
7    removal_lookup_path as normalize_worktree_removal_target,
8    resolve_base_commit_with_timeout,
9    run_git_read_bounded, GitCommandOutput, GitWorktreeError, GitWorktreeErrorKind,
10    NativeGitWorktreeSnapshot,
11};
12use crate::host_directory_browser::{
13    browse_host_directories, HostDirectoryBrowseError, HostDirectoryBrowseErrorKind,
14};
15use crate::environment_profiles::{
16    EnvironmentProfileBinding, NodeEnvironmentProfile, MAX_NODE_ENVIRONMENT_PROFILES,
17};
18use crate::bundle_catalog::{BundleCatalog, NodeBundle};
19use self::bundle_delivery::{DeliveryStore, DeliveryStoreError};
20use self::context_pack_store::ContextPackStore;
21use crate::bundle_provider::{
22    bundle_launch_arguments, validate_bundle_binding, BundleProviderLayout,
23};
24use crate::context_pack::{
25    ContextPackCatalog, ContextPackRepository, ContextPackRepositoryFileSource,
26    ContextPackSelectedFileSkipReason, NodeContextPack, CONTEXT_PACK_SELECTED_FILES,
27};
28use crate::harness_mcp_proxy::{
29    HarnessMcpProxyError, HarnessMcpProxyRegistry, PreparedHarnessMcpSpawn,
30    ReviewedHarnessMcpProgram,
31};
32use crate::session_environment::{
33    MaterializationId, MaterializationOwner, MaterializationOwnershipRecord,
34    MaterializationState, NodeSecretResolver, NodeSessionMaterializationProfile,
35    SessionEnvironmentMaterializeError, SessionEnvironmentMaterializer,
36};
37use crate::worktree_service::{
38    exact_created_worktree, exact_owned_worktree, ManagedWorktreeLeaseRecord,
39    ManagedWorktreeProfile, ManagedWorktreeRegistry, ManagedWorktreeSessionHolder,
40};
41use crate::session_registry::{
42    self, validate_display_name, LoadedNodeState, ManagedWorktreeSpawnReplayRecordV10,
43    ManagedWorktreeSpawnReplayStateV10, MAX_MANAGED_SESSION_RECORDS,
44    MAX_MANAGED_WORKTREE_SPAWN_REPLAYS,
45};
46#[cfg(unix)]
47use crate::workspace_file_unix::{
48    create_workspace_directory as create_workspace_directory_on_disk,
49    create_workspace_file as create_workspace_file_on_disk,
50    read_workspace_file as read_workspace_file_from_disk,
51    write_workspace_file as write_workspace_file_to_disk, WorkspaceFileBytes,
52    WorkspaceFileReadError, WorkspaceFileReadErrorKind,
53    WORKSPACE_ENTRY_CREATE_CANCELED, WORKSPACE_ENTRY_CREATE_COMMITTING,
54    WORKSPACE_ENTRY_CREATE_PENDING,
55};
56#[cfg(windows)]
57use crate::workspace_file_windows::{
58    create_workspace_directory as create_workspace_directory_on_disk,
59    create_workspace_file as create_workspace_file_on_disk,
60    read_workspace_file as read_workspace_file_from_disk,
61    write_workspace_file as write_workspace_file_to_disk, WorkspaceFileBytes,
62    WorkspaceFileReadError, WorkspaceFileReadErrorKind,
63    WORKSPACE_ENTRY_CREATE_CANCELED, WORKSPACE_ENTRY_CREATE_COMMITTING,
64    WORKSPACE_ENTRY_CREATE_PENDING,
65};
66use crate::platform;
67use crate::provider_runtime::{
68    require_policy, ProviderRuntimeAdmissionError, ProviderRuntimeMonitor,
69    ProviderRuntimeRequirement,
70};
71use crate::spawn_spec::SpawnProfileRegistry;
72use crate::standalone_workspace::{
73    prepare_standalone_workspace, StandaloneWorkspaceError, StandaloneWorkspaceErrorKind,
74};
75use crate::protocol::{
76    read_json_frame_limited_body_timeout, write_json_frame, write_json_frame_limited,
77    validate_node_negotiated_handshake_capacity, validate_provider_contract_manifest,
78    provider_id_is_legacy, AdapterContractRevision, AgentStreamChunkKindV1, AgentStreamChunkV1,
79    AgentStreamInteractionOptionV1, AgentStreamNamedIdV1,
80    CapabilityId, ClientFrame, ClientRole,
81    ControllerState, DeliveryBlobChunkHexV1, DeliveryBlobDigestV1,
82    DeliveryBundleManifestV2, DeliveryCommitReceiptV1, DeliveryStageId,
83    AgentProgressAttentionKindV1, AgentProgressAttentionV1, AgentProgressCurrentV1,
84    AgentProgressEventKindV1, AgentProgressUsageV1, AgentProgressV1, FrameError,
85    GitCommitDetails, GitDiff, GitDiffMode, GitDiffRequest, GitHistoryPage,
86    GitObjectId, GitCommitSummary, GitSignatureStatus, GitSnapshot, GitStatusEntry,
87    GitWorktreeSnapshot, HarnessMcpActivationDigest, HarnessMcpReservationId,
88    HostDirectoryListing,
89    NodeCompatibilitySupport, NodeEvent,
90    NodeEventEnvelope, NodeFailure, NodeFailureCode, NodeHello, NodeId, NodeIncarnationId,
91    NegotiatedNodeCompatibility, NodeRequest, NodeResponse, NodeSnapshot, OpaqueHostPath,
92    ProtocolRange,
93    ProviderAdapterContractSupport,
94    ProviderContractRevision, ProviderContractSupport, RepositoryPath, RequestEnvelope,
95    ProviderRuntimeStatuses, ResolvedBundleReceipt, ResolvedEnvironmentProfileReceipt,
96    ContextPackLineageReceipt, ContextPackBytesRead, ResolvedContextPackReceipt,
97    ResolvedHarnessMcpProxyReceiptV1, ResolvedSpawnReceipt,
98    ResolvedSpawnSpec, ResponseEnvelope,
99    ServerChallenge, ServerFrame, SessionAddress, SessionAgentProgress, SessionKey, SessionMode,
100    ManagedSessionRecord,
101    ManagedSessionState, ManagedWorktreeCleanupFailure, ManagedWorktreeLeaseId,
102    SessionRecordRetentionConfig,
103    ManagedWorktreeLeaseSnapshot, ManagedWorktreeLeaseState, ManagedWorktreeRetention,
104    ManagedWorktreeSpawnReceipt, ManagedWorktreeSpawnRequest,
105    ManagedWorktreeSpawnRequestV2, SessionRecordId,
106    NativeSessionCatalogRoute, NativeSessionSelection, SessionTaskBindingV1,
107    SessionTaskTargetV1, TaskId,
108    BlockAuthorityV1, HarnessMcpLaunchV1, SessionHistorySummaryV1, MAX_ACP_BLOCKED_HELP_BYTES, MAX_ACP_BLOCKED_REASON_BYTES,
109    StateSchemaSupport, WorkspaceEntry, WorkspaceEntryKind, WorktreeProfileId,
110    SpawnContextId, SpawnEnvironmentProfileId, SpawnIdempotencyKey,
111    SpawnProfileDefaults, SpawnRequiredCapabilities, SpawnSpec, SpawnSpecResolveError,
112    WorkspaceFileContent, WorkspaceFileRevision,
113    WorkspaceFileRead, WorkspaceId, WorkspaceInspection, WorkspaceInspectionTruncationV1,
114    WorkspaceSnapshot,
115    WorktreeServiceMode,
116    LaunchInventory, ManagedWorktreeProfileSummary, SpawnProfileSummary,
117    WorktreeProfileInventory, MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
118    DEFAULT_CONTROLLER_LEASE_MS,
119    MAX_CONTROLLER_LEASE_MS, MIN_CONTROLLER_LEASE_MS, NODE_COMPATIBILITY_METADATA_CAPABILITY,
120    CAPABILITY_HOST_DIRECTORY_BROWSE_V1,
121    NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY, NODE_REPOSITORY_PATH_CAPABILITY,
122    NODE_PROVIDER_ID_OPEN_CAPABILITY,
123    NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
124    NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY, NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
125    NODE_HISTORY_CONTEXT_PACK_CAPABILITY, NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
126    NODE_NATIVE_SESSION_CATALOG_CAPABILITY, NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
127    NODE_NATIVE_SESSION_INDEX_CAPABILITY, NODE_NATIVE_SESSION_PREVIEW_CAPABILITY,
128    NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
129    NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY,
130    NODE_HARNESS_MCP_READ_PROXY_CAPABILITY,
131    NODE_SESSION_TASK_CORRELATION_CAPABILITY,
132    NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY,
133    NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
134    NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
135    NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
136    NODE_SPAWN_PROFILE_REVISION_CAPABILITY, NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
137    NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
138    NODE_AGENT_STREAM_EVENTS_CAPABILITY, NODE_ACP_CONTROL_CAPABILITY,
139    NODE_GIT_READ_CAPABILITY, NODE_WORKSPACE_FILE_READ_CAPABILITY,
140    NODE_WORKSPACE_FILE_WRITE_CAPABILITY, NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY,
141    NODE_WORKTREE_SELECTION_CAPABILITY,
142    BUILD_STAMP, MAX_NODE_CLIENT_FRAME_BYTES, MAX_NODE_HELLO_FRAME_BYTES,
143    MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS, MAX_AGENT_PROGRESS_ENTRY_BYTES,
144    MAX_NODE_TERMINAL_BYTES, MAX_NODE_TEXT_BYTES,
145    MAX_REPOSITORY_PATH_BYTES,
146    MAX_GIT_DIFF_BYTES, MAX_GIT_HISTORY_COMMITS, MAX_WORKSPACE_FILE_BYTES,
147    MAX_HARNESS_MCP_PENDING_CALLS_PER_NODE,
148    MAX_WORKSPACE_ROOT_BYTES, NODE_STATE_SCHEMA_V1, NODE_STATE_SCHEMA_V10,
149    SPAWN_RUNTIME_PROVIDER_SESSION_IDENTITY, SPAWN_RUNTIME_RAW_PTY_LIFECYCLE,
150    SPAWN_RUNTIME_SEMANTIC_READINESS, SPAWN_RUNTIME_SEMANTIC_RESUME,
151    SPAWN_RUNTIME_STRUCTURED_PROMPT,
152};
153#[cfg(test)]
154use crate::protocol::WorktreeProfileRevision;
155use ring::digest::{digest, SHA256};
156use crate::protocol::correlation::{
157    interaction_correlation, sanitize_progress_tool_label, tool_class_label, tool_correlation,
158    truncate_text,
159};
160use gate4agent_catalog::{builtin_registry, AgentRegistry};
161use gate4agent_handle::{EventSubscription, Gate4AgentHandle, PortDispatchError};
162use gate4agent_runtime_native::{
163    NativeMcpServerLaunchOverlay, NativeInstanceLaunchOverlay,
164    NativeLaunchEnvironmentOverlay,
165    NativeHistoryConfig, NativeLaunchProfileControl, NativeRuntime, NativeRuntimeConfig,
166    NativeSessionCatalogAuthority, NativeSessionCatalogError, NativeSessionPreviewError,
167    ScopedNativeSessionCatalogEntry,
168};
169use gate4agent_runtime_native::shell_efficiency::ShellEfficiencyProfile;
170use std::sync::OnceLock;
171use gate4agent_runtime_native::tick_profile::TickProfileSnapshot;
172use drive_loop_profile::DriveLoopProfiler;
173#[cfg(test)]
174use gate4agent_runtime_native::{HistorySourceLayout, NativeHistoryRoot};
175use gate4agent_node_wire::{
176    auth_proof, negotiated_auth_proof, proofs_match, random_incarnation_id, random_nonce,
177    AuthDirection, OwnerOnlyLocalListener,
178};
179use gate4agent_types::{
180    AdapterBinding, AdapterFamily, AgentAdapterCapabilities, AgentCapabilities, AgentId,
181    AgentInstanceId, AgentReadinessSpec, AgentSpec, AgentTransportCapabilities, ApprovalLevel,
182    DetectionSpec, InitialPromptMode, LaunchSpec, ProcessMatcher, PromptSpec, SpecVerification,
183    CommandEnvelope, CommandId,
184    validate_candidate_id, ControlCommand, ControlEvent, ControlEventKind,
185    HistoryCandidateSummary, HistoryOperation, HistoryQuery, HistorySessionRecord,
186    HostDecisionAuthority as ProviderHostDecisionAuthority,
187    HostRequestDecision as ProviderHostRequestDecision,
188    InputAction,
189    PromptFraming, PromptPayload, ResumeLaunchRequest,
190    ProviderActivity, ProviderEvent, ProviderInteractionKind, ProviderInteractionStatus,
191    ProviderRuntimeCapability, ProviderRuntimePolicy, ProviderSessionIdentity, ProviderSessionKey,
192    ProviderSnapshot, ProviderStopReason,
193    ResumeTarget,
194    SessionGeneration, StartRequest,
195    TerminalControl, TerminalText,
196    SessionStatus, TerminalFrame, TransportKind, CONTROL_SESSIONS_MAX,
197    HISTORY_DISCOVERY_LIMIT_MAX, WORKING_DIRECTORY_MAX_BYTES,
198};
199use std::collections::{BTreeMap, VecDeque};
200use std::ffi::OsString;
201use std::io;
202use std::path::{Path, PathBuf};
203use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, AtomicUsize, Ordering};
204use std::sync::{Arc, Mutex, RwLock};
205use std::time::{Duration, Instant};
206use thiserror::Error;
207use tokio::sync::{broadcast, mpsc, Mutex as AsyncMutex, Notify, OwnedSemaphorePermit, Semaphore};
208use tokio::task::{AbortHandle, JoinSet};
209use tokio::time::{sleep, timeout};
210
211mod http_api;
212pub mod backend_paths;
213pub mod node_wake;
214
215/// Model of the spawn in progress (for the m4a auto-nick), if the request named one.
216pub fn spawn_model() -> Option<String> {
217    SPAWN_SESSION_OPTIONS.try_with(|o| o.as_ref().map(|o| o.model.clone())).ok().flatten().filter(|m| !m.is_empty())
218        .or_else(|| SPAWN_NICK_MODEL.try_with(|m| m.clone()).ok().flatten().filter(|m| !m.is_empty()))
219}
220
221tokio::task_local! {
222    /// Model named by the spawn request (any launch path, ACP included), for the m4a auto-nick
223    /// that is registered before the ACP options are applied.
224    static SPAWN_NICK_MODEL: Option<String>;
225    /// Launch options (model / effort) of a `SpawnWithOptions` request, scoped around that
226    /// spawn so the provider's start carries them (g4a maps them to the CLI's own flags).
227    static SPAWN_SESSION_OPTIONS: Option<gate4agent_types::SessionOptionSelection>;
228    /// Provider session id to resume (node-start restore of journalled sessions).
229    static RESUME_PROVIDER_SESSION: Option<String>;
230    /// Origin (`human`/`agent`) and optional parent signature for the node session registry.
231    static SPAWN_ORIGIN: Option<String>;
232    static SPAWN_PARENT: Option<String>;
233}
234
235/// `SpawnSessionOptions` -> g4a selection. Effort needs a model (g4a options hang off a model).
236fn spawn_selection(
237    options: &gate4agent_node_protocol::SpawnSessionOptions,
238) -> Result<Option<gate4agent_types::SessionOptionSelection>, NodeFailure> {
239    options.validate().map_err(|e| failure(NodeFailureCode::InvalidRequest, e))?;
240    match (&options.model, &options.effort) {
241        (None, None) => Ok(None),
242        (None, Some(_)) => Err(failure(NodeFailureCode::InvalidRequest, "spawn effort needs a model")),
243        (Some(model), effort) => {
244            let mut sel = gate4agent_types::SessionOptionSelection::new(model.clone());
245            if let Some(e) = effort {
246                sel = sel.with_value("effort", e.clone());
247            }
248            Ok(Some(sel))
249        }
250    }
251}
252
253mod browser_bridge;
254
255use gate4agent_node_core::drive_loop_profile;
256
257#[path = "bundle_delivery.rs"]
258mod bundle_delivery;
259
260#[path = "context_pack_store.rs"]
261mod context_pack_store;
262
263#[cfg(windows)]
264pub use crate::platform::DEFAULT_NODE_ENDPOINT;
265
266const NODE_EVENT_HISTORY_MAX: usize = 4_096;
267const NODE_BROADCAST_CAPACITY: usize = 1_024;
268const NODE_TERMINAL_BROADCAST_CAPACITY: usize = 1;
269/// Unlike `NODE_TERMINAL_BROADCAST_CAPACITY`'s single replaceable slot (a
270/// terminal frame is a full-screen snapshot; only the latest ever matters),
271/// an `AgentStreamChunkV1` is one delta in an ordered sequence -- collapsing
272/// two pending sends into one would silently drop content a subscriber can
273/// never recover. This needs headroom for a burst instead.
274const NODE_AGENT_STREAM_BROADCAST_CAPACITY: usize = 256;
275/// The node never has more than `MAX_HARNESS_MCP_PENDING_CALLS_PER_NODE`
276/// `NodeEvent::HarnessMcpReadCall`s in flight at once (`begin_call`'s own
277/// admission check), and `publish_transient` sends each exactly once as a
278/// single-envelope batch -- so this many slots covers the entire pending
279/// set without ever lagging the one subscriber that matters, the harness's
280/// own connection, the way sharing the durable `event_tx`'s bus could.
281const NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY: usize = MAX_HARNESS_MCP_PENDING_CALLS_PER_NODE;
282const NODE_CONNECTION_EVENT_BURST_MAX: usize = 16;
283const CONTROL_EVENT_SUBSCRIPTION_CAPACITY: usize = 1_024;
284const MAX_PREAUTH_CONNECTIONS: usize = 32;
285const MAX_AUTHENTICATED_CONNECTIONS: usize = 16;
286const AUTH_FRAME_TIMEOUT_MS: u64 = 5_000;
287const FRAME_BODY_TIMEOUT_MS: u64 = 5_000;
288const CONNECTION_SHUTDOWN_GRACE_MS: u64 = 250;
289const SPAWN_DISPATCH_TIMEOUT_MS: u64 = 2_000;
290/// Fresh Spawn stays provisional until HQ successfully attaches (first Resize /
291/// TerminalBytes). This is an *attach-never-arrived* timeout from spawn time —
292/// not the disconnect reconnect grace (HQ owns that, starting only on a real
293/// client WS close). A provider conversation id is a session; a PTY with no
294/// attached HQ view is still a draft and is reaped here if attach never lands.
295const PROVISIONAL_SPAWN_GRACE_MS: u64 = 90_000;
296/// How often `drive_runtime_until_shutdown`'s loop re-evaluates the session
297/// record retention policy (`NodeServerConfig::session_record_retention`).
298/// A sweep cadence, not a policy value -- the policy itself lives entirely
299/// in the operator-configured age/keep-N flags; this just bounds how stale
300/// the in-memory view of dead `Unavailable` records can get between sweeps
301/// on an otherwise-idle node, mirroring the once-per-startup sweep that
302/// also runs alongside `reconcile_managed_worktrees`.
303const SESSION_RECORD_RETENTION_SWEEP_PERIOD: Duration = Duration::from_secs(5 * 60);
304/// TTL for `NodeShared::rejected_command_reasons` entries -- see that
305/// field's own doc for why a spawn waiter needs this at all. Must outlive
306/// the longest a waiter can still be polling for this exact command's
307/// outcome: `gate4agent_node_protocol::MAX_SPAWN_DEADLINE_MS` (120s) is the
308/// largest `deadline_ms` any caller may request, and the margin on top
309/// absorbs the waiter's own poll cadence plus clock skew between the drive
310/// loop (writer) and the waiter (reader).
311const REJECTED_COMMAND_REASON_TTL_MS: u64 = 150_000;
312/// Bounds `NodeShared::rejected_command_reasons` against an unbounded
313/// backlog of asynchronously-rejected commands nobody ever collects (a
314/// waiter that stops polling, or a burst of always-rejected commands from a
315/// misbehaving caller): once at capacity, the oldest entry is evicted to
316/// make room for the newest, mirroring the eviction rule already used by
317/// `spawn_idempotency`.
318const REJECTED_COMMAND_REASON_MAX_ENTRIES: usize = 256;
319/// How long `wait_until_removed` waits between two `Remove` re-dispatches
320/// for the same session. The presence poll runs far faster (2ms); this
321/// only paces the sending, so an accepted `Remove` still in flight is not
322/// piled on by a dozen more that land on an already-dropped instance.
323const REMOVE_REDISPATCH_INTERVAL_MS: u64 = 100;
324const SPAWN_IDEMPOTENCY_MAX_ENTRIES: usize = 256;
325const SPAWN_IDEMPOTENCY_TTL_MS: u64 = 15 * 60 * 1_000;
326const PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS: u64 = 2_500;
327const MUTATION_SETTLE_TIMEOUT_MS: u64 = 5_000;
328const READINESS_SETTLE_HEADROOM_MS: u64 = 2_000;
329const MANAGED_RESUME_SETTLE_TIMEOUT_MS: u64 = 30_000;
330const WORKSPACE_INSPECTION_MAX_CONCURRENCY: usize = 4;
331const WORKSPACE_FILE_READ_TIMEOUT_MS: u64 = 2_000;
332const HOST_DIRECTORY_BROWSE_TIMEOUT_MS: u64 = 2_000;
333const NATIVE_SESSION_CATALOG_TIMEOUT_MS: u64 = 30_000;
334const WORKSPACE_TREE_MAX_DEPTH: usize = 6;
335const WORKSPACE_TREE_MAX_ENTRIES: usize = 512;
336/// Default total wall-clock budget for `inspect_workspace`'s walk+git
337/// chain, overridable via `GATE4AGENT_NODE_WORKSPACE_INSPECTION_BUDGET_MS`.
338const WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT: u64 = 8_000;
339/// The harness relay enforces its own flat response deadline
340/// (`HOST_RUN_READ_RESPONSE_DEADLINE`, `hatchery-harness-service::runtime`,
341/// 12s) above this node-side budget. The two constants live in different
342/// crates with no shared definition, so clamp any override here comfortably
343/// below that external ceiling — a misconfigured value above it would
344/// reopen the exact deadline race this budget exists to close.
345const WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX: u64 = 11_000;
346/// Default cap on directory entries visited (not just returned) by one
347/// walk, overridable via `GATE4AGENT_NODE_WORKSPACE_INSPECTION_ENTRY_CAP`.
348const WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT: usize = 50_000;
349const WORKSPACE_INSPECTION_ENTRY_CAP_MAX: usize = 1_000_000;
350const GIT_STATUS_MAX_ENTRIES: usize = 128;
351const GIT_COMMIT_MAX_ENTRIES: usize = 12;
352const GIT_OUTPUT_MAX_BYTES: usize = 64 * 1_024;
353const GIT_DIAGNOSTIC_MAX_BYTES: usize = 1_024;
354const WORKSPACE_FILE_WRITE_TIMEOUT_MS: u64 = 5_000;
355const WORKSPACE_ENTRY_CREATE_TIMEOUT_MS: u64 = 5_000;
356const GIT_COMMAND_TIMEOUT_MS: u64 = 1_500;
357const WORKSPACE_UNAVAILABLE_ERROR: &str = "workspace-unavailable";
358const PROVIDER_SESSION_SCOPE_CONFLICT_ERROR: &str = "provider-session-scope-conflict";
359const PROVIDER_SESSION_LIVE_CONFLICT_ERROR: &str = "provider-session-live-conflict";
360const MANAGED_SESSION_CAPACITY_ERROR: &str = "managed-session-capacity";
361const PROVIDER_IDENTITY_ALLOCATION_ERROR: &str =
362    "provider-session-identity-allocation-failed";
363const PROVIDER_RESUME_REJECTED_ERROR: &str = "provider-resume-rejected";
364const DURABLE_STATE_COMMIT_FAILED_ERROR: &str = "durable-state-commit-failed";
365const DURABLE_STATE_LOCK_FAILED_ERROR: &str = "durable-state-lock-failed";
366const DURABLE_STATE_LOAD_FAILED_ERROR: &str = "durable-state-load-failed";
367const DURABLE_STATE_CONFLICT_ERROR: &str = "durable-state-conflict";
368const DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR: &str = "durable-state-schema-unsupported";
369const DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR: &str =
370    "durable-state-path-semantics-unsupported";
371
372fn opaque_windows_path(value: String) -> OpaqueHostPath {
373    OpaqueHostPath::utf8(value).expect("validated Windows node path must remain wire-safe")
374}
375
376fn agent_progress_from_provider_snapshot(
377    address: SessionAddress,
378    provider: &ProviderSnapshot,
379) -> Option<SessionAgentProgress> {
380    let mut truncated = false;
381    let active_tool_count = bounded_progress_count(provider.active_tools.len(), &mut truncated);
382    let mut active_tool_labels = Vec::with_capacity(
383        provider
384            .active_tools
385            .len()
386            .min(MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS),
387    );
388    for tool in &provider.active_tools {
389        if active_tool_labels.len() == MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS {
390            truncated = true;
391            break;
392        }
393        if let Some(label) = sanitize_progress_tool_label(&tool.name, &mut truncated) {
394            active_tool_labels.push(label);
395        }
396    }
397    let attention = provider
398        .interactions
399        .iter()
400        .find(|interaction| {
401            matches!(
402                interaction.status,
403                ProviderInteractionStatus::Pending | ProviderInteractionStatus::Resolving { .. }
404            )
405        })
406        .map(|interaction| AgentProgressAttentionV1 {
407            kind: match interaction.interaction_kind {
408                ProviderInteractionKind::Approval => AgentProgressAttentionKindV1::Approval,
409                ProviderInteractionKind::Question => AgentProgressAttentionKindV1::Question,
410            },
411            tool_label: sanitize_progress_tool_label(&interaction.tool_name, &mut truncated),
412        });
413    let subagent_count = bounded_progress_count(provider.subagents.len(), &mut truncated);
414    let usage = (provider.completed_turns > 0
415        || provider.usage.input_tokens > 0
416        || provider.usage.output_tokens > 0
417        || provider.usage.cache_read_tokens > 0
418        || provider.usage.cache_write_tokens > 0
419        || provider.usage.reasoning_tokens > 0)
420        .then_some(AgentProgressUsageV1 {
421            input_tokens: provider.usage.input_tokens,
422            output_tokens: provider.usage.output_tokens,
423            cache_read_tokens: provider.usage.cache_read_tokens,
424            cache_write_tokens: provider.usage.cache_write_tokens,
425            reasoning_tokens: provider.usage.reasoning_tokens,
426        });
427    let progress = AgentProgressV1 {
428        provider_sequence: provider.sequence,
429        activity: provider.activity,
430        completed_turns: provider.completed_turns,
431        usage,
432        current: AgentProgressCurrentV1::from(provider.activity),
433        active_tool_labels,
434        active_tool_count,
435        attention,
436        subagent_count,
437        last_event_kind: provider.last_event.as_ref().and_then(agent_progress_event_kind),
438        gap_count: provider.gap_count,
439        stale: provider.stale,
440        truncated,
441    };
442    let entry = SessionAgentProgress { address, progress };
443    serde_json::to_vec(&entry)
444        .ok()
445        .filter(|encoded| encoded.len() <= MAX_AGENT_PROGRESS_ENTRY_BYTES)
446        .map(|_| entry)
447}
448
449fn bounded_progress_count(count: usize, truncated: &mut bool) -> u32 {
450    match u32::try_from(count) {
451        Ok(count) => count,
452        Err(_) => {
453            *truncated = true;
454            u32::MAX
455        }
456    }
457}
458
459fn agent_progress_event_kind(event: &ProviderEvent) -> Option<AgentProgressEventKindV1> {
460    if matches!(event, ProviderEvent::ContextWindowUsage { .. }) {
461        return None;
462    }
463    Some(match event {
464        ProviderEvent::SessionStarted { .. } => AgentProgressEventKindV1::SessionStarted,
465        ProviderEvent::SessionIdentityObserved { .. } => {
466            AgentProgressEventKindV1::SessionIdentityObserved
467        }
468        ProviderEvent::TurnStarted { .. } => AgentProgressEventKindV1::TurnStarted,
469        ProviderEvent::WorkingObserved => AgentProgressEventKindV1::WorkingObserved,
470        ProviderEvent::Text { .. } => AgentProgressEventKindV1::Text,
471        ProviderEvent::Thinking { .. } => AgentProgressEventKindV1::Thinking,
472        ProviderEvent::ToolStarted { .. } => AgentProgressEventKindV1::ToolStarted,
473        ProviderEvent::ToolCompleted { .. } => AgentProgressEventKindV1::ToolCompleted,
474        ProviderEvent::TurnCompleted { .. } => AgentProgressEventKindV1::TurnCompleted,
475        ProviderEvent::TurnInterrupted => AgentProgressEventKindV1::TurnInterrupted,
476        ProviderEvent::SessionEnded { .. } => AgentProgressEventKindV1::SessionEnded,
477        ProviderEvent::Error { .. } => AgentProgressEventKindV1::Error,
478        ProviderEvent::Ready => AgentProgressEventKindV1::Ready,
479        ProviderEvent::InteractionRequested { .. } => {
480            AgentProgressEventKindV1::InteractionRequested
481        }
482        ProviderEvent::InteractionResolved { .. } => AgentProgressEventKindV1::InteractionResolved,
483        ProviderEvent::SubagentStarted { .. } => AgentProgressEventKindV1::SubagentStarted,
484        ProviderEvent::SubagentStopped { .. } => AgentProgressEventKindV1::SubagentStopped,
485        ProviderEvent::RateLimited { .. } => AgentProgressEventKindV1::RateLimited,
486        ProviderEvent::HostRequestObserved { .. } => AgentProgressEventKindV1::HostRequestObserved,
487        ProviderEvent::UnrecognizedNotification { .. } => {
488            AgentProgressEventKindV1::UnrecognizedNotification
489        }
490        ProviderEvent::ContextWindowUsage { .. } => unreachable!("handled above"),
491        // ACP session/update coverage beyond text/tool/turn streaming
492        // (`plan`, `available_commands_update`, `current_mode_update`,
493        // `session_info_update`, `usage_update`, `config_option_update`,
494        // `user_message_chunk`). `AgentProgressEventKindV1` is a versioned
495        // wire enum without a `#[serde(other)]` fallback; minting new
496        // variants for it is a deliberate wire-contract decision for
497        // whoever owns this protocol, not a side effect of parsing more of
498        // ACP's own wire -- until that decision is made, these fold into
499        // the same "we saw something, no dedicated kind for it yet" bucket
500        // as `UnrecognizedNotification`.
501        ProviderEvent::UserMessage { .. }
502        | ProviderEvent::Plan { .. }
503        | ProviderEvent::AvailableCommandsUpdated { .. }
504        | ProviderEvent::ModeChanged { .. }
505        | ProviderEvent::SessionInfoUpdated { .. }
506        | ProviderEvent::UsageUpdated { .. }
507        | ProviderEvent::ConfigOptionsUpdated { .. } => {
508            AgentProgressEventKindV1::UnrecognizedNotification
509        }
510    })
511}
512
513/// Maps this harness's own `HostDecisionAuthority` onto the wider
514/// Maps a host request decider onto the `BlockAuthorityV1` a `Blocked` stream
515/// chunk carries -- the four authorities a `Denied` decision is ever made
516/// under, one-for-one. The remaining `BlockAuthorityV1` variants
517/// (`ProviderClassifier`, `ProviderPermissionRule`, `ProviderSandbox`,
518/// `ProviderRefusal`, `UserRejected`, `ProviderQuota`, `Unknown`) describe a
519/// block the PROVIDER side of the wire reported -- this function never
520/// produces one, because `ProviderHostDecisionAuthority` has no vocabulary for
521/// them; the provider-side blocks below mint those directly from typed fields.
522/// Whether a provider source is a live session transport -- the only kind
523/// whose events reach the agent stream. History, resume, session-option and
524/// capability-probe sources are not sessions and stream nothing.
525fn provider_family_is_live(family: AdapterFamily) -> bool {
526    match family {
527        AdapterFamily::PtySemantic
528        | AdapterFamily::Hook
529        | AdapterFamily::ManagedHook
530        | AdapterFamily::Pipe
531        | AdapterFamily::OneShot
532        | AdapterFamily::Acp => true,
533        AdapterFamily::History
534        | AdapterFamily::Resume
535        | AdapterFamily::SessionOptions
536        | AdapterFamily::CapabilityProbe => false,
537    }
538}
539
540fn block_authority(by: ProviderHostDecisionAuthority) -> BlockAuthorityV1 {
541    match by {
542        ProviderHostDecisionAuthority::Gate => BlockAuthorityV1::HostGate,
543        ProviderHostDecisionAuthority::Policy => BlockAuthorityV1::HostPolicy,
544        ProviderHostDecisionAuthority::Operator => BlockAuthorityV1::Operator,
545        ProviderHostDecisionAuthority::DeadlinePolicy => BlockAuthorityV1::HostDeadline,
546    }
547}
548
549/// The `Blocked::reason` for a `Denied` decision this build's own
550/// `AgentEvent::RpcIncomingRequest::reason` did not attach text to --
551/// reachable today for `Operator`/`HostDeadline`: a deferred `session/
552/// request_permission` request resolved by `AcpSession::
553/// resolve_pending_request`/`expire_deadlines` (`src/acp/session.rs`),
554/// neither of which computes free text (an operator answers approve/reject,
555/// never a comment; an unattended deadline asks nobody anything). `reason`
556/// is a REQUIRED field on the wire (see `AgentStreamChunkKindV1::Blocked`)
557/// -- a categorical fallback naming the authority is the
558/// honest minimum, never a placeholder standing in for detail nobody
559/// reported.
560fn action_blocked_fallback_reason(authority: BlockAuthorityV1) -> &'static str {
561    match authority {
562        BlockAuthorityV1::HostGate => "denied by the dangerous-command gate",
563        BlockAuthorityV1::HostPolicy => "denied by host policy",
564        BlockAuthorityV1::HostDeadline => {
565            "denied by host policy after the operator deadline expired"
566        }
567        BlockAuthorityV1::Operator => "declined by the operator",
568        BlockAuthorityV1::ProviderClassifier => "denied by the provider's classifier",
569        BlockAuthorityV1::ProviderPermissionRule => "denied by a provider permission rule",
570        BlockAuthorityV1::ProviderSandbox => "denied by the provider's sandbox",
571        BlockAuthorityV1::ProviderRefusal => "refused by the provider",
572        BlockAuthorityV1::UserRejected => "rejected by the user",
573        BlockAuthorityV1::ProviderQuota => "the provider's quota or rate limit was exhausted",
574        BlockAuthorityV1::Unknown => "blocked; no reason was reported",
575    }
576}
577
578/// Computes `AgentStreamChunkKindV1::Blocked`'s `tool_class`/`authority`/`reason`
579/// for a `HostRequestObserved` `Denied` decision.
580fn host_request_denied_block(
581    method: &str,
582    by: ProviderHostDecisionAuthority,
583    reason: Option<&str>,
584) -> (String, BlockAuthorityV1, String) {
585    let authority = block_authority(by);
586    let reason_source = reason.unwrap_or_else(|| action_blocked_fallback_reason(authority));
587    // `AgentStreamChunkKindV1::validate` REJECTS an over-long reason outright
588    // rather than truncating -- this cut happens here, at the producer, before
589    // the validator ever sees the text.
590    let (reason_text, _) =
591        truncate_text(reason_source, MAX_ACP_BLOCKED_REASON_BYTES);
592    (tool_class_label(method), authority, reason_text)
593}
594
595// ---------------------------------------------------------------------------
596// K2 -- provider-side blocks read off ACP `tool_call_update._meta.
597// nonExecutionKind` (`ProviderEvent::ToolCompleted::non_execution_kind`) and
598// `SessionEnded::stop_reason` (`stopReason: "refusal"`, or a `session/
599// prompt` RPC error whose vendor code names a quota/limit/rate
600// exhaustion). See
601// `docs/gate4agent/plans/gate4agent-blocked-action-event-2026-09-02.md`
602// §3 slice K2.
603// ---------------------------------------------------------------------------
604
605/// Classifies ACP's `nonExecutionKind` into the typed authority a block
606/// this build mints under, or `None` when the tool truly did not run for a
607/// reason that is NOT a block -- `"interrupted"`/`"cancelled"` mean the
608/// TURN stopped (session closed, the operator cancelled), not that a
609/// policy/classifier/rule refused the call, so those (and any unrecognized
610/// future value) stay a plain `ToolCompleted { success: false }` only, per
611/// this plan's own rule that a tool failure is never inflated into a block
612/// this build cannot actually attribute.
613fn provider_tool_block_authority(kind: Option<&str>) -> Option<(BlockAuthorityV1, &'static str)> {
614    match kind? {
615        "user-rejected" => Some((BlockAuthorityV1::UserRejected, "user-rejected")),
616        "permission-rule" => Some((BlockAuthorityV1::ProviderPermissionRule, "permission-rule")),
617        _ => None,
618    }
619}
620
621/// Splits a provider's own denial sentence into `reason`/`help`, when it
622/// matches the fixed shape Claude Code's classifier/permission-rule denial
623/// uses -- verified live, the owner's sample fixture
624/// (`docs/gate4agent/research/gate4agent-blocked-action-signals-2026-09-02.md`
625/// appendix): "...denied by <authority>. Reason: <reason>. <help...>".
626/// `authority` is NEVER derived from this text (see this module's own rule
627/// that authority comes only from a typed field) -- only `reason`/`help`
628/// are. Falls back to the full text as `reason` with no `help` when the
629/// shape does not match (a provider whose text does not carry this exact
630/// wording) -- the honest minimum: the tool truly did not run, so SOME
631/// reason is always reported, never fabricated detail beyond what arrived.
632fn split_provider_denial_text(text: &str) -> (String, Option<String>) {
633    let trimmed = text.trim();
634    let Some((_, after_reason)) = trimmed.split_once("Reason:") else {
635        return (trimmed.to_owned(), None);
636    };
637    let tail = after_reason.trim_start();
638    let Some((reason, help)) = tail.split_once(". ") else {
639        // "Reason:" present but no ". " to split help off -- the whole
640        // tail is the reason, e.g. "Reason: Blocked by classifier." with
641        // no help text following on some providers/versions.
642        let reason = tail.strip_suffix('.').unwrap_or(tail).trim();
643        return (reason.to_owned(), None);
644    };
645    let help = help.trim();
646    if help.is_empty() {
647        (reason.trim().to_owned(), None)
648    } else {
649        (reason.trim().to_owned(), Some(help.to_owned()))
650    }
651}
652
653/// `tool_class` for a provider-side `ActionBlocked` this build has no typed
654/// tool name for -- `ProviderEvent::ToolCompleted` carries only `id`, never
655/// `name` (see `ProviderEvent::ToolStarted`, which does, but a tool's own
656/// name is never threaded through to its completion). Reads the class off
657/// the guidance tail's own "add a <Class> permission rule" sentence when
658/// present (the shape the owner's sample fixture uses) -- text matching
659/// fills `tool_class` here, same as `reason`/`help`, NEVER `authority` (see
660/// this module's own rule). Falls back to the same generic `"Tool"` class
661/// `ToolCompleted`'s own observation already uses when nothing names one.
662fn tool_class_from_denial_help(help: Option<&str>) -> String {
663    help.and_then(|text| text.split("add a ").nth(1))
664        .and_then(|tail| tail.split(' ').next())
665        .filter(|word| !word.is_empty())
666        .map(str::to_owned)
667        .unwrap_or_else(|| "Tool".to_owned())
668}
669
670/// Whether a `session/prompt` RPC error's vendor code (or, absent one, its
671/// message) names a quota/rate-limit exhaustion -- Codex's
672/// `data.codexErrorInfo: "usageLimitExceeded"` (measured live 2026-09-05)
673/// contains "limit", the word this build actually keys on; "quota"/"rate"
674/// are kept for whichever OTHER vendor's own code names one of those
675/// instead, never confirmed live, but the honest generalization of the
676/// one confirmed sample.
677fn provider_error_is_quota(vendor_code: Option<&str>, message: &str) -> bool {
678    fn names_quota(s: &str) -> bool {
679        let lower = s.to_ascii_lowercase();
680        lower.contains("limit") || lower.contains("quota") || lower.contains("rate")
681    }
682    vendor_code.is_some_and(names_quota) || names_quota(message)
683}
684
685/// The `Blocked` chunk this build mints when a turn stopped via a
686/// typed provider fact rather than a tool call -- `stopReason: "refusal"`
687/// (`authority: ProviderRefusal`) or a `session/prompt` RPC error whose
688/// vendor code (or message) names a quota/limit/rate exhaustion
689/// (`authority: ProviderQuota`, `reason` = the error message verbatim,
690/// `reason_kind` = the vendor code). `None` for every other stop --
691/// `end_turn`, `max_tokens`, `max_turn_requests`, `cancelled`, an untagged
692/// `Other`, or an RPC error whose vendor code/message names none of the
693/// three words: `SessionEnded` stays a plain `Exited`/turn-end fact for
694/// those, never inflated into a block this build cannot actually
695/// attribute. `ProviderRefusal`'s `reason` has no source text to read on
696/// `SessionEnded` itself (ACP's `refusal` stopReason carries none) -- the
697/// honest minimum names the fact that happened, never invented detail; a
698/// later slice that threads the turn's last agent text chunk through can
699/// replace it without changing this function's contract.
700fn provider_session_end_block(
701    stop_reason: &ProviderStopReason,
702) -> Option<(BlockAuthorityV1, String, String)> {
703    match stop_reason {
704        ProviderStopReason::Refusal => Some((
705            BlockAuthorityV1::ProviderRefusal,
706            "refusal".to_owned(),
707            "the provider ended the turn with stopReason: refusal".to_owned(),
708        )),
709        ProviderStopReason::ProviderError { message, vendor_code, .. }
710            if provider_error_is_quota(vendor_code.as_deref(), message) =>
711        {
712            let reason_kind = vendor_code.clone().unwrap_or_else(|| "provider-error".to_owned());
713            Some((BlockAuthorityV1::ProviderQuota, reason_kind, message.clone()))
714        }
715        _ => None,
716    }
717}
718
719/// Builds the `AgentStreamChunkV1` `event`'s `ProviderEvent` carries onto the
720/// `agent-stream-events-v1` channel -- see
721/// `docs/gate4agent/plans/gate4agent-acp-control-plane-on-the-wire-2026-09-02.md`
722/// §5. Two invariants keep the chunk correlatable with the `NodeEvent::Control`
723/// event it derives from, so a client joining the two sees one id and one
724/// ordering:
725///
726/// - `source_sequence` on the chunk is the `source_sequence` the provider
727///   event itself carries.
728/// - `InteractionPrompt::correlation_id` is minted by
729///   `correlation::interaction_correlation` with the
730///   `(instance_id, generation, provider_sequence)` triple -- never the
731///   provider's own `request_id`, whose opacity on the wire is pinned by a
732///   dedicated test.
733///
734/// Returns `None` for every `ControlEventKind` other than `ProviderEvent`
735/// (nothing else carries stream content), for a source that is not a live
736/// session transport, and for the `ProviderEvent` variants below with no
737/// declared chunk kind to carry them, or whose declared chunk kind this build
738/// has no source for yet:
739///
740/// - `ModelCatalog { current, available }` has no `ProviderEvent` source at
741///   all -- grok's vendor model switch is not bridged into `ProviderEvent`
742///   yet, the same gap the plan names explicitly.
743/// - `UserMessage { text, is_delta }` is an echo of the OPERATOR's own
744///   prompt, replayed on session resume -- `Text`/`Thinking` are documented
745///   as "what the agent is saying", so mapping the operator's words onto
746///   `Text` would misrepresent whose content it is. No dedicated
747///   user-message kind is declared, so this stays unproduced rather than
748///   mislabeled.
749///
750/// `HostRequestObserved { .. }` is its own arm below: a `Granted`/`Deferred`
751/// decision returns `None` (nothing for the operator to react to), a `Denied`
752/// decision returns `Some(Blocked { .. })`.
753///
754/// `ToolCompleted { .. }`/`SessionEnded { .. }` are likewise their own arms
755/// below (K2): each returns `None` unless
756/// `provider_tool_block_authority`/`provider_session_end_block` classifies the
757/// event as an actual block (ACP's `_meta.nonExecutionKind`, or `stopReason`/an
758/// RPC error naming a quota).
759fn agent_stream_chunk(event: &ControlEvent) -> Option<AgentStreamChunkV1> {
760    let ControlEventKind::ProviderEvent {
761        sequence: provider_sequence,
762        source,
763        source_sequence,
764        event: provider_event,
765    } = &event.event
766    else {
767        return None;
768    };
769    if !provider_family_is_live(source.family) {
770        return None;
771    }
772    let kind = match provider_event {
773        ProviderEvent::Text { text, is_delta } => AgentStreamChunkKindV1::Text {
774            text: text.clone(),
775            is_delta: *is_delta,
776        },
777        ProviderEvent::Thinking { text } => {
778            AgentStreamChunkKindV1::Thinking { text: text.clone() }
779        }
780        ProviderEvent::InteractionRequested {
781            interaction_kind,
782            tool_name,
783            title,
784            prompt,
785            options,
786            ..
787        } => AgentStreamChunkKindV1::InteractionPrompt {
788            correlation_id: interaction_correlation(
789                event.instance_id,
790                event.generation,
791                *provider_sequence,
792            ),
793            interaction_kind: *interaction_kind,
794            tool_name: tool_name.clone(),
795            // `title`/`options` are carried straight through from
796            // `ProviderEvent::InteractionRequested`, as the agent itself
797            // gave them on `session/request_permission`'s `PermissionTool
798            // Call.title`/`options` (see `gate4agent-shell-native`'s
799            // `provider_event`, the only ACP-transport producer). A PTY- or
800            // hook-sourced interaction carries `title: None` and
801            // `options: []` from its own producer because that source has
802            // none to offer, never because this mapping dropped one.
803            title: title.clone(),
804            prompt: prompt.clone(),
805            options: options
806                .iter()
807                .map(|option| AgentStreamInteractionOptionV1 {
808                    option_id: option.option_id.clone(),
809                    name: option.name.clone(),
810                    kind: option.kind.clone(),
811                })
812                .collect(),
813        },
814        ProviderEvent::ConfigOptionsUpdated { options } => {
815            AgentStreamChunkKindV1::ConfigOptions { options: options.clone() }
816        }
817        // `available` is the mode catalogue `AcpSession::available_modes()`
818        // read back at `session/new` and threaded through by
819        // `gate4agent-shell-native`'s `provider_event` (see that crate's
820        // `ProviderEvent::ModeChanged` arm) -- an empty list here is the
821        // agent's own honest "no modes announced", never "not read": ACP
822        // orders `session/new` before any `session/update`, and the
823        // catalogue is fixed at handshake, so a `ModeChanged` event cannot
824        // exist before the same session object's catalogue is already
825        // populated (see `gate4agent_types::ProviderEvent::ModeChanged`'s
826        // own doc comment for the ordering argument in full).
827        ProviderEvent::ModeChanged { mode_id, available } => AgentStreamChunkKindV1::ModeCatalog {
828            current: Some(mode_id.clone()),
829            available: available
830                .iter()
831                .map(|mode| AgentStreamNamedIdV1 {
832                    id: mode.id.clone(),
833                    name: mode.name.clone(),
834                    description: mode.description.clone(),
835                })
836                .collect(),
837        },
838        // A denied host request is a block -- see this function's own doc
839        // comment for why `HostRequestObserved` is not in the exclusion list
840        // below.
841        ProviderEvent::HostRequestObserved { method, decision, reason, .. } => {
842            let ProviderHostRequestDecision::Denied { by } = decision else {
843                return None;
844            };
845            let (tool_class, authority, reason_text) =
846                host_request_denied_block(method, *by, reason.as_deref());
847            AgentStreamChunkKindV1::Blocked {
848                correlation_id: None,
849                tool_class,
850                authority,
851                reason_kind: None,
852                reason: reason_text,
853                help: None,
854            }
855        }
856        // Same relationship as `HostRequestObserved` above, driven by ACP's
857        // own `_meta.nonExecutionKind` instead of the host's
858        // `HostRequestDecision`.
859        ProviderEvent::ToolCompleted { id, output, non_execution_kind, .. } => {
860            let (authority, reason_kind) =
861                provider_tool_block_authority(non_execution_kind.as_deref())?;
862            let (reason, help) = split_provider_denial_text(output);
863            let (reason, _) =
864                truncate_text(&reason, MAX_ACP_BLOCKED_REASON_BYTES);
865            let help = help.map(|help| {
866                truncate_text(&help, MAX_ACP_BLOCKED_HELP_BYTES).0
867            });
868            AgentStreamChunkKindV1::Blocked {
869                correlation_id: Some(tool_correlation(event, source, id)),
870                tool_class: tool_class_from_denial_help(help.as_deref()),
871                authority,
872                reason_kind: Some(reason_kind.to_owned()),
873                reason,
874                help,
875            }
876        }
877        // Same relationship, driven by `stopReason: "refusal"` or a
878        // `session/prompt` RPC error naming a quota/limit/rate exhaustion
879        // instead of a tool-level fact.
880        ProviderEvent::SessionEnded { stop_reason, .. } => {
881            let (authority, reason_kind, reason) =
882                stop_reason.as_ref().and_then(provider_session_end_block)?;
883            let (reason, _) =
884                truncate_text(&reason, MAX_ACP_BLOCKED_REASON_BYTES);
885            AgentStreamChunkKindV1::Blocked {
886                correlation_id: None,
887                tool_class: "Session".to_owned(),
888                authority,
889                reason_kind: Some(reason_kind),
890                reason,
891                help: None,
892            }
893        }
894        ProviderEvent::SessionStarted { .. }
895        | ProviderEvent::SessionIdentityObserved { .. }
896        | ProviderEvent::TurnStarted { .. }
897        | ProviderEvent::WorkingObserved
898        | ProviderEvent::ToolStarted { .. }
899        | ProviderEvent::TurnCompleted { .. }
900        | ProviderEvent::ContextWindowUsage { .. }
901        | ProviderEvent::TurnInterrupted
902        | ProviderEvent::Error { .. }
903        | ProviderEvent::Ready
904        | ProviderEvent::InteractionResolved { .. }
905        | ProviderEvent::SubagentStarted { .. }
906        | ProviderEvent::SubagentStopped { .. }
907        | ProviderEvent::RateLimited { .. }
908        | ProviderEvent::UnrecognizedNotification { .. }
909        | ProviderEvent::UserMessage { .. }
910        | ProviderEvent::Plan { .. }
911        | ProviderEvent::AvailableCommandsUpdated { .. }
912        | ProviderEvent::SessionInfoUpdated { .. }
913        | ProviderEvent::UsageUpdated { .. } => return None,
914    };
915    Some(AgentStreamChunkV1 { source_sequence: *source_sequence, kind })
916}
917
918async fn run_native_session_catalog_operation<T, E, F>(
919    catalog: Arc<Mutex<NativeSessionCatalogAuthority>>,
920    operation: &'static str,
921    execute: F,
922) -> Result<T, NodeFailure>
923where
924    T: Send + 'static,
925    E: std::fmt::Display + Send + 'static,
926    F: FnOnce(&mut NativeSessionCatalogAuthority) -> Result<T, E> + Send + 'static,
927{
928    let task = tokio::task::spawn_blocking(move || {
929        let mut catalog = match catalog.try_lock() {
930            Ok(catalog) => catalog,
931            Err(std::sync::TryLockError::Poisoned(poisoned)) => poisoned.into_inner(),
932            Err(std::sync::TryLockError::WouldBlock) => {
933                return Err(failure(
934                    NodeFailureCode::BackendBusy,
935                    "native session catalog operation is already running",
936                ));
937            }
938        };
939        execute(&mut catalog).map_err(|error| {
940            failure(
941                NodeFailureCode::BackendOperationFailed,
942                &format!("{operation} failed: {error}"),
943            )
944        })
945    });
946    timeout(Duration::from_millis(NATIVE_SESSION_CATALOG_TIMEOUT_MS), task)
947        .await
948        .map_err(|_| {
949            failure(
950                NodeFailureCode::BackendBusy,
951                &format!("{operation} exceeded its bounded deadline"),
952            )
953        })?
954        .map_err(|_| {
955            failure(
956                NodeFailureCode::BackendOperationFailed,
957                &format!("{operation} task failed"),
958            )
959        })?
960}
961
962fn windows_path_text(path: &OpaqueHostPath) -> &str {
963    path.as_utf8()
964        .expect("Windows node only creates UTF-8 opaque host paths")
965}
966
967fn require_windows_path(path: OpaqueHostPath) -> Result<String, NodeFailure> {
968    path.as_utf8()
969        .map(str::to_owned)
970        .ok_or_else(|| failure(
971            NodeFailureCode::InvalidRequest,
972            "this node requires UTF-8 host paths",
973        ))
974}
975
976fn protocol_worktree(mut worktree: NativeGitWorktreeSnapshot) -> GitWorktreeSnapshot {
977    GitWorktreeSnapshot {
978        path: opaque_windows_path(worktree.path),
979        head: worktree.head,
980        branch: worktree.branch,
981        is_bare: worktree.is_bare,
982        is_main: worktree.is_main,
983        locked: worktree.locked,
984        lock_reason: worktree.lock_reason,
985        prunable: worktree.prunable,
986        prunable_reason: worktree.prunable_reason,
987        workspace_id: worktree.workspace_id.take(),
988    }
989}
990
991#[derive(Clone, Debug, Eq, PartialEq)]
992pub struct WorkspaceConfig {
993    workspace_id: WorkspaceId,
994    canonical_root: String,
995    worktree_service_mode: WorktreeServiceMode,
996    managed_worktree_profiles: BTreeMap<WorktreeProfileId, ManagedWorktreeProfile>,
997}
998
999impl WorkspaceConfig {
1000    pub fn new(
1001        workspace_id: WorkspaceId,
1002        root: impl AsRef<Path>,
1003    ) -> Result<Self, NodeServerError> {
1004        let root = root.as_ref();
1005        if !root.is_absolute() {
1006            return Err(NodeServerError::InvalidWorkspaceRoot {
1007                workspace_id,
1008                path: root.to_string_lossy().into_owned(),
1009                message: "path is not absolute".to_owned(),
1010            });
1011        }
1012        let canonical = std::fs::canonicalize(root).map_err(|error| {
1013            NodeServerError::InvalidWorkspaceRoot {
1014                workspace_id: workspace_id.clone(),
1015                path: root.to_string_lossy().into_owned(),
1016                message: error.to_string(),
1017            }
1018        })?;
1019        if !canonical.is_dir() {
1020            return Err(NodeServerError::InvalidWorkspaceRoot {
1021                workspace_id,
1022                path: root.to_string_lossy().into_owned(),
1023                message: "path is not a directory".to_owned(),
1024            });
1025        }
1026        let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
1027            NodeServerError::InvalidWorkspaceRoot {
1028                workspace_id: workspace_id.clone(),
1029                path: path.to_string_lossy().into_owned(),
1030                message: "canonical path is not valid Unicode".to_owned(),
1031            }
1032        })?;
1033        let canonical_root = platform::normalize_canonical_root(canonical_root);
1034        validate_workspace_root(&workspace_id, &canonical_root)?;
1035        Ok(Self {
1036            workspace_id,
1037            canonical_root,
1038            worktree_service_mode: WorktreeServiceMode::Manual,
1039            managed_worktree_profiles: BTreeMap::new(),
1040        })
1041    }
1042
1043    pub fn workspace_id(&self) -> &WorkspaceId {
1044        &self.workspace_id
1045    }
1046
1047    pub fn canonical_root(&self) -> &str {
1048        &self.canonical_root
1049    }
1050
1051    pub fn with_worktree_service_mode(mut self, mode: WorktreeServiceMode) -> Self {
1052        self.worktree_service_mode = mode;
1053        self
1054    }
1055
1056    pub fn with_managed_worktree_profile(
1057        mut self,
1058        profile: ManagedWorktreeProfile,
1059    ) -> Result<Self, NodeServerError> {
1060        profile.validate_for_workspace(&self.canonical_root).map_err(|message| {
1061            NodeServerError::InvalidManagedWorktreeProfile {
1062                workspace_id: self.workspace_id.clone(),
1063                profile_id: profile.profile_id().clone(),
1064                message,
1065            }
1066        })?;
1067        if self.managed_worktree_profiles.contains_key(profile.profile_id()) {
1068            return Err(NodeServerError::DuplicateManagedWorktreeProfile {
1069                workspace_id: self.workspace_id,
1070                profile_id: profile.profile_id().clone(),
1071            });
1072        }
1073        if self.managed_worktree_profiles.len()
1074            == MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE
1075        {
1076            return Err(NodeServerError::ManagedWorktreeProfileCapacity {
1077                workspace_id: self.workspace_id,
1078                max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
1079            });
1080        }
1081        self.managed_worktree_profiles
1082            .insert(profile.profile_id().clone(), profile);
1083        Ok(self)
1084    }
1085}
1086
1087#[derive(Clone)]
1088pub struct NodeServerConfig {
1089    pub endpoint: String,
1090    api_listen: Option<std::net::SocketAddr>,
1091    /// Opt-in node-envelope HTTP+WS browser bridge (separate from `--api-listen`
1092    /// ops HTTP and from framed C2). Loopback only; default `None` = off.
1093    bridge_listen: Option<std::net::SocketAddr>,
1094    /// Optional bridge shared secret (`GATE4AGENT_BRIDGE_TOKEN`). Distinct from
1095    /// NODE_TOKEN; never logged. `None` = loopback bind is the only gate.
1096    bridge_token: Option<String>,
1097    /// Tip 6: optional mesh underlay UDP bind that relays to local `--bridge-listen`.
1098    /// Requires bridge_listen + bridge_token. HQ never accepts (NodePeer).
1099    bridge_underlay: Option<browser_bridge::BridgeUnderlayAccept>,
1100    /// A relay to CALL, rather than wait to be called by. `None` keeps the
1101    /// node purely a listener, which is what it has always been.
1102    call_home: Option<std::net::SocketAddr>,
1103    /// When set, call-home authenticates by signing the relay's challenge with this
1104    /// identity (enrolled node) and falls back to the token handshake against a relay
1105    /// that sends no challenge.
1106    call_home_identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
1107    pub node_id: NodeId,
1108    pub workspaces: Vec<WorkspaceConfig>,
1109    access_token: String,
1110    pub runtime: NativeRuntimeConfig,
1111    session_record_retention: SessionRecordRetentionConfig,
1112    state_path: Option<PathBuf>,
1113    spawn_profiles: SpawnProfileRegistry,
1114    session_environment: Option<NodeSessionEnvironmentConfig>,
1115    history: Option<NativeHistoryConfig>,
1116    harness_mcp_helper: Option<ReviewedHarnessMcpProgram>,
1117    /// Station network allowlist catalog (opaque ids). Empty default.
1118    /// Loaded at startup from `--network-allowlist-catalog` / env
1119    /// `GATE4AGENT_NODE_NETWORK_ALLOWLIST_CATALOG` file. Never secrets.
1120    network_allowlist_catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog,
1121    #[cfg(feature = "fixture")]
1122    fixture_raw_pty_runtime: bool,
1123}
1124
1125#[derive(Clone)]
1126struct NodeSessionEnvironmentConfig {
1127    root: PathBuf,
1128    resolver: Arc<dyn NodeSecretResolver>,
1129}
1130
1131impl NodeServerConfig {
1132    pub fn new(
1133        endpoint: impl Into<String>,
1134        access_token: impl Into<String>,
1135        node_id: NodeId,
1136        workspaces: impl IntoIterator<Item = WorkspaceConfig>,
1137    ) -> Result<Self, NodeServerError> {
1138        let endpoint = endpoint.into();
1139        let access_token = access_token.into();
1140        if !platform::validate_endpoint(&endpoint) {
1141            return Err(NodeServerError::InvalidEndpoint);
1142        }
1143        if access_token.is_empty() || access_token.len() > 4_096 {
1144            return Err(NodeServerError::InvalidAccessToken);
1145        }
1146        let workspaces = workspaces.into_iter().collect::<Vec<_>>();
1147        if workspaces.is_empty() {
1148            return Err(NodeServerError::NoWorkspaces);
1149        }
1150        let mut workspace_ids = BTreeMap::new();
1151        let mut workspace_roots = BTreeMap::new();
1152        for workspace in &workspaces {
1153            if workspace.worktree_service_mode == WorktreeServiceMode::Managed
1154                && workspace.managed_worktree_profiles.is_empty()
1155            {
1156                return Err(NodeServerError::ManagedWorktreeProfileRequired(
1157                    workspace.workspace_id.clone(),
1158                ));
1159            }
1160            if workspace.worktree_service_mode != WorktreeServiceMode::Managed
1161                && !workspace.managed_worktree_profiles.is_empty()
1162            {
1163                return Err(NodeServerError::ManagedWorktreeProfileModeMismatch(
1164                    workspace.workspace_id.clone(),
1165                ));
1166            }
1167            if workspace_ids
1168                .insert(workspace.workspace_id.clone(), ())
1169                .is_some()
1170            {
1171                return Err(NodeServerError::DuplicateWorkspaceId(
1172                    workspace.workspace_id.clone(),
1173                ));
1174            }
1175            let root_key = platform::root_identity(&workspace.canonical_root);
1176            if let Some(existing) = workspace_roots.insert(
1177                root_key,
1178                workspace.workspace_id.clone(),
1179            ) {
1180                return Err(NodeServerError::DuplicateWorkspaceRoot {
1181                    first: existing,
1182                    second: workspace.workspace_id.clone(),
1183                    root: workspace.canonical_root.clone(),
1184                });
1185            }
1186        }
1187        Ok(Self {
1188            endpoint,
1189            api_listen: None,
1190            bridge_listen: None,
1191            bridge_token: None,
1192            bridge_underlay: None,
1193            call_home: None,
1194            call_home_identity: None,
1195            node_id,
1196            workspaces,
1197            access_token,
1198            // PTY output is polled by the runtime's effect worker; the upstream
1199            // default (20ms) added ~10-20ms to every keystroke echo.
1200            runtime: NativeRuntimeConfig {
1201                worker_poll_interval_ms: 4,
1202                ..NativeRuntimeConfig::default()
1203            },
1204            session_record_retention: SessionRecordRetentionConfig::default(),
1205            state_path: None,
1206            spawn_profiles: SpawnProfileRegistry::default(),
1207            session_environment: None,
1208            history: None,
1209            harness_mcp_helper: None,
1210            network_allowlist_catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog::new(),
1211            #[cfg(feature = "fixture")]
1212            fixture_raw_pty_runtime: false,
1213        })
1214    }
1215
1216    pub fn with_api_listen(
1217        mut self,
1218        api_listen: std::net::SocketAddr,
1219    ) -> Result<Self, NodeServerError> {
1220        if !api_listen.ip().is_loopback() {
1221            return Err(NodeServerError::InvalidApiListen(api_listen));
1222        }
1223        self.api_listen = Some(api_listen);
1224        Ok(self)
1225    }
1226
1227    /// Opt-in node-envelope HTTP+WS bridge listener. Loopback only (same spirit
1228    /// as `with_api_listen`). Does not enable by default — libraries and the
1229    /// binary leave this `None` until `--bridge-listen` / `with_bridge_listen`.
1230    pub fn with_bridge_listen(
1231        mut self,
1232        bridge_listen: std::net::SocketAddr,
1233    ) -> Result<Self, NodeServerError> {
1234        if !bridge_listen.ip().is_loopback() {
1235            return Err(NodeServerError::InvalidBridgeListen(bridge_listen));
1236        }
1237        self.bridge_listen = Some(bridge_listen);
1238        Ok(self)
1239    }
1240
1241    /// Optional bridge shared secret, distinct from the C2/node access token.
1242    /// Never log the value. Empty / oversize refused.
1243    pub fn with_bridge_token(
1244        mut self,
1245        bridge_token: impl Into<String>,
1246    ) -> Result<Self, NodeServerError> {
1247        let bridge_token = bridge_token.into();
1248        if bridge_token.is_empty() || bridge_token.len() > 4_096 {
1249            return Err(NodeServerError::InvalidBridgeToken);
1250        }
1251        self.bridge_token = Some(bridge_token);
1252        Ok(self)
1253    }
1254
1255    /// Tip 6: point bridge reachability over mesh underlay (UDP+AEAD relay to
1256    /// local loopback `--bridge-listen`). Requires bridge listen + bridge token
1257    /// already configured. Transport key is 32 raw bytes (never logged).
1258    /// Underlay auth token is distinct from BRIDGE_TOKEN (crypto ≠ auth).
1259    pub fn with_bridge_underlay(
1260        mut self,
1261        bind: std::net::SocketAddr,
1262        transport_key: [u8; 32],
1263        underlay_token: impl Into<String>,
1264    ) -> Result<Self, NodeServerError> {
1265        if self.bridge_listen.is_none() {
1266            return Err(NodeServerError::BridgeUnderlayRequiresBridgeListen);
1267        }
1268        if self.bridge_token.is_none() {
1269            return Err(NodeServerError::BridgeUnderlayRequiresBridgeToken);
1270        }
1271        let underlay_token = underlay_token.into();
1272        if underlay_token.is_empty() || underlay_token.len() > 4_096 {
1273            return Err(NodeServerError::InvalidBridgeUnderlayToken);
1274        }
1275        // Port 0 is allowed (ephemeral). Role lock (no HQ accept) lives in underlay.
1276        self.bridge_underlay = Some(browser_bridge::BridgeUnderlayAccept {
1277            bind,
1278            transport_key,
1279            underlay_token,
1280        });
1281        Ok(self)
1282    }
1283
1284    /// Also dial `relay` and serve this wire over the connection, in
1285    /// addition to accepting on the local endpoint.
1286    ///
1287    /// Who opens the socket and who is the protocol's server are separate
1288    /// questions, and this is the node's half of separating them. The node
1289    /// stays the server either way -- it answers `NodeRequest`s and emits
1290    /// `NodeEvent`s exactly as it does for a relay that dialled it. All
1291    /// that changes is that a node with no address anyone can reach makes
1292    /// the connection itself, and names itself once it arrives (see
1293    /// `gate4agent_node_wire::write_call_home_announce`).
1294    ///
1295    /// Loopback only, and that is not an oversight to be flagged and
1296    /// worked around. This wire carries terminal contents, keystrokes and
1297    /// file bytes as plaintext JSON; its mutual challenge-response
1298    /// authenticates both ends and encrypts nothing. Every listener in
1299    /// this stack is loopback-guarded for that reason, and a dialler must
1300    /// hold the same line -- a node calling a public relay would put the
1301    /// whole control plane on the wire in the clear. Reaching a relay on
1302    /// another host is a transport question, and it stays unanswered here
1303    /// on purpose: this change is about direction, not distance.
1304    pub fn with_call_home(
1305        mut self,
1306        relay: std::net::SocketAddr,
1307    ) -> Result<Self, NodeServerError> {
1308        // Loopback, or the concrete C2 WireGuard tunnel address. Unspecified
1309        // and port 0 refused — plaintext wire must not dial every interface.
1310        if relay.port() == 0 || relay.ip().is_unspecified() {
1311            return Err(NodeServerError::InvalidCallHome(relay));
1312        }
1313        self.call_home = Some(relay);
1314        Ok(self)
1315    }
1316
1317    /// Authenticate call-home with a node-held identity instead of a standing token.
1318    pub fn with_call_home_identity(mut self, identity: Arc<hatchery_enroll::NodeIdentity>) -> Self {
1319        self.call_home_identity = Some(identity);
1320        self
1321    }
1322
1323    pub fn with_state_path(
1324        mut self,
1325        state_path: impl Into<PathBuf>,
1326    ) -> Result<Self, NodeServerError> {
1327        let state_path = state_path.into();
1328        if !state_path.is_absolute() || state_path.file_name().is_none() {
1329            return Err(NodeServerError::InvalidStatePath(
1330                state_path.to_string_lossy().into_owned(),
1331            ));
1332        }
1333        self.state_path = Some(state_path);
1334        Ok(self)
1335    }
1336
1337    pub fn with_spawn_profiles(mut self, spawn_profiles: SpawnProfileRegistry) -> Self {
1338        self.spawn_profiles = spawn_profiles;
1339        self
1340    }
1341
1342    /// Sets the age/keep-N policy the drive loop's retention sweep uses to
1343    /// retire dead `ManagedSessionState::Unavailable` records. Both fields
1344    /// default to `0` (disabled) via `SessionRecordRetentionConfig::default`
1345    /// when this is never called -- a node never deletes a durable record
1346    /// until an operator opts in with a real value.
1347    pub fn with_session_record_retention(
1348        mut self,
1349        retention: SessionRecordRetentionConfig,
1350    ) -> Self {
1351        self.session_record_retention = retention;
1352        self
1353    }
1354
1355    pub fn with_session_environment_materialization(
1356        mut self,
1357        root: impl Into<PathBuf>,
1358        resolver: Arc<dyn NodeSecretResolver>,
1359    ) -> Result<Self, NodeServerError> {
1360        let root = root.into();
1361        if !root.is_absolute() {
1362            return Err(NodeServerError::InvalidSessionEnvironmentRoot);
1363        }
1364        self.session_environment = Some(NodeSessionEnvironmentConfig { root, resolver });
1365        Ok(self)
1366    }
1367
1368    /// Enables bounded provider-history discovery and loading from explicit
1369    /// host-owned roots. Ambient vendor homes are never inferred here.
1370    pub fn with_history(mut self, history: NativeHistoryConfig) -> Self {
1371        self.history = Some(history);
1372        self
1373    }
1374
1375    /// Enables the H3B read proxy with one exact operator-reviewed helper file.
1376    /// Linux/macOS use owner-only UDS; Windows uses owner-only named pipes.
1377    /// Both platforms review the helper the same way (absolute regular file +
1378    /// identity hash) — the old `cfg(not(windows))` hard-refuse was a leftover
1379    /// gate after Unix listener/peer-cred support landed.
1380    pub fn with_harness_mcp_helper(
1381        mut self,
1382        helper_program: impl Into<PathBuf>,
1383    ) -> Result<Self, NodeServerError> {
1384        self.harness_mcp_helper = Some(
1385            ReviewedHarnessMcpProgram::review(helper_program.into())
1386                .map_err(|_| NodeServerError::InvalidHarnessMcpHelper)?,
1387        );
1388        Ok(self)
1389    }
1390
1391    /// Install an in-memory station network allowlist catalog (opaque ids +
1392    /// optional node-local permits / provider-native mapping). Empty remains
1393    /// the default — unknown spawn ids refuse at resolve. Never cookies /
1394    /// OAuth / proxy credentials. C2 / inventory still list ids only.
1395    pub fn with_network_allowlist_catalog(
1396        mut self,
1397        catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog,
1398    ) -> Result<Self, NodeServerError> {
1399        if catalog.len() > crate::network_allowlist_catalog::MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES {
1400            return Err(NodeServerError::NetworkAllowlistCatalogCapacity {
1401                max: crate::network_allowlist_catalog::MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES,
1402            });
1403        }
1404        self.network_allowlist_catalog = catalog;
1405        Ok(self)
1406    }
1407
1408    /// Load station network allowlist catalog from an absolute regular file
1409    /// (v1 id-list or v2 JSON schema). Unset path is not used here — callers
1410    /// that want env fallback use `resolve_network_allowlist_catalog`.
1411    pub fn with_network_allowlist_catalog_file(
1412        self,
1413        path: impl AsRef<std::path::Path>,
1414    ) -> Result<Self, NodeServerError> {
1415        let catalog = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(path)
1416            .map_err(NodeServerError::from)?;
1417        self.with_network_allowlist_catalog(catalog)
1418    }
1419}
1420
1421pub fn default_state_path(node_id: &NodeId) -> Result<PathBuf, NodeServerError> {
1422    platform::default_state_path(node_id.as_str())
1423        .ok_or(NodeServerError::LocalStateDirectoryUnavailable)
1424}
1425
1426pub fn default_node_endpoint() -> Result<PathBuf, NodeServerError> {
1427    platform::default_node_endpoint().ok_or(NodeServerError::LocalRuntimeDirectoryUnavailable)
1428}
1429
1430fn delivery_store_root_for_state_path(state_path: &Path) -> Option<PathBuf> {
1431    let parent = state_path.parent()?;
1432    let mut name = state_path.file_name()?.to_os_string();
1433    name.push(".delivery-store");
1434    Some(parent.join(name))
1435}
1436
1437fn context_pack_store_root_for_state_path(state_path: &Path) -> Option<PathBuf> {
1438    let parent = state_path.parent()?;
1439    let mut name = state_path.file_name()?.to_os_string();
1440    name.push(".context-pack-store");
1441    Some(parent.join(name))
1442}
1443
1444pub struct NodeServer {
1445    config: NodeServerConfig,
1446    runtime: NativeRuntime,
1447    shared: Arc<NodeShared>,
1448    events: EventSubscription,
1449    state_path_lock: Option<session_registry::StatePathLock>,
1450}
1451
1452#[cfg(feature = "fixture")]
1453#[derive(Clone, Default)]
1454pub struct SpawnManagedWorktreeV2FailureProbe {
1455    latest_code: Arc<Mutex<Option<NodeFailureCode>>>,
1456}
1457
1458#[cfg(feature = "fixture")]
1459impl SpawnManagedWorktreeV2FailureProbe {
1460    pub fn latest_code(&self) -> Option<NodeFailureCode> {
1461        *self
1462            .latest_code
1463            .lock()
1464            .unwrap_or_else(|poisoned| poisoned.into_inner())
1465    }
1466
1467    fn record(&self, code: Option<NodeFailureCode>) {
1468        *self
1469            .latest_code
1470            .lock()
1471            .unwrap_or_else(|poisoned| poisoned.into_inner()) = code;
1472    }
1473}
1474
1475#[cfg(all(feature = "fixture", windows))]
1476struct IsolatedCodexHomeFixtureEnvironment;
1477
1478#[cfg(all(feature = "fixture", windows))]
1479impl gate4agent_runtime_native::NativeChildEnvironmentResolver
1480    for IsolatedCodexHomeFixtureEnvironment
1481{
1482    fn resolve_child_environment(
1483        &self,
1484    ) -> Result<
1485        Vec<gate4agent_catalog::EnvMutation>,
1486        gate4agent_runtime_native::NativeChildEnvironmentResolveError,
1487    > {
1488        Ok(vec![gate4agent_catalog::EnvMutation {
1489            key: OsString::from("USERPROFILE"),
1490            value: None,
1491        }])
1492    }
1493}
1494
1495impl NodeServer {
1496    /// The letter-wake handle for the door, when m4a accounts are on. `token` is what callers
1497    /// must present.
1498    /// Turn on the m4a signer proxy for PTY spawns (replaces `M4A_SESSION_CAP`).
1499    #[cfg(unix)]
1500    pub fn set_signer_proxy(&self, proxy: Arc<crate::keystore_proxy::SignerProxy>) -> bool {
1501        self.shared.signer_proxy.set(proxy).is_ok()
1502    }
1503
1504    pub fn wake_handle(&self, token: String, policy: Arc<crate::wake_pipeline::WakePolicy>) -> Option<node_wake::NodeWake> {
1505        self.shared.m4a_accounts.as_ref()?;
1506        Some(node_wake::NodeWake::new(Arc::clone(&self.shared), token, policy))
1507    }
1508
1509    pub fn new(config: NodeServerConfig) -> Result<Self, NodeServerError> {
1510        let catalog = active_registry()?;
1511        Self::new_with_registry(config, catalog)
1512    }
1513
1514    #[cfg(feature = "fixture")]
1515    pub fn new_fixture(config: NodeServerConfig) -> Result<Self, NodeServerError> {
1516        let mut spec = gate4agent_testkit::interactive_agent_spec();
1517        spec.id = AgentId::new("claude").map_err(|error| NodeServerError::Registry(error.to_string()))?;
1518        spec.display_name = "Controlled Claude PTY fixture".to_owned();
1519        Self::new_fixture_with_spec(config, spec)
1520    }
1521
1522    #[cfg(feature = "fixture")]
1523    pub fn new_clean_exit_fixture(
1524        config: NodeServerConfig,
1525        fixture_root: PathBuf,
1526        started_marker: PathBuf,
1527        release_signal: PathBuf,
1528    ) -> Result<Self, NodeServerError> {
1529        let mut spec = gate4agent_testkit::controlled_clean_exit_agent_spec(
1530            &fixture_root,
1531            &started_marker,
1532            &release_signal,
1533        )
1534        .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1535        spec.id = AgentId::new("claude")
1536            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1537        spec.display_name = "Controlled Claude clean-exit fixture".to_owned();
1538        Self::new_fixture_with_spec(config, spec)
1539    }
1540
1541    /// A `claude` source whose session establishes a verified provider
1542    /// identity via an early `SessionStart` Hook post (so it is eligible for
1543    /// auto-export-at-exit and its native history is independently
1544    /// loadable), waits for `release_signal`, then exits cleanly — paired
1545    /// on the same Node with a plain `codex` target that never validates
1546    /// anything about what it receives. Built for A2 durable ContextPack
1547    /// E2E coverage: the test itself inspects materialized files and
1548    /// managed-session-record fields directly, rather than having the
1549    /// target script self-validate.
1550    #[cfg(all(feature = "fixture", windows))]
1551    pub fn new_durable_context_pack_clean_exit_fixture(
1552        config: NodeServerConfig,
1553        fixture_root: PathBuf,
1554        started_marker: PathBuf,
1555        release_signal: PathBuf,
1556        session_id: &str,
1557    ) -> Result<Self, NodeServerError> {
1558        let mut source_spec = gate4agent_testkit::identified_clean_exit_agent_spec(
1559            &fixture_root,
1560            &started_marker,
1561            &release_signal,
1562            session_id,
1563        )
1564        .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1565        let source_id = AgentId::new("claude")
1566            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1567        let source_provider = builtin_registry().get(&source_id).ok_or_else(|| {
1568            NodeServerError::Registry(
1569                "durable context pack clean-exit fixture source adapter is unavailable"
1570                    .to_owned(),
1571            )
1572        })?;
1573        source_spec.capabilities.adapters.history =
1574            source_provider.capabilities.adapters.history.clone();
1575        source_spec.id = source_id;
1576        source_spec.display_name =
1577            "Controlled Claude durable context-pack clean-exit fixture".to_owned();
1578        // Both specs launch via `powershell.exe`; the registry's ambiguity
1579        // check keys on `detection.command` (+ aliases), not `launch.program`
1580        // -- give each a distinct detection identity, same precedent as
1581        // `context_pack_fixture_catalog`'s per-provider
1582        // `gate4agent-{provider}-context-fixture.cmd` commands.
1583        source_spec.detection.command =
1584            "gate4agent-claude-durable-context-pack-fixture.cmd".to_owned();
1585        source_spec.detection.aliases.clear();
1586
1587        let mut target_spec = gate4agent_testkit::interactive_agent_spec();
1588        target_spec.id = AgentId::new("codex")
1589            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1590        target_spec.display_name =
1591            "Controlled Codex durable context-pack target fixture".to_owned();
1592        target_spec.detection.command =
1593            "gate4agent-codex-durable-context-pack-fixture.cmd".to_owned();
1594        target_spec.detection.aliases.clear();
1595
1596        let catalog = AgentRegistry::new([source_spec, target_spec])
1597            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1598        let mut server = Self::new_with_registry(config, catalog)?;
1599        Arc::get_mut(&mut server.shared)
1600            .expect("fixture Node shared state must remain uniquely owned before run")
1601            .fixture_semantic_hook_policy = true;
1602        Ok(server)
1603    }
1604
1605    #[cfg(feature = "fixture")]
1606    pub fn new_monitoring_hook_fixture(
1607        config: NodeServerConfig,
1608    ) -> Result<Self, NodeServerError> {
1609        let mut spec = gate4agent_testkit::monitoring_hook_agent_spec();
1610        spec.id = AgentId::new("claude")
1611            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1612        let mut server = Self::new_fixture_with_spec(config, spec)?;
1613        Arc::get_mut(&mut server.shared)
1614            .expect("fixture Node shared state must remain uniquely owned before run")
1615            .fixture_semantic_hook_policy = true;
1616        Ok(server)
1617    }
1618
1619    #[cfg(all(feature = "fixture", windows))]
1620    pub fn new_provider_bundle_argv_fixture(
1621        config: NodeServerConfig,
1622        agent_id: AgentId,
1623        proof_path: PathBuf,
1624    ) -> Result<Self, NodeServerError> {
1625        let spec = Self::provider_bundle_argv_fixture_spec(agent_id, proof_path, None)?;
1626        Self::new_fixture_with_spec(config, spec)
1627    }
1628
1629    #[cfg(all(feature = "fixture", windows))]
1630    pub fn new_provider_bundle_argv_hold_fixture(
1631        config: NodeServerConfig,
1632        agent_id: AgentId,
1633        proof_path: PathBuf,
1634        release_signal: PathBuf,
1635    ) -> Result<Self, NodeServerError> {
1636        let spec = Self::provider_bundle_argv_fixture_spec(
1637            agent_id,
1638            proof_path,
1639            Some(release_signal),
1640        )?;
1641        Self::new_fixture_with_spec(config, spec)
1642    }
1643
1644    #[cfg(all(feature = "fixture", windows))]
1645    fn provider_bundle_argv_fixture_spec(
1646        agent_id: AgentId,
1647        proof_path: PathBuf,
1648        release_signal: Option<PathBuf>,
1649    ) -> Result<gate4agent_types::AgentSpec, NodeServerError> {
1650        if !matches!(agent_id.as_str(), "claude" | "codex" | "kimi") {
1651            return Err(NodeServerError::Registry(
1652                "provider bundle argv fixture requires Claude, Codex, or Kimi".to_owned(),
1653            ));
1654        }
1655        let mut spec = gate4agent_testkit::interactive_agent_spec();
1656        spec.id = agent_id.clone();
1657        spec.display_name = format!("Controlled {agent_id} bundle argv fixture");
1658        let provider = builtin_registry()
1659            .get(&agent_id)
1660            .ok_or_else(|| {
1661                NodeServerError::Registry(
1662                    "provider bundle argv fixture adapter is unavailable".to_owned(),
1663                )
1664            })?;
1665        spec.capabilities.adapters.history = provider.capabilities.adapters.history.clone();
1666        if !proof_path.is_absolute()
1667            || !proof_path
1668                .parent()
1669                .is_some_and(|parent| parent.is_dir())
1670        {
1671            return Err(NodeServerError::Registry(
1672                "provider bundle argv proof path requires an existing absolute parent"
1673                    .to_owned(),
1674            ));
1675        }
1676        if let Some(release_signal) = release_signal.as_ref() {
1677            if !release_signal.is_absolute()
1678                || !release_signal
1679                    .parent()
1680                    .is_some_and(|parent| parent.is_dir())
1681            {
1682                return Err(NodeServerError::Registry(
1683                    "provider bundle argv release path requires an existing absolute parent"
1684                        .to_owned(),
1685                ));
1686            }
1687            if release_signal.exists() {
1688                return Err(NodeServerError::Registry(
1689                    "provider bundle argv release path must not exist".to_owned(),
1690                ));
1691            }
1692            let proof_parent = std::fs::canonicalize(
1693                proof_path
1694                    .parent()
1695                    .expect("validated provider bundle proof parent"),
1696            )
1697            .map_err(|_| {
1698                NodeServerError::Registry(
1699                    "provider bundle argv proof parent is unavailable".to_owned(),
1700                )
1701            })?;
1702            let release_parent = std::fs::canonicalize(
1703                release_signal
1704                    .parent()
1705                    .expect("validated provider bundle release parent"),
1706            )
1707            .map_err(|_| {
1708                NodeServerError::Registry(
1709                    "provider bundle argv release parent is unavailable".to_owned(),
1710                )
1711            })?;
1712            if proof_parent != release_parent {
1713                return Err(NodeServerError::Registry(
1714                    "provider bundle argv release path must share the proof parent".to_owned(),
1715                ));
1716            }
1717        }
1718        let proof_path = proof_path.into_os_string().into_string().map_err(|_| {
1719            NodeServerError::Registry(
1720                "provider bundle argv proof path is not valid Unicode".to_owned(),
1721            )
1722        })?;
1723        let release_signal = release_signal.map(|release_signal| {
1724            release_signal.into_os_string().into_string().map_err(|_| {
1725                NodeServerError::Registry(
1726                    "provider bundle argv release path is not valid Unicode".to_owned(),
1727                )
1728            })
1729        }).transpose()?;
1730        let script = spec
1731            .launch
1732            .fixed_args
1733            .pop()
1734            .ok_or_else(|| NodeServerError::Registry("PTY fixture script is unavailable".to_owned()))?;
1735        let bundle_validation = if agent_id.as_str() == "codex" {
1736            "if ($bundleArgs.Count -ne 0) { exit 91 }; $codexHome = [Environment]::GetEnvironmentVariable('CODEX_HOME', 'Process'); $cwd = (Get-Location).ProviderPath; if ([string]::IsNullOrEmpty($codexHome)) { [IO.File]::WriteAllLines($proofPath, [string[]]@('unbundled', $cwd)); [Console]::Out.WriteLine('F63_CODEX_UNBUNDLED_VALIDATED') } elseif (-not [IO.Path]::IsPathRooted($codexHome) -or -not (Test-Path -LiteralPath $codexHome -PathType Container)) { exit 92 } else { $skillPath = Join-Path $codexHome 'skills/review-code/SKILL.md'; if (-not (Test-Path -LiteralPath $skillPath -PathType Leaf)) { exit 93 }; $files = @(Get-ChildItem -LiteralPath $codexHome -Recurse -Force -File); if ($files.Count -ne 1 -or [IO.Path]::GetFullPath($files[0].FullName) -ine [IO.Path]::GetFullPath($skillPath)) { exit 94 }; $sha = [Security.Cryptography.SHA256]::Create(); try { $skillHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($skillPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }; if ($skillHash -cne 'd78fe03be106b673fcf5415c8359e99f0298b5071cd11822c58ebcb27e52a68d') { exit 95 }; [IO.File]::WriteAllLines($proofPath, [string[]]@('bundled', $codexHome, $cwd, $skillHash)); [Console]::Out.WriteLine('F63_CODEX_BUNDLE_HOME_VALIDATED') }"
1737        } else if agent_id.as_str() == "claude" {
1738            "if ($bundleArgs.Count -eq 0) { [IO.File]::WriteAllLines($proofPath, [string[]]@()); [Console]::Out.WriteLine('F62_NO_BUNDLE_ARGV') } elseif ($bundleArgs.Count -eq 2 -and $bundleArgs[0] -ceq '--plugin-dir' -and [IO.Path]::IsPathRooted($bundleArgs[1]) -and (Test-Path -LiteralPath $bundleArgs[1] -PathType Container)) { if (-not (Test-Path -LiteralPath (Join-Path $bundleArgs[1] '.claude-plugin/plugin.json') -PathType Leaf)) { exit 92 }; [IO.File]::WriteAllLines($proofPath, [string[]]@($bundleArgs[0], $bundleArgs[1])); [Console]::Out.WriteLine('F62_BUNDLE_ARGV_VALIDATED') } else { exit 91 }"
1739        } else {
1740            "if ($bundleArgs.Count -eq 0) { [IO.File]::WriteAllLines($proofPath, [string[]]@()); [Console]::Out.WriteLine('F62_NO_BUNDLE_ARGV') } elseif ($bundleArgs.Count -eq 2 -and $bundleArgs[0] -ceq '--skills-dir' -and [IO.Path]::IsPathRooted($bundleArgs[1]) -and (Test-Path -LiteralPath $bundleArgs[1] -PathType Container)) { if ((Split-Path -Leaf $bundleArgs[1]) -ne 'skills') { exit 93 }; [IO.File]::WriteAllLines($proofPath, [string[]]@($bundleArgs[0], $bundleArgs[1])); [Console]::Out.WriteLine('F62_BUNDLE_ARGV_VALIDATED') } else { exit 91 }"
1741        };
1742        if let Some(release_signal) = release_signal {
1743            spec.launch.fixed_args.push(format!(
1744                "& {{ param([string]$proofPath, [string]$releaseSignal, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {bundle_validation}; $deadline = [DateTime]::UtcNow.AddSeconds(45); while (-not (Test-Path -LiteralPath $releaseSignal -PathType Leaf)) {{ if ([DateTime]::UtcNow -ge $deadline) {{ exit 96 }}; Start-Sleep -Milliseconds 20 }}; {script} }}",
1745            ));
1746            spec.launch.fixed_args.push(proof_path);
1747            spec.launch.fixed_args.push(release_signal);
1748        } else {
1749            spec.launch.fixed_args.push(format!(
1750                "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {bundle_validation}; {script} }}",
1751            ));
1752            spec.launch.fixed_args.push(proof_path);
1753        }
1754        Ok(spec)
1755    }
1756
1757    #[cfg(all(feature = "fixture", windows))]
1758    pub fn new_context_pack_fixture(
1759        config: NodeServerConfig,
1760        proof_path: PathBuf,
1761    ) -> Result<Self, NodeServerError> {
1762        let catalog = Self::context_pack_fixture_catalog(proof_path, None, false)?;
1763        Self::new_with_registry(config, catalog)
1764    }
1765
1766    #[cfg(all(feature = "fixture", windows))]
1767    pub fn new_monitoring_context_pack_fixture(
1768        config: NodeServerConfig,
1769        proof_path: PathBuf,
1770    ) -> Result<Self, NodeServerError> {
1771        let catalog = Self::context_pack_fixture_catalog(proof_path, None, true)?;
1772        let mut server = Self::new_with_registry(config, catalog)?;
1773        Arc::get_mut(&mut server.shared)
1774            .expect("fixture Node shared state must remain uniquely owned before run")
1775            .fixture_semantic_hook_policy = true;
1776        Ok(server)
1777    }
1778
1779    #[cfg(all(feature = "fixture", windows))]
1780    pub fn install_isolated_codex_home_environment_fixture(
1781        &mut self,
1782        profile_id: SpawnEnvironmentProfileId,
1783        profile_revision: crate::protocol::SpawnEnvironmentProfileRevision,
1784    ) -> Result<(), NodeServerError> {
1785        let provider = AgentId::new("codex")
1786            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1787        let native_profile = gate4agent_runtime_native::NativeLaunchProfile::new(
1788            gate4agent_runtime_native::NativeLaunchProfileId::new(format!(
1789                "{}-pty",
1790                profile_id.as_str(),
1791            ))
1792            .map_err(|error| NodeServerError::Registry(error.to_string()))?,
1793            provider.clone(),
1794            TransportKind::Pty,
1795            vec![OsString::from("USERPROFILE")],
1796            Arc::new(IsolatedCodexHomeFixtureEnvironment),
1797        )
1798        .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1799        let materialization = NodeSessionMaterializationProfile::new(
1800            Vec::new(),
1801            vec![crate::session_environment::NodeSessionPathBinding::new(
1802                "CODEX_HOME",
1803                crate::session_environment::NodeSessionPathClass::ProviderHome,
1804            )
1805            .map_err(|error| NodeServerError::Registry(error.to_string()))?],
1806            Vec::new(),
1807        )
1808        .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1809        self.install_environment_profile(
1810            NodeEnvironmentProfile::new_with_materialization(
1811                profile_id,
1812                profile_revision,
1813                provider,
1814                [native_profile],
1815                Some(materialization),
1816            )
1817            .map_err(|error| NodeServerError::Registry(error.to_string()))?,
1818        )
1819    }
1820
1821    #[cfg(all(feature = "fixture", windows))]
1822    pub fn new_context_only_proof_fixture(
1823        config: NodeServerConfig,
1824        target_provider: AgentId,
1825        proof_path: PathBuf,
1826    ) -> Result<Self, NodeServerError> {
1827        if target_provider.as_str() != "kimi" {
1828            return Err(NodeServerError::Registry(
1829                "context-only proof fixture requires Kimi".to_owned(),
1830            ));
1831        }
1832        let catalog = Self::context_pack_fixture_catalog(
1833            proof_path,
1834            Some(&target_provider),
1835            false,
1836        )?;
1837        Self::new_with_registry(config, catalog)
1838    }
1839
1840    #[cfg(all(feature = "fixture", windows))]
1841    fn context_pack_fixture_catalog(
1842        proof_path: PathBuf,
1843        context_only_target: Option<&AgentId>,
1844        monitoring_claude_source: bool,
1845    ) -> Result<AgentRegistry, NodeServerError> {
1846        if !proof_path.is_absolute()
1847            || !proof_path
1848                .parent()
1849                .is_some_and(|parent| parent.is_dir())
1850        {
1851            return Err(NodeServerError::Registry(
1852                "context pack proof path requires an existing absolute parent".to_owned(),
1853            ));
1854        }
1855        let proof_path = proof_path.into_os_string().into_string().map_err(|_| {
1856            NodeServerError::Registry(
1857                "context pack proof path is not valid Unicode".to_owned(),
1858            )
1859        })?;
1860        let providers = ["claude", "codex", "grok", "kimi"];
1861        let builtins = builtin_registry();
1862        let mut specs = Vec::with_capacity(providers.len());
1863        for provider_id in providers {
1864            let agent_id = AgentId::new(provider_id)
1865                .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1866            let provider = builtins.get(&agent_id).ok_or_else(|| {
1867                NodeServerError::Registry(
1868                    "context pack fixture provider adapter is unavailable".to_owned(),
1869                )
1870            })?;
1871            let monitored_provider =
1872                monitoring_claude_source && matches!(provider_id, "claude" | "codex");
1873            let mut spec = if monitored_provider {
1874                gate4agent_testkit::monitoring_hook_agent_spec()
1875            } else {
1876                gate4agent_testkit::interactive_agent_spec()
1877            };
1878            spec.id = agent_id.clone();
1879            spec.display_name = format!("Controlled {agent_id} context pack fixture");
1880            spec.detection.command = format!("gate4agent-{provider_id}-context-fixture.cmd");
1881            spec.detection.aliases.clear();
1882            spec.capabilities.adapters.history = provider.capabilities.adapters.history.clone();
1883            if provider_id == "codex" {
1884                let script = spec.launch.fixed_args.pop().ok_or_else(|| {
1885                    NodeServerError::Registry("PTY fixture script is unavailable".to_owned())
1886                })?;
1887                let validation = r#"
1888$bundleArgs = @($bundleArgs)
1889if ($bundleArgs.Count -ne 0) { exit 91 }
1890$codexHome = [Environment]::GetEnvironmentVariable('CODEX_HOME', 'Process')
1891$contextRoot = [Environment]::GetEnvironmentVariable('GATE4AGENT_CONTEXT_ROOT', 'Process')
1892$cwd = (Get-Location).ProviderPath
1893if ([string]::IsNullOrEmpty($codexHome) -or -not [IO.Path]::IsPathRooted($codexHome) -or -not (Test-Path -LiteralPath $codexHome -PathType Container)) { exit 92 }
1894$skillPath = Join-Path $codexHome 'skills/review-code/SKILL.md'
1895if (-not (Test-Path -LiteralPath $skillPath -PathType Leaf)) { exit 93 }
1896$homeFiles = @(Get-ChildItem -LiteralPath $codexHome -Recurse -Force -File)
1897if ($homeFiles.Count -ne 1 -or [IO.Path]::GetFullPath($homeFiles[0].FullName) -ine [IO.Path]::GetFullPath($skillPath)) { exit 94 }
1898$sha = [Security.Cryptography.SHA256]::Create()
1899try { $skillHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($skillPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1900if ($skillHash -cne 'd78fe03be106b673fcf5415c8359e99f0298b5071cd11822c58ebcb27e52a68d') { exit 95 }
1901if ([string]::IsNullOrEmpty($contextRoot) -or -not [IO.Path]::IsPathRooted($contextRoot) -or -not (Test-Path -LiteralPath $contextRoot -PathType Container)) { exit 96 }
1902$contextPath = Join-Path $contextRoot 'context-pack.json'
1903if (-not (Test-Path -LiteralPath $contextPath -PathType Leaf)) { exit 97 }
1904$contextFiles = @(Get-ChildItem -LiteralPath $contextRoot -Recurse -Force -File)
1905if ($contextFiles.Count -ne 1 -or [IO.Path]::GetFullPath($contextFiles[0].FullName) -ine [IO.Path]::GetFullPath($contextPath)) { exit 98 }
1906$contextFull = [IO.Path]::GetFullPath($contextRoot).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1907$codexFull = [IO.Path]::GetFullPath($codexHome).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1908$cwdFull = [IO.Path]::GetFullPath($cwd).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1909$separator = [IO.Path]::DirectorySeparatorChar
1910if ($contextFull.Equals($codexFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($codexFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 99 }
1911if ($contextFull.Equals($cwdFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($cwdFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 100 }
1912$rawContext = [IO.File]::ReadAllText($contextPath)
1913if ($rawContext.Contains('g4a-private-provider-session-canary') -or $rawContext.Contains('private-provider-login-identity')) { exit 101 }
1914try { $document = $rawContext | ConvertFrom-Json -ErrorAction Stop } catch { exit 102 }
1915$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')
1916$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')
1917foreach ($property in @($document.PSObject.Properties.Name)) {
1918    if ($allowedTopLevel -cnotcontains [string]$property) { exit 103 }
1919}
1920foreach ($property in $requiredTopLevel) {
1921    if ($null -eq $document.PSObject.Properties[$property]) { exit 104 }
1922}
1923if ($document.schema -cne 'g4a-context-pack-v1' -or $null -ne $document.PSObject.Properties['cwd']) { exit 105 }
1924if (@('claude', 'codex', 'grok', 'kimi') -cnotcontains [string]$document.source_provider) { exit 106 }
1925$messages = @($document.retained_messages)
1926if ($messages.Count -eq 0) { exit 107 }
1927foreach ($message in $messages) {
1928    $messageProperties = @($message.PSObject.Properties.Name)
1929    foreach ($property in $messageProperties) {
1930        if (@('role', 'text') -cnotcontains [string]$property) { exit 108 }
1931    }
1932    if ($messageProperties.Count -ne 2 -or @('user', 'assistant') -cnotcontains [string]$message.role -or [string]::IsNullOrEmpty([string]$message.text)) { exit 109 }
1933}
1934$sha = [Security.Cryptography.SHA256]::Create()
1935try { $contextHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($contextPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1936[IO.File]::WriteAllLines($proofPath, [string[]]@('contextual', $codexHome, $cwd, $skillHash, $contextRoot, $contextHash, [string]$document.schema, [string]$document.source_provider, [string]$messages.Count, [string]$messages[0].text, [string]$messages[$messages.Count - 1].text))
1937$rawProof = [IO.File]::ReadAllText($proofPath)
1938if ($rawProof.Contains('g4a-private-provider-session-canary') -or $rawProof.Contains('private-provider-login-identity')) { exit 110 }
1939[Console]::Out.WriteLine('F7_CODEX_BUNDLE_CONTEXT_VALIDATED')
1940"#;
1941                spec.launch.fixed_args.push(format!(
1942                    "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {validation}; {script} }}",
1943                ));
1944                spec.launch.fixed_args.push(proof_path.clone());
1945            } else if context_only_target.map(AgentId::as_str) == Some(provider_id) {
1946                let script = spec.launch.fixed_args.pop().ok_or_else(|| {
1947                    NodeServerError::Registry("PTY fixture script is unavailable".to_owned())
1948                })?;
1949                let validation = r#"
1950$bundleArgs = @($bundleArgs)
1951if ($bundleArgs.Count -ne 0) { exit 91 }
1952$contextRoot = [Environment]::GetEnvironmentVariable('GATE4AGENT_CONTEXT_ROOT', 'Process')
1953$cwd = (Get-Location).ProviderPath
1954if ([string]::IsNullOrEmpty($contextRoot) -or -not [IO.Path]::IsPathRooted($contextRoot) -or -not (Test-Path -LiteralPath $contextRoot -PathType Container)) { exit 92 }
1955$contextPath = Join-Path $contextRoot 'context-pack.json'
1956if (-not (Test-Path -LiteralPath $contextPath -PathType Leaf)) { exit 93 }
1957$entries = @(Get-ChildItem -LiteralPath $contextRoot -Force)
1958if ($entries.Count -ne 1 -or $entries[0] -isnot [IO.FileInfo] -or [IO.Path]::GetFullPath($entries[0].FullName) -ine [IO.Path]::GetFullPath($contextPath)) { exit 94 }
1959$contextFull = [IO.Path]::GetFullPath($contextRoot).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1960$cwdFull = [IO.Path]::GetFullPath($cwd).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1961$separator = [IO.Path]::DirectorySeparatorChar
1962if ($contextFull.Equals($cwdFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($cwdFull + $separator, [StringComparison]::OrdinalIgnoreCase) -or $cwdFull.StartsWith($contextFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 95 }
1963try { $document = [IO.File]::ReadAllText($contextPath) | ConvertFrom-Json -ErrorAction Stop } catch { exit 96 }
1964if ($document.schema -cne 'g4a-context-pack-v1' -or $null -ne $document.PSObject.Properties['cwd']) { exit 97 }
1965if (@('claude', 'codex', 'grok', 'kimi') -cnotcontains [string]$document.source_provider) { exit 98 }
1966$messages = @($document.retained_messages)
1967if ($messages.Count -eq 0) { exit 99 }
1968$hasUser = $false
1969$hasAssistant = $false
1970foreach ($message in $messages) {
1971    if ([string]::IsNullOrWhiteSpace([string]$message.text)) { exit 100 }
1972    if ([string]$message.role -ceq 'user') { $hasUser = $true }
1973    elseif ([string]$message.role -ceq 'assistant') { $hasAssistant = $true }
1974    else { exit 101 }
1975}
1976if (-not $hasUser -or -not $hasAssistant) { exit 102 }
1977$sha = [Security.Cryptography.SHA256]::Create()
1978try { $contextHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($contextPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1979[IO.File]::WriteAllLines($proofPath, [string[]]@('context-only', $contextRoot, $cwd, $contextHash, [string]$document.schema, [string]$document.source_provider, [string]$messages.Count))
1980[Console]::Out.WriteLine('F7_KIMI_CONTEXT_ONLY_VALIDATED')
1981"#;
1982                spec.launch.fixed_args.push(format!(
1983                    "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {validation}; {script} }}",
1984                ));
1985                spec.launch.fixed_args.push(proof_path.clone());
1986            }
1987            specs.push(spec);
1988        }
1989        let catalog = AgentRegistry::new(specs)
1990            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1991        Ok(catalog)
1992    }
1993
1994    #[cfg(feature = "fixture")]
1995    pub fn new_exact_launcher_fixture(
1996        config: NodeServerConfig,
1997        agent_id: AgentId,
1998        launcher: String,
1999    ) -> Result<Self, NodeServerError> {
2000        if !matches!(agent_id.as_str(), "claude" | "codex" | "grok" | "kimi") {
2001            return Err(NodeServerError::Registry(
2002                "exact launcher fixture requires a supported provider".to_owned(),
2003            ));
2004        }
2005        if launcher.contains('\0') {
2006            return Err(NodeServerError::Registry(
2007                "exact launcher fixture path contains NUL".to_owned(),
2008            ));
2009        }
2010        let launcher_path = Path::new(&launcher);
2011        if !launcher_path.is_absolute() {
2012            return Err(NodeServerError::Registry(
2013                "exact launcher fixture path must be absolute".to_owned(),
2014            ));
2015        }
2016        let metadata = std::fs::metadata(launcher_path).map_err(|_| {
2017            NodeServerError::Registry("exact launcher fixture path is unavailable".to_owned())
2018        })?;
2019        if !metadata.is_file() {
2020            return Err(NodeServerError::Registry(
2021                "exact launcher fixture path must be a regular file".to_owned(),
2022            ));
2023        }
2024        let mut spec = builtin_registry()
2025            .get(&agent_id)
2026            .ok_or_else(|| {
2027                NodeServerError::Registry(
2028                    "exact launcher fixture provider is unavailable".to_owned(),
2029                )
2030            })?
2031            .clone();
2032        spec.launch.program = launcher;
2033        Self::new_fixture_with_spec(config, spec)
2034    }
2035
2036    /// Shared H3B validation and spec construction for
2037    /// `new_harness_mcp_proxy_fixture` and
2038    /// `new_harness_mcp_acp_launcher_fixture`: an absolute, exact-regular-
2039    /// file provider launcher plus at most 32 Unicode, NUL-free fixed
2040    /// arguments (65,536 bytes total), replacing `spec.launch` on a clone of
2041    /// the named builtin catalog entry -- detection command and expected
2042    /// process matcher rebased on the launcher's own basename, exactly the
2043    /// same shape `new_harness_mcp_proxy_fixture` always built.
2044    #[cfg(feature = "fixture")]
2045    fn h3b_provider_spec_with_program_and_args(
2046        agent_id: AgentId,
2047        provider_program: PathBuf,
2048        fixed_args: Vec<OsString>,
2049    ) -> Result<AgentSpec, NodeServerError> {
2050        if !provider_program.is_absolute() || fixed_args.len() > 32 {
2051            return Err(NodeServerError::Registry(
2052                "H3B fixture requires an absolute provider and at most 32 arguments".to_owned(),
2053            ));
2054        }
2055        let metadata = std::fs::symlink_metadata(&provider_program).map_err(|_| {
2056            NodeServerError::Registry("H3B fixture provider is unavailable".to_owned())
2057        })?;
2058        if !metadata.file_type().is_file() || metadata.file_type().is_symlink() {
2059            return Err(NodeServerError::Registry(
2060                "H3B fixture provider must be an exact regular file".to_owned(),
2061            ));
2062        }
2063        let program = provider_program.into_os_string().into_string().map_err(|_| {
2064            NodeServerError::Registry("H3B fixture provider path is not Unicode".to_owned())
2065        })?;
2066        let fixed_args = fixed_args.into_iter().map(|argument| {
2067            argument.into_string().map_err(|_| NodeServerError::Registry(
2068                "H3B fixture argument is not Unicode".to_owned(),
2069            ))
2070        }).collect::<Result<Vec<_>, _>>()?;
2071        let argument_bytes = fixed_args.iter()
2072            .try_fold(0usize, |total, argument| total.checked_add(argument.len()));
2073        if program.contains('\0')
2074            || fixed_args.iter().any(|argument| argument.contains('\0'))
2075            || !matches!(argument_bytes, Some(total) if total <= 65_536)
2076        {
2077            return Err(NodeServerError::Registry(
2078                "H3B fixture provider or arguments are invalid".to_owned(),
2079            ));
2080        }
2081        let mut spec = builtin_registry().get(&agent_id).ok_or_else(|| {
2082            NodeServerError::Registry("H3B fixture provider is unavailable".to_owned())
2083        })?.clone();
2084        let process_name = Path::new(&program).file_name().and_then(|name| name.to_str())
2085            .ok_or_else(|| NodeServerError::Registry(
2086                "H3B fixture provider basename is unavailable".to_owned(),
2087            ))?.to_owned();
2088        spec.detection.command = process_name.clone();
2089        spec.expected_processes = vec![gate4agent_types::ProcessMatcher::Exact {
2090            name: process_name,
2091        }];
2092        spec.launch.program = program;
2093        spec.launch.fixed_args = fixed_args;
2094        Ok(spec)
2095    }
2096
2097    #[cfg(feature = "fixture")]
2098    pub fn new_harness_mcp_proxy_fixture(
2099        config: NodeServerConfig,
2100        provider_program: PathBuf,
2101        fixed_args: Vec<OsString>,
2102        harness_mcp_program: PathBuf,
2103    ) -> Result<Self, NodeServerError> {
2104        let mut config = config.with_harness_mcp_helper(harness_mcp_program)?;
2105        let agent_id = AgentId::new("codex")
2106            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2107        let mut spec =
2108            Self::h3b_provider_spec_with_program_and_args(agent_id, provider_program, fixed_args)?;
2109        spec.display_name = "Controlled H3B MCP proxy fixture".to_owned();
2110        config.fixture_raw_pty_runtime = true;
2111        Self::new_fixture_with_spec(config, spec)
2112    }
2113
2114    /// The ACP-mode sibling of `new_harness_mcp_proxy_fixture`: the same
2115    /// `--harness-mcp-helper` wiring and the same absolute-program-plus-
2116    /// fixed-args launcher replacement as `new_exact_launcher_fixture`, but
2117    /// admitting `ProviderRuntimeRequirement::Acp` the honest way instead of
2118    /// hardcoding it. `new_harness_mcp_proxy_fixture` forces `config.
2119    /// fixture_raw_pty_runtime = true`, which tears out the live
2120    /// `ProviderRuntimeMonitor` entirely (`NodeServer::new_with_registry`)
2121    /// and makes `NodeShared::admit_provider_runtime`'s no-monitor branch
2122    /// hardcode `acp_transport = false` for every provider, refusing
2123    /// `ProviderRuntimeRequirement::Acp` unconditionally regardless of what
2124    /// the catalog declares. This constructor leaves `fixture_raw_pty_
2125    /// runtime` at its default `false`, so a real `ProviderRuntimeMonitor`
2126    /// is built from the (cloned, launcher-replaced) catalog spec, and
2127    /// `ProviderRuntimeMonitor::supports_acp_transport` reads the honest,
2128    /// unmodified catalog fact: every builtin provider this accepts
2129    /// (`claude`, `codex`, `grok`, `kimi`) already declares `capabilities.
2130    /// transports.acp` in `gate4agent-catalog::builtin`, so no capability is
2131    /// invented here -- this constructor only removes the fixture-only
2132    /// bypass standing in its way. `provider_runtime::policy_for_transport`'s
2133    /// `TransportKind::Acp` arm (applied unconditionally at spawn-prep time
2134    /// for every ACP session, fixture or not) is what actually grants the
2135    /// ACP-shaped `ProviderRuntimePolicy` downstream of that admission --
2136    /// this constructor never calls it directly, and does not need to.
2137    #[cfg(feature = "fixture")]
2138    pub fn new_harness_mcp_acp_launcher_fixture(
2139        config: NodeServerConfig,
2140        helper_program: PathBuf,
2141        provider: AgentId,
2142        program: PathBuf,
2143        args: Vec<OsString>,
2144    ) -> Result<Self, NodeServerError> {
2145        if !matches!(provider.as_str(), "claude" | "codex" | "grok" | "kimi") {
2146            return Err(NodeServerError::Registry(
2147                "ACP harness-MCP launcher fixture requires a supported provider".to_owned(),
2148            ));
2149        }
2150        let config = config.with_harness_mcp_helper(helper_program)?;
2151        let mut spec = Self::h3b_provider_spec_with_program_and_args(provider, program, args)?;
2152        let launch = spec.launch.clone();
2153        let acp = spec.capabilities.transports.acp.as_mut().ok_or_else(|| {
2154            NodeServerError::Registry(
2155                "ACP harness-MCP launcher fixture provider does not declare an ACP transport"
2156                    .to_owned(),
2157            )
2158        })?;
2159        // Routes the ACP spawn itself through the exact replaced launcher
2160        // (`AcpSession::spawn_with_launch` -> a bare `Command::new(&launch.
2161        // program)`, `gate4agent`'s `src/acp/spawn.rs`) instead of the
2162        // catalog's own vendor-npm resolution (`acp_command`, same file) --
2163        // without this the ACP transport ignores `spec.launch` entirely and
2164        // tries to spawn the real vendor CLI (e.g. `npx -y @agentclientprotocol/
2165        // codex-acp@1.10.0`), which this fixture must never reach.
2166        acp.launch_override = Some(launch);
2167        spec.display_name = "Controlled ACP harness-MCP launcher fixture".to_owned();
2168        Self::new_fixture_with_spec(config, spec)
2169    }
2170
2171    #[cfg(feature = "fixture")]
2172    pub fn new_resume_fixture(config: NodeServerConfig) -> Result<Self, NodeServerError> {
2173        let mut spec = gate4agent_testkit::interactive_agent_spec();
2174        let claude_id = AgentId::new("claude")
2175            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2176        let claude = builtin_registry()
2177            .get(&claude_id)
2178            .ok_or_else(|| NodeServerError::Registry("Claude fixture adapter is unavailable".to_owned()))?;
2179        spec.id = claude_id;
2180        spec.display_name = "Resumable Claude PTY fixture".to_owned();
2181        spec.expected_processes = vec![gate4agent_types::ProcessMatcher::Exact {
2182            name: "claude".to_owned(),
2183        }];
2184        spec.capabilities.transports.pty_adapter = claude
2185            .capabilities
2186            .transports
2187            .pty_adapter
2188            .clone();
2189        spec.capabilities.adapters.resume = claude
2190            .capabilities
2191            .adapters
2192            .resume
2193            .clone();
2194        let script = spec
2195            .launch
2196            .fixed_args
2197            .pop()
2198            .ok_or_else(|| NodeServerError::Registry("PTY fixture script is unavailable".to_owned()))?;
2199        spec.launch.fixed_args.push(format!(
2200            "& {{ param([Parameter(ValueFromRemainingArguments=$true)][string[]]$ignored) {script} }}",
2201        ));
2202        Self::new_fixture_with_spec(config, spec)
2203    }
2204
2205    #[cfg(feature = "fixture")]
2206    pub fn new_cmd_cwd_fixture(
2207        config: NodeServerConfig,
2208        command: String,
2209    ) -> Result<Self, NodeServerError> {
2210        let mut spec = gate4agent_testkit::interactive_agent_spec();
2211        spec.id = AgentId::new("claude")
2212            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2213        spec.display_name = "Controlled cmd cwd fixture".to_owned();
2214        spec.detection.command = "gate4agent-cwd-fixture.cmd".to_owned();
2215        spec.launch.program = command;
2216        spec.launch.fixed_args.clear();
2217        Self::new_fixture_with_spec(config, spec)
2218    }
2219
2220    #[cfg(feature = "fixture")]
2221    fn new_fixture_with_spec(
2222        config: NodeServerConfig,
2223        spec: gate4agent_types::AgentSpec,
2224    ) -> Result<Self, NodeServerError> {
2225        let catalog = AgentRegistry::new([spec])
2226            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2227        Self::new_with_registry(config, catalog)
2228    }
2229
2230    fn new_with_registry(
2231        config: NodeServerConfig,
2232        catalog: AgentRegistry,
2233    ) -> Result<Self, NodeServerError> {
2234        for profile in config.spawn_profiles.iter() {
2235            if catalog.get(&profile.provider).is_none() {
2236                return Err(NodeServerError::Registry(
2237                    "spawn profile references an unavailable provider".to_owned(),
2238                ));
2239            }
2240        }
2241        let input_settle_timeout_ms = catalog
2242            .iter()
2243            .map(|spec| spec.readiness.timeout_ms)
2244            .max()
2245            .unwrap_or(MUTATION_SETTLE_TIMEOUT_MS)
2246            .saturating_add(READINESS_SETTLE_HEADROOM_MS)
2247            .max(MUTATION_SETTLE_TIMEOUT_MS);
2248        let (provider_contracts, provider_adapter_contracts) =
2249            provider_contract_manifest(&catalog)?;
2250        let compatibility_support = node_compatibility_support_for_manifest(
2251            &provider_contracts,
2252            &provider_adapter_contracts,
2253        )?;
2254        validate_node_negotiated_handshake_capacity(&compatibility_support)
2255            .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2256        let enabled_providers = provider_contracts
2257            .iter()
2258            .map(|contract| contract.provider.clone())
2259            .collect();
2260        #[cfg(feature = "fixture")]
2261        let fixture_raw_pty_runtime = config.fixture_raw_pty_runtime;
2262        #[cfg(not(feature = "fixture"))]
2263        let fixture_raw_pty_runtime = false;
2264        let (provider_runtime_monitor, provider_runtime_statuses) = if fixture_raw_pty_runtime {
2265            let statuses = ProviderRuntimeStatuses::new(catalog.iter().map(|spec| {
2266                crate::protocol::ProviderRuntimeStatus::raw_passthrough(spec.id.clone(), None)
2267            })).expect("fixture provider catalog remains bounded");
2268            (None, statuses)
2269        } else {
2270            let monitor = Arc::new(ProviderRuntimeMonitor::new(&catalog));
2271            let statuses = monitor.collect();
2272            (Some(monitor), statuses)
2273        };
2274        let state_path_lock =
2275            session_registry::StatePathLock::acquire(config.state_path.as_deref())
2276                .map_err(|error| {
2277                    durable_state_server_error(error, DURABLE_STATE_LOCK_FAILED_ERROR)
2278                })?;
2279        let (delivery_store, delivered_bundles) = match config.state_path.as_ref() {
2280            Some(state_path) => {
2281                let delivery_root = delivery_store_root_for_state_path(state_path)
2282                    .ok_or_else(|| {
2283                        NodeServerError::DeliveryStore(
2284                            "delivery store root could not be derived from state path".to_owned(),
2285                        )
2286                    })?;
2287                let (store, bundles) = DeliveryStore::open(delivery_root).map_err(|error| {
2288                    let detail = match &error {
2289                        DeliveryStoreError::Storage(io_error) => {
2290                            format!("{error}: {io_error}")
2291                        }
2292                        _ => error.to_string(),
2293                    };
2294                    NodeServerError::DeliveryStore(detail)
2295                })?;
2296                (Some(store), bundles)
2297            }
2298            None => (None, Vec::new()),
2299        };
2300        let delivered_catalog = BundleCatalog::new(delivered_bundles).map_err(|error| {
2301            NodeServerError::DeliveryStore(format!("bundle catalog: {error}"))
2302        })?;
2303        let (context_pack_store, durable_context_packs) = match config.state_path.as_ref() {
2304            Some(state_path) => {
2305                let context_pack_root = context_pack_store_root_for_state_path(state_path)
2306                    .ok_or(NodeServerError::ContextPackStore)?;
2307                let (store, packs) = ContextPackStore::open(context_pack_root)
2308                    .map_err(|_| NodeServerError::ContextPackStore)?;
2309                (Some(store), packs)
2310            }
2311            None => (None, Vec::new()),
2312        };
2313        let mut durable_context_catalog = ContextPackCatalog::default();
2314        for pack in durable_context_packs {
2315            durable_context_catalog
2316                .insert(pack)
2317                .map_err(|_| NodeServerError::ContextPackStore)?;
2318        }
2319        let session_environment_materializer = config
2320            .session_environment
2321            .as_ref()
2322            .map(|environment| {
2323                SessionEnvironmentMaterializer::new(
2324                    environment.root.clone(),
2325                    Arc::clone(&environment.resolver),
2326                )
2327                .map_err(|_| NodeServerError::SessionEnvironmentMaterializer)
2328            })
2329            .transpose()?;
2330        // Session launchers go through the unprivileged `exec-as` wrapper when run-as is set
2331        // (the provider version probe above already used the original catalog).
2332        #[cfg(unix)]
2333        let catalog = crate::isolation::wrap_registry(catalog).map_err(NodeServerError::Registry)?;
2334        let native_session_catalog = config.history.clone().map(|history| {
2335            Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)))
2336        });
2337        let (handle, runtime) = match config.history.clone() {
2338            Some(history) => NativeRuntime::new_with_history(catalog, config.runtime, history),
2339            None => NativeRuntime::new(catalog, config.runtime),
2340        };
2341        let native_launch_profile_control = runtime.native_launch_profile_control();
2342        let harness_mcp_registry = config
2343            .harness_mcp_helper
2344            .clone()
2345            .map(HarnessMcpProxyRegistry::new);
2346        let events = handle.subscribe(CONTROL_EVENT_SUBSCRIPTION_CAPACITY);
2347        let incarnation_id = random_incarnation_id()
2348            .map_err(NodeServerError::IncarnationIdentity)?;
2349        let mut loaded = session_registry::load(config.state_path.as_deref(), &config.node_id)
2350            .map_err(durable_state_load_error)?;
2351        let managed_worktrees = std::mem::take(&mut loaded.managed_worktrees);
2352        let managed_worktree_tombstones =
2353            std::mem::take(&mut loaded.managed_worktree_tombstones);
2354        let materializations = std::mem::take(&mut loaded.materializations);
2355        let managed_spawn_replays = std::mem::take(&mut loaded.managed_spawn_replays);
2356        let (workspaces, records, persistence_warning) =
2357            merge_durable_state(&config.workspaces, loaded)?;
2358        let mut shared = NodeShared::new_with_incarnation(
2359            handle,
2360            config.access_token.clone(),
2361            config.node_id.clone(),
2362            incarnation_id,
2363            workspaces,
2364            enabled_providers,
2365            provider_runtime_statuses,
2366            provider_runtime_monitor,
2367            provider_contracts,
2368            provider_adapter_contracts,
2369            config.spawn_profiles.clone(),
2370            Some(native_launch_profile_control),
2371            native_session_catalog,
2372            session_environment_materializer,
2373            config.state_path.clone(),
2374            records,
2375            managed_worktrees,
2376            managed_worktree_tombstones,
2377            materializations,
2378            persistence_warning,
2379            input_settle_timeout_ms,
2380        );
2381        shared.harness_mcp_registry = harness_mcp_registry.clone();
2382        shared.session_record_retention = config.session_record_retention;
2383        shared.network_allowlist_catalog =
2384            RwLock::new(config.network_allowlist_catalog.clone());
2385        shared.bundle_catalog = RwLock::new(delivered_catalog);
2386        shared.delivery_store = Mutex::new(delivery_store);
2387        shared.context_catalog = RwLock::new(durable_context_catalog);
2388        shared.context_pack_store = Mutex::new(context_pack_store);
2389        shared.managed_spawn_replays = Mutex::new(
2390            managed_spawn_replays
2391                .into_iter()
2392                .map(|record| (record.idempotency_key.clone(), record))
2393                .collect(),
2394        );
2395        let shared = Arc::new(shared);
2396        if let Some(registry) = harness_mcp_registry {
2397            let weak = Arc::downgrade(&shared);
2398            registry.set_event_sink(Arc::new(move |event| match weak.upgrade() {
2399                Some(shared) => shared.publish_transient(event),
2400                None => false,
2401            }));
2402        }
2403        if shared.persistence_error().is_none() {
2404            shared.persist_state().map_err(|error| {
2405                durable_state_server_error(error, DURABLE_STATE_COMMIT_FAILED_ERROR)
2406            })?;
2407        }
2408        Ok(Self {
2409            config,
2410            runtime,
2411            shared,
2412            events,
2413            state_path_lock,
2414        })
2415    }
2416
2417    pub fn shutdown_handle(&self) -> NodeShutdownHandle {
2418        NodeShutdownHandle {
2419            shared: Arc::clone(&self.shared),
2420        }
2421    }
2422
2423    #[cfg(feature = "fixture")]
2424    pub fn spawn_managed_worktree_v2_failure_probe_fixture(
2425        &self,
2426    ) -> SpawnManagedWorktreeV2FailureProbe {
2427        self.shared
2428            .fixture_spawn_managed_worktree_v2_failure_probe
2429            .clone()
2430    }
2431
2432    /// Installs one immutable host-local environment profile before the server runs.
2433    ///
2434    /// Native resolvers remain inside the runtime; the shared node registry keeps
2435    /// only public IDs, revisions, provider bindings, and opaque native profile IDs.
2436    pub fn install_environment_profile(
2437        &mut self,
2438        profile: NodeEnvironmentProfile,
2439    ) -> Result<(), NodeServerError> {
2440        if !self.shared.enabled_providers.contains(profile.provider()) {
2441            return Err(NodeServerError::EnvironmentProfileProviderUnavailable(
2442                profile.provider().clone(),
2443            ));
2444        }
2445        let (binding, native_profiles, materialization) = profile.into_parts();
2446        if materialization.is_some() && self.shared.session_environment_materializer.is_none() {
2447            return Err(NodeServerError::SessionEnvironmentMaterializerRequired);
2448        }
2449        let mut profiles = self
2450            .shared
2451            .environment_profiles
2452            .write()
2453            .unwrap_or_else(|poisoned| poisoned.into_inner());
2454        if profiles.contains_key(&binding.id) {
2455            return Err(NodeServerError::DuplicateEnvironmentProfile(
2456                binding.id.clone(),
2457            ));
2458        }
2459        if profiles.len() >= MAX_NODE_ENVIRONMENT_PROFILES {
2460            return Err(NodeServerError::EnvironmentProfileCapacity {
2461                max: MAX_NODE_ENVIRONMENT_PROFILES,
2462            });
2463        }
2464        for native_id in binding.native_profile_ids() {
2465            if profiles.values().any(|current| {
2466                current.native_profile_ids().any(|current_id| current_id == native_id)
2467            }) {
2468                return Err(NodeServerError::DuplicateNativeEnvironmentProfile(
2469                    native_id.to_string(),
2470                ));
2471            }
2472        }
2473
2474        let mut installed = Vec::with_capacity(native_profiles.len());
2475        for native_profile in native_profiles {
2476            let native_id = native_profile.id().clone();
2477            if let Err(error) = self.runtime.upsert_native_launch_profile(native_profile) {
2478                for installed_id in installed.into_iter().rev() {
2479                    let _ = self.runtime.remove_native_launch_profile(&installed_id);
2480                }
2481                return Err(NodeServerError::NativeEnvironmentProfile(error.to_string()));
2482            }
2483            installed.push(native_id);
2484        }
2485        if let Some(materialization) = materialization {
2486            self.shared
2487                .environment_materialization_profiles
2488                .write()
2489                .unwrap_or_else(|poisoned| poisoned.into_inner())
2490                .insert(binding.id.clone(), materialization);
2491        }
2492        profiles.insert(binding.id.clone(), binding);
2493        Ok(())
2494    }
2495
2496    /// Installs one immutable host-local bundle before the server runs.
2497    pub fn install_bundle(&mut self, bundle: NodeBundle) -> Result<(), NodeServerError> {
2498        if self.shared.session_environment_materializer.is_none() {
2499            return Err(NodeServerError::SessionEnvironmentMaterializerRequired);
2500        }
2501        let mut catalog = self
2502            .shared
2503            .bundle_catalog
2504            .write()
2505            .unwrap_or_else(|poisoned| poisoned.into_inner());
2506        catalog
2507            .insert_idempotent(bundle)
2508            .map_err(|error| NodeServerError::BundleCatalog(error.to_string()))?;
2509        Ok(())
2510    }
2511
2512    pub async fn run_until_ctrl_signal(self) -> Result<(), NodeServerError> {
2513        let shutdown = self.shutdown_handle();
2514        let run = self.run();
2515        tokio::pin!(run);
2516        tokio::select! {
2517            result = &mut run => result,
2518            signal = wait_for_ctrl_signal() => {
2519                signal?;
2520                let shutdown_result = shutdown.request_shutdown().await;
2521                let run_result = run.await;
2522                shutdown_result.and(run_result)
2523            }
2524        }
2525    }
2526
2527    pub async fn run(self) -> Result<(), NodeServerError> {
2528        let NodeServer {
2529            config,
2530            mut runtime,
2531            shared,
2532            events,
2533            state_path_lock: _state_path_lock,
2534        } = self;
2535        shared.reconcile_materializations();
2536        shared.reconcile_managed_worktrees().await;
2537        shared.retire_unavailable_session_records().await;
2538        shared.reconcile_context_pack_exports().await;
2539        {
2540            let restore = Arc::clone(&shared);
2541            tokio::spawn(async move {
2542                sleep(Duration::from_secs(3)).await;
2543                restore.restore_journalled_sessions().await;
2544                loop {
2545                    restore.refresh_session_journal();
2546                    if restore.shutdown.load(Ordering::Acquire) { break; }
2547                    sleep(Duration::from_secs(10)).await;
2548                }
2549            });
2550        }
2551        // Lifecycle hooks are retired (owner ruling 2026-09-25): the node
2552        // observes sessions through ACP where a provider has it; there is no
2553        // hook ingress.
2554        let endpoint = config.endpoint.clone();
2555        let call_home = config.call_home;
2556        let call_home_identity = config.call_home_identity.clone();
2557        let api_listen = config.api_listen;
2558        let bridge_listen = config.bridge_listen;
2559        let bridge_underlay = config.bridge_underlay;
2560        let bridge_auth = browser_bridge::BridgeAuth::from_optional(config.bridge_token)
2561            .map_err(|_| NodeServerError::InvalidBridgeToken)?;
2562        let accept_shared = Arc::clone(&shared);
2563        let api_shared = Arc::clone(&shared);
2564        let bridge_shared = Arc::clone(&shared);
2565        let shutdown_shared = Arc::clone(&shared);
2566        let shutdown_timeout = Duration::from_millis(
2567            config.runtime.provider_shutdown_timeout_ms.max(1),
2568        );
2569        let result = {
2570            let runtime_loop = drive_runtime_until_shutdown(
2571                &mut runtime,
2572                events,
2573                Arc::clone(&shared),
2574                shutdown_timeout,
2575            );
2576            let accept_loop = accept_connections(&endpoint, call_home, call_home_identity, accept_shared);
2577            let api_loop = http_api::run(api_listen, api_shared);
2578            let bridge_loop = browser_bridge::run(bridge_listen, bridge_auth, bridge_shared, bridge_underlay);
2579            tokio::pin!(runtime_loop);
2580            tokio::pin!(accept_loop);
2581            tokio::pin!(api_loop);
2582            tokio::pin!(bridge_loop);
2583            tokio::select! {
2584                runtime_result = &mut runtime_loop => {
2585                    let shutdown_result = shutdown_shared
2586                        .begin_shutdown()
2587                        .await
2588                        .map_err(NodeServerError::ShutdownDispatch);
2589                    let (accept_result, api_result, bridge_result) =
2590                        tokio::join!(&mut accept_loop, &mut api_loop, &mut bridge_loop);
2591                    match runtime_result {
2592                        Err(error) => Err(error),
2593                        Ok(()) => shutdown_result
2594                            .and(accept_result)
2595                            .and(api_result.map_err(NodeServerError::HttpApi))
2596                            .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2597                    }
2598                }
2599                accept_result = &mut accept_loop => {
2600                    let shutdown_result = shutdown_shared
2601                        .begin_shutdown()
2602                        .await
2603                        .map_err(NodeServerError::ShutdownDispatch);
2604                    let (runtime_result, api_result, bridge_result) =
2605                        tokio::join!(&mut runtime_loop, &mut api_loop, &mut bridge_loop);
2606                    match accept_result {
2607                        Err(error) => Err(error),
2608                        Ok(()) => shutdown_result
2609                            .and(runtime_result)
2610                            .and(api_result.map_err(NodeServerError::HttpApi))
2611                            .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2612                    }
2613                }
2614                api_result = &mut api_loop => {
2615                    let shutdown_result = shutdown_shared
2616                        .begin_shutdown()
2617                        .await
2618                        .map_err(NodeServerError::ShutdownDispatch);
2619                    let (runtime_result, accept_result, bridge_result) =
2620                        tokio::join!(&mut runtime_loop, &mut accept_loop, &mut bridge_loop);
2621                    match api_result {
2622                        Err(error) => Err(NodeServerError::HttpApi(error)),
2623                        Ok(()) => shutdown_result
2624                            .and(runtime_result)
2625                            .and(accept_result)
2626                            .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2627                    }
2628                }
2629                bridge_result = &mut bridge_loop => {
2630                    let shutdown_result = shutdown_shared
2631                        .begin_shutdown()
2632                        .await
2633                        .map_err(NodeServerError::ShutdownDispatch);
2634                    let (runtime_result, accept_result, api_result) =
2635                        tokio::join!(&mut runtime_loop, &mut accept_loop, &mut api_loop);
2636                    match bridge_result {
2637                        Err(error) => Err(NodeServerError::BridgeApi(error)),
2638                        Ok(()) => shutdown_result
2639                            .and(runtime_result)
2640                            .and(accept_result)
2641                            .and(api_result.map_err(NodeServerError::HttpApi)),
2642                    }
2643                }
2644            }
2645        };
2646        // Persistence gap fix: durable DeliveryStore / ContextPackStore hold
2647        // exclusive flock (Unix) / share-mode locks (Windows) inside NodeShared.
2648        // NodeShutdownHandle also clones that Arc, so without an explicit release
2649        // here a supervisor that keeps the shutdown handle cannot reopen the same
2650        // state path after run() returns (EAGAIN / Resource temporarily unavailable).
2651        // StatePathLock already drops with this stack frame (`_state_path_lock`).
2652        if let Ok(mut guard) = shared.delivery_store.lock() {
2653            *guard = None;
2654        }
2655        if let Ok(mut guard) = shared.context_pack_store.lock() {
2656            *guard = None;
2657        }
2658        result
2659    }
2660}
2661
2662async fn drive_runtime_until_shutdown(
2663    runtime: &mut NativeRuntime,
2664    events: EventSubscription,
2665    shared: Arc<NodeShared>,
2666    shutdown_timeout: Duration,
2667) -> Result<(), NodeServerError> {
2668    // Published once, never per iteration. The worker loops inside
2669    // `NativeRuntime` fold into this profile continuously; turning it into
2670    // percentiles is what costs, and that is `GET /metrics`'s bill to pay.
2671    let _ = shared
2672        .shell_efficiency_profile
2673        .set(runtime.shell_efficiency_profile());
2674    let mut shutdown_started = None;
2675    let mut last_retention_sweep = Instant::now();
2676    let mut last_provisional_reap = Instant::now();
2677    loop {
2678        // Every phase below is timed unconditionally -- see
2679        // `drive_loop_profile`'s own doc comment for why an always-on,
2680        // allocation-free ring is cheap enough to never need a feature
2681        // flag. This is the only place these numbers can be taken: the
2682        // phases are calls made directly from this loop, not something a
2683        // callee could time on our behalf.
2684        let runtime_tick_start = Instant::now();
2685        let tick_result = runtime.tick().await;
2686        let runtime_tick_elapsed = runtime_tick_start.elapsed();
2687
2688        let event_drain_start = Instant::now();
2689        // Every command the kernel refused, named. `NativeRuntimeTick`
2690        // has carried these outcomes all along and nothing read them, so a
2691        // rejected command -- a hook event denied by runtime policy, a
2692        // stale generation, an unsupported capability -- vanished at the
2693        // last step of the loop that produced it. A refusal the operator
2694        // cannot see is indistinguishable from an event that never arrived,
2695        // and telling those two apart is most of debugging this stack.
2696        for outcome in &tick_result.command_outcomes {
2697            if let Err(error) = &outcome.result {
2698                shared.rejected_commands_total.fetch_add(1, Ordering::Relaxed);
2699                tracing::warn!(
2700                    command_id = outcome.command_id.0,
2701                    cause = %error,
2702                    "control command rejected"
2703                );
2704                shared.record_rejected_command(
2705                    outcome.command_id,
2706                    error.to_string(),
2707                    error.is_unsupported_transport(),
2708                );
2709            }
2710        }
2711        let mut events_drained: u64 = 0;
2712        while let Ok(event) = events.try_recv() {
2713            events_drained += 1;
2714            let clean_exit = matches!(
2715                event.event,
2716                ControlEventKind::Exited { exit_code: Some(0), forced: false },
2717            );
2718            let instance_id = event.instance_id;
2719            let generation = event.generation;
2720            // Resolve and spawn before publish_control: publish_control's own
2721            // managed-record bookkeeping (reconcile_managed_record) downgrades
2722            // this exact record from Live to Dormant and clears
2723            // active_session for every Exited event, inside the same
2724            // synchronous call. Capturing (record_id, session) here — before
2725            // that runs — is the only way to observe them still Live and
2726            // still populated; the export itself must still run on its own
2727            // detached task (never awaited inline on this loop) because it
2728            // needs NativeRuntime::tick() to keep running to ever settle, and
2729            // tick() is only ever driven by this same loop. See
2730            // reconcile_context_pack_export_for_record's doc comment.
2731            if clean_exit {
2732                if let Some((record_id, session)) =
2733                    shared.managed_record_export_target_for_instance(instance_id, generation)
2734                {
2735                    let export_shared = Arc::clone(&shared);
2736                    tokio::spawn(async move {
2737                        export_shared
2738                            .reconcile_context_pack_export_for_record(&record_id, session)
2739                            .await;
2740                    });
2741                }
2742            }
2743            shared.publish_control(event);
2744        }
2745        let event_drain_elapsed = event_drain_start.elapsed();
2746
2747        let publish_terminal_frames_start = Instant::now();
2748        shared.publish_terminal_frames();
2749        shared.run_due_provider_retries();
2750        let publish_terminal_frames_elapsed = publish_terminal_frames_start.elapsed();
2751
2752        // Physical PTY count and the runtime's own per-phase tick timings
2753        // both live inside `NativeRuntime`, which `GET /metrics` (reading
2754        // only `NodeShared`) cannot reach directly -- this loop is the one
2755        // place holding `&mut NativeRuntime`, so it is the only place that
2756        // can copy either out.
2757        shared
2758            .native_session_gauge
2759            .store(runtime.active_native_sessions(), Ordering::Relaxed);
2760        *shared
2761            .runtime_tick_profile
2762            .lock()
2763            .unwrap_or_else(|poisoned| poisoned.into_inner()) = runtime.tick_profile_snapshot();
2764
2765        let remainder_start = Instant::now();
2766        if shared.shutdown.load(Ordering::Acquire) {
2767            let started = *shutdown_started.get_or_insert_with(Instant::now);
2768            let snapshot = shared.handle.snapshot();
2769            let sessions_settled = snapshot.sessions.iter().all(|session| {
2770                !matches!(
2771                    session.status,
2772                    gate4agent_types::SessionStatus::Starting
2773                        | gate4agent_types::SessionStatus::Running
2774                        | gate4agent_types::SessionStatus::Stopping
2775                )
2776            });
2777            if runtime.active_native_sessions() == 0 && sessions_settled {
2778                return Ok(());
2779            }
2780            if started.elapsed() >= shutdown_timeout {
2781                return Err(NodeServerError::ShutdownTimedOut {
2782                    active_native_sessions: runtime.active_native_sessions(),
2783                });
2784            }
2785        }
2786        let remainder_elapsed = remainder_start.elapsed();
2787
2788        {
2789            let mut profile = shared
2790                .drive_loop_profile
2791                .lock()
2792                .unwrap_or_else(|poisoned| poisoned.into_inner());
2793            profile.record_runtime_tick(runtime_tick_elapsed);
2794            profile.record_event_drain(event_drain_elapsed);
2795            profile.record_publish_terminal_frames(publish_terminal_frames_elapsed);
2796            profile.record_remainder(remainder_elapsed);
2797            profile.note_iteration(Instant::now());
2798        }
2799
2800        // "Produced nothing" means this iteration's `NativeRuntime::tick`
2801        // applied zero observations (`tick_result.observations_applied`,
2802        // which already folds in any terminal frames collected -- see
2803        // `NativeEffectDispatcher::drain_observations`) AND the
2804        // control-event subscription drained above forwarded zero events.
2805        // Command ingress and dispatched effects are deliberately NOT part
2806        // of this definition: an idle node still runs `step_control_plane`
2807        // and every provider supervisor's `tick` regardless, every 10ms --
2808        // so "nothing" here means nothing OBSERVABLE left this iteration,
2809        // matching what a reader would assume from `drive_loop_iterations`'s
2810        // own field name, not that the tick itself was free of work.
2811        shared.drive_loop_iterations_total.fetch_add(1, Ordering::Relaxed);
2812        if tick_result.observations_applied == 0 && events_drained == 0 {
2813            shared.drive_loop_iterations_idle.fetch_add(1, Ordering::Relaxed);
2814        }
2815
2816        // A sweep cadence, not a policy value -- see
2817        // `SESSION_RECORD_RETENTION_SWEEP_PERIOD`'s own doc comment. The
2818        // policy itself (whether this does anything at all) lives entirely
2819        // in `NodeShared::session_record_retention`.
2820        if last_retention_sweep.elapsed() >= SESSION_RECORD_RETENTION_SWEEP_PERIOD {
2821            shared.retire_unavailable_session_records().await;
2822            last_retention_sweep = Instant::now();
2823        }
2824        // Provisional Spawn grace: kill PTYs that never got an HQ attach.
2825        if last_provisional_reap.elapsed() >= Duration::from_secs(2) {
2826            for address in shared.take_due_provisional_spawns() {
2827                shared.reap_provisional_spawn(address).await;
2828            }
2829            last_provisional_reap = Instant::now();
2830        }
2831
2832        // Fast-poll for 300ms after PTY input so its echo is read and published
2833        // within ~1ms; otherwise nap 10ms but wake at once on new input.
2834        let hot = shared
2835            .pty_input_marks
2836            .lock()
2837            .unwrap_or_else(|poisoned| poisoned.into_inner())
2838            .values()
2839            .any(|(at, _)| at.elapsed() < Duration::from_millis(300));
2840        if hot {
2841            sleep(Duration::from_millis(1)).await;
2842        } else {
2843            tokio::select! {
2844                _ = sleep(Duration::from_millis(10)) => {}
2845                _ = shared.pty_input_wake.notified() => {}
2846            }
2847        }
2848    }
2849}
2850
2851#[derive(Clone)]
2852pub struct NodeShutdownHandle {
2853    shared: Arc<NodeShared>,
2854}
2855
2856impl NodeShutdownHandle {
2857    pub async fn request_shutdown(&self) -> Result<(), NodeServerError> {
2858        self.shared
2859            .begin_shutdown()
2860            .await
2861            .map_err(NodeServerError::ShutdownDispatch)
2862    }
2863}
2864
2865async fn wait_for_ctrl_signal() -> Result<(), NodeServerError> {
2866    #[cfg(windows)]
2867    {
2868    let mut ctrl_c = tokio::signal::windows::ctrl_c()?;
2869    let mut ctrl_break = tokio::signal::windows::ctrl_break()?;
2870    return tokio::select! {
2871        signal = ctrl_c.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2872        signal = ctrl_break.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2873    };
2874    }
2875    #[cfg(unix)]
2876    {
2877        let mut interrupt = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())?;
2878        let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
2879        tokio::select! {
2880            signal = interrupt.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2881            signal = terminate.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2882        }
2883    }
2884}
2885
2886/// The providers this node actually runs.
2887///
2888/// `builtin_registry()` is a reference catalog of everything the project has
2889/// ever described; the fleet is this list, and it is deliberately short. A
2890/// provider only belongs here if it can be exercised for real -- there is a
2891/// subscription behind it and a failure in it can be reproduced and
2892/// debugged. Carrying one that cannot is carrying code nobody can verify,
2893/// which is worse than not supporting it: it looks supported.
2894///
2895/// A provider dropped from the fleet by this rule is removed outright, not
2896/// carried dark: no catalog entry, no adapter, no dead code claiming support
2897/// for something nobody can verify runs.
2898/// Host interactive shell for HQ terminal attach (no provider CLI, no network).
2899fn shell_host_spec() -> AgentSpec {
2900    // Windows nodes (incl. MSYS-hosted) have no `/bin/bash` for Win32 Path::is_file;
2901    // fleet inventory on op-box resolves `bash` to the WSL stub at
2902    // `/c/Windows/system32/bash`, which is not a host shell. Use ComSpec/cmd.
2903    #[cfg(windows)]
2904    let (detect_command, program, fixed_args, process_name) = {
2905        let program = std::env::var("ComSpec").unwrap_or_else(|_| {
2906            r"C:\Windows\System32\cmd.exe".to_owned()
2907        });
2908        ("cmd".to_owned(), program, Vec::<String>::new(), "cmd".to_owned())
2909    };
2910    #[cfg(not(windows))]
2911    let (detect_command, program, fixed_args, process_name) = (
2912        "bash".to_owned(),
2913        "/bin/bash".to_owned(),
2914        vec!["-i".to_owned()],
2915        "bash".to_owned(),
2916    );
2917    AgentSpec {
2918        id: AgentId::new("shell").expect("shell agent id"),
2919        revision: "hatchery:shell-pty-1".to_owned(),
2920        display_name: "Host shell".to_owned(),
2921        detection: DetectionSpec {
2922            command: detect_command,
2923            aliases: vec!["sh".to_owned(), "shell".to_owned(), "cmd".to_owned()],
2924            required_commands: Vec::new(),
2925            unsupported_platforms: Vec::new(),
2926        },
2927        launch: LaunchSpec {
2928            program,
2929            fixed_args,
2930        },
2931        expected_processes: vec![ProcessMatcher::Exact {
2932            name: process_name,
2933        }],
2934        prompt: PromptSpec {
2935            initial: InitialPromptMode::None,
2936            native_draft: None,
2937        },
2938        readiness: AgentReadinessSpec::default(),
2939        capabilities: AgentCapabilities {
2940            agent_commands: None,
2941            transports: AgentTransportCapabilities {
2942                pty: true,
2943                pty_adapter: None,
2944                pipe: None,
2945                acp: None,
2946            },
2947            adapters: AgentAdapterCapabilities::default(),
2948        },
2949        verification: SpecVerification::Gate4AgentVerified,
2950    }
2951}
2952
2953fn active_registry() -> Result<AgentRegistry, NodeServerError> {
2954    let mut specs = vec![shell_host_spec()];
2955    specs.extend(
2956        builtin_registry()
2957            .iter()
2958            .filter(|spec| matches!(spec.id.as_str(), "claude" | "codex" | "grok" | "kimi"))
2959            .cloned(),
2960    );
2961    AgentRegistry::new(specs).map_err(|error| NodeServerError::Registry(error.to_string()))
2962}
2963
2964fn provider_contract_manifest(
2965    catalog: &AgentRegistry,
2966) -> Result<
2967    (
2968        Vec<ProviderContractSupport>,
2969        Vec<ProviderAdapterContractSupport>,
2970    ),
2971    NodeServerError,
2972> {
2973    let mut provider_contracts = Vec::new();
2974    let mut provider_adapter_contracts = Vec::new();
2975    for spec in catalog.iter() {
2976        let provider = spec.id.clone();
2977        let revision = ProviderContractRevision::new(spec.revision.clone())
2978            .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2979        provider_contracts.push(ProviderContractSupport {
2980            provider: provider.clone(),
2981            revision,
2982        });
2983        for (family, binding) in declared_adapter_bindings(spec)? {
2984            let revision = AdapterContractRevision::new(binding.revision.clone())
2985                .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2986            provider_adapter_contracts.push(ProviderAdapterContractSupport {
2987                provider: provider.clone(),
2988                family,
2989                adapter_id: binding.id.clone(),
2990                revision,
2991            });
2992        }
2993    }
2994    validate_provider_contract_manifest(&provider_contracts, &provider_adapter_contracts)
2995        .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2996    Ok((provider_contracts, provider_adapter_contracts))
2997}
2998
2999fn declared_adapter_bindings(
3000    spec: &AgentSpec,
3001) -> Result<Vec<(AdapterFamily, &AdapterBinding)>, NodeServerError> {
3002    let mut bindings = Vec::new();
3003    push_declared_binding(
3004        &mut bindings,
3005        AdapterFamily::PtySemantic,
3006        spec.capabilities.transports.pty_adapter.as_ref(),
3007    )?;
3008    push_declared_binding(
3009        &mut bindings,
3010        AdapterFamily::Pipe,
3011        spec.capabilities.adapters.pty_sidecar.as_ref(),
3012    )?;
3013    let pipe_transport = spec.capabilities.transports.pipe.as_ref();
3014    let pipe_family = pipe_transport.map(|transport| match transport.protocol {
3015        gate4agent_types::PipeProtocol::SemanticNdjson
3016        | gate4agent_types::PipeProtocol::StructuredJsonl => AdapterFamily::Pipe,
3017        gate4agent_types::PipeProtocol::OneShotText => AdapterFamily::OneShot,
3018    });
3019    push_declared_binding(
3020        &mut bindings,
3021        AdapterFamily::Pipe,
3022        pipe_transport
3023            .filter(|_| pipe_family == Some(AdapterFamily::Pipe))
3024            .map(|transport| &transport.adapter),
3025    )?;
3026    push_declared_binding(
3027        &mut bindings,
3028        AdapterFamily::Acp,
3029        spec.capabilities
3030            .transports
3031            .acp
3032            .as_ref()
3033            .map(|transport| &transport.adapter),
3034    )?;
3035    push_declared_binding(
3036        &mut bindings,
3037        AdapterFamily::Hook,
3038        spec.capabilities.adapters.hook.as_ref(),
3039    )?;
3040    push_declared_binding(
3041        &mut bindings,
3042        AdapterFamily::ManagedHook,
3043        spec.capabilities.adapters.managed_hook.as_ref(),
3044    )?;
3045    push_declared_binding(
3046        &mut bindings,
3047        AdapterFamily::OneShot,
3048        pipe_transport
3049            .filter(|_| pipe_family == Some(AdapterFamily::OneShot))
3050            .map(|transport| &transport.adapter),
3051    )?;
3052    push_declared_binding(
3053        &mut bindings,
3054        AdapterFamily::OneShot,
3055        spec.capabilities.adapters.one_shot.as_ref(),
3056    )?;
3057    push_declared_binding(
3058        &mut bindings,
3059        AdapterFamily::History,
3060        spec.capabilities.adapters.history.as_ref(),
3061    )?;
3062    push_declared_binding(
3063        &mut bindings,
3064        AdapterFamily::Resume,
3065        spec.capabilities.adapters.resume.as_ref(),
3066    )?;
3067    push_declared_binding(
3068        &mut bindings,
3069        AdapterFamily::SessionOptions,
3070        spec.capabilities.adapters.session_options.as_ref(),
3071    )?;
3072    push_declared_binding(
3073        &mut bindings,
3074        AdapterFamily::CapabilityProbe,
3075        spec.capabilities.adapters.capability_probe.as_ref(),
3076    )?;
3077    Ok(bindings)
3078}
3079
3080fn push_declared_binding<'a>(
3081    bindings: &mut Vec<(AdapterFamily, &'a AdapterBinding)>,
3082    family: AdapterFamily,
3083    binding: Option<&'a AdapterBinding>,
3084) -> Result<(), NodeServerError> {
3085    let Some(binding) = binding else {
3086        return Ok(());
3087    };
3088    if let Some((_, existing)) = bindings
3089        .iter()
3090        .find(|(existing_family, _)| *existing_family == family)
3091    {
3092        if *existing == binding {
3093            return Ok(());
3094        }
3095        return Err(NodeServerError::ProviderContractManifest(format!(
3096            "provider declares conflicting {family:?} adapter bindings"
3097        )));
3098    }
3099    bindings.push((family, binding));
3100    Ok(())
3101}
3102
3103fn merge_durable_state(
3104    configured: &[WorkspaceConfig],
3105    mut loaded: LoadedNodeState,
3106) -> Result<(Vec<WorkspaceConfig>, Vec<ManagedSessionRecord>, Option<String>), NodeServerError> {
3107    let mut workspaces = configured
3108        .iter()
3109        .cloned()
3110        .map(|workspace| (workspace.workspace_id.clone(), workspace))
3111        .collect::<BTreeMap<_, _>>();
3112    let mut roots = workspaces
3113        .values()
3114        .map(|workspace| {
3115            (
3116                platform::root_identity(&workspace.canonical_root),
3117                workspace.workspace_id.clone(),
3118            )
3119        })
3120        .collect::<BTreeMap<_, _>>();
3121    let mut warning = loaded
3122        .warning
3123        .take()
3124        .map(|message| session_registry::sanitized_persistence_summary(&message));
3125
3126    for (workspace_id, canonical_root) in loaded.workspaces {
3127        if let Some(existing) = workspaces.get(&workspace_id) {
3128            if !platform::roots_equal(&existing.canonical_root, &canonical_root) {
3129                return Err(NodeServerError::DurableState(io::Error::new(
3130                    io::ErrorKind::InvalidData,
3131                    DURABLE_STATE_CONFLICT_ERROR,
3132                )));
3133            }
3134            continue;
3135        }
3136        if roots.get(&platform::root_identity(&canonical_root)).is_some() {
3137            return Err(NodeServerError::DurableState(io::Error::new(
3138                io::ErrorKind::InvalidData,
3139                DURABLE_STATE_CONFLICT_ERROR,
3140            )));
3141        }
3142        if !Path::new(&canonical_root).is_dir() {
3143            warning.get_or_insert_with(|| {
3144                session_registry::DURABLE_STATE_WORKSPACE_WARNING.to_owned()
3145            });
3146            continue;
3147        }
3148        let workspace = WorkspaceConfig::new(workspace_id.clone(), &canonical_root).map_err(|_| {
3149            NodeServerError::DurableState(io::Error::new(
3150                io::ErrorKind::InvalidData,
3151                DURABLE_STATE_CONFLICT_ERROR,
3152            ))
3153        })?;
3154        roots.insert(
3155            platform::root_identity(&workspace.canonical_root),
3156            workspace.workspace_id.clone(),
3157        );
3158        workspaces.insert(workspace_id, workspace);
3159    }
3160
3161    for record in &mut loaded.records {
3162        match workspaces.get(&record.workspace_id) {
3163            Some(workspace)
3164                if platform::roots_equal(
3165                    &workspace.canonical_root,
3166                    windows_path_text(&record.canonical_root),
3167                ) => {}
3168            Some(_) => {
3169                return Err(NodeServerError::DurableState(io::Error::new(
3170                    io::ErrorKind::InvalidData,
3171                    DURABLE_STATE_CONFLICT_ERROR,
3172                )));
3173            }
3174            None => {
3175                record.state = ManagedSessionState::Unavailable;
3176                record.active_session = None;
3177                record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
3178            }
3179        }
3180    }
3181    Ok((workspaces.into_values().collect(), loaded.records, warning))
3182}
3183
3184/// One entry of `NodeShared::rejected_command_reasons` -- see that field's
3185/// own doc for the mechanism this exists to close.
3186struct RejectedCommandReason {
3187    message: String,
3188    /// True when the kernel refused this command specifically because the
3189    /// agent does not declare the requested transport (`KernelCommandError::
3190    /// UnsupportedTransport`) -- lets the reader choose the named
3191    /// `NodeFailureCode::UnsupportedTransport` over the generic
3192    /// `BackendOperationFailed` bucket, so this one well-known rejection
3193    /// keeps its identity all the way to the operator wire.
3194    unsupported_transport: bool,
3195    expires_at: Instant,
3196}
3197
3198struct NodeShared {
3199    handle: Gate4AgentHandle,
3200    access_token: String,
3201    node_id: NodeId,
3202    incarnation_id: NodeIncarnationId,
3203    started_at_unix_ms: u64,
3204    workspaces: RwLock<BTreeMap<WorkspaceId, String>>,
3205    worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode>,
3206    managed_worktree_profiles:
3207        BTreeMap<WorkspaceId, BTreeMap<WorktreeProfileId, ManagedWorktreeProfile>>,
3208    managed_worktrees: Mutex<ManagedWorktreeRegistry>,
3209    enabled_providers: Vec<AgentId>,
3210    provider_runtime_statuses: ProviderRuntimeStatuses,
3211    provider_runtime_status_updates:
3212        RwLock<BTreeMap<AgentId, crate::protocol::ProviderRuntimeStatus>>,
3213    provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
3214    provider_contracts: Vec<ProviderContractSupport>,
3215    provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
3216    spawn_profiles: SpawnProfileRegistry,
3217    environment_profiles:
3218        RwLock<BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>>,
3219    environment_materialization_profiles:
3220        RwLock<BTreeMap<SpawnEnvironmentProfileId, NodeSessionMaterializationProfile>>,
3221    /// Station **network** allowlist catalog (opaque policy ids only).
3222    /// Empty by default — any `network_allowlist` on spawn must be registered
3223    /// here or resolve refuses with `UnknownNetworkAllowlist`. Optional
3224    /// startup load via `NodeServerConfig::with_network_allowlist_catalog(_file)`
3225    /// / env `GATE4AGENT_NODE_NETWORK_ALLOWLIST_CATALOG`. Plan
3226    /// `station-network-and-browser-profile-knobs-2026-10-02.md` §2.1 / §4.
3227    /// Never holds cookies / OAuth / proxy credentials.
3228    network_allowlist_catalog: RwLock<crate::network_allowlist_catalog::NetworkAllowlistCatalog>,
3229    /// Node-local exclusive dig2browser station profile leases (feature
3230    /// `dig2-station-probe`). Opaque `browser_profile_id` ↔ session holder;
3231    /// cleared on session end. Never ImportSession / cookies on C2.
3232    #[cfg(feature = "dig2-station-probe")]
3233    browser_station_leases: crate::dig2_station_lease::BrowserStationLeaseTable,
3234    bundle_catalog: RwLock<BundleCatalog>,
3235    delivery_store: Mutex<Option<DeliveryStore>>,
3236    context_pack_store: Mutex<Option<ContextPackStore>>,
3237    context_catalog: RwLock<ContextPackCatalog>,
3238    native_launch_profile_control: Option<NativeLaunchProfileControl>,
3239    harness_mcp_registry: Option<HarnessMcpProxyRegistry>,
3240    native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
3241    session_environment_materializer: Option<SessionEnvironmentMaterializer>,
3242    /// Per-session m4a accounts assigned at spawn (see [`crate::m4a_accounts`]).
3243    m4a_accounts: Option<crate::m4a_accounts::M4aSessionAccounts>,
3244    /// nick -> the session that holds that m4a account (for letter wakes).
3245    m4a_live: Mutex<BTreeMap<String, (AgentInstanceId, AgentId)>>,
3246    /// m4a session key per runtime this node gave an account.
3247    m4a_keys: Mutex<BTreeMap<AgentInstanceId, String>>,
3248    /// Keys a resume hands to its new runtime (same nick and mailbox).
3249    m4a_reuse_keys: Mutex<BTreeMap<AgentInstanceId, String>>,
3250    /// Per-session grok leader sockets (never the operator's shared leader).
3251    grok_leaders: Mutex<BTreeMap<AgentInstanceId, std::path::PathBuf>>,
3252    /// m4a signer proxy (option A): when set, PTY sessions get a process-bound handle
3253    /// instead of a bearer capability.
3254    #[cfg(unix)]
3255    signer_proxy: std::sync::OnceLock<Arc<crate::keystore_proxy::SignerProxy>>,
3256    materializations: Mutex<BTreeMap<MaterializationId, MaterializationOwnershipRecord>>,
3257    spawn_idempotency: Mutex<SpawnIdempotencyCache>,
3258    managed_spawn_replays:
3259        Mutex<BTreeMap<SpawnIdempotencyKey, ManagedWorktreeSpawnReplayRecordV10>>,
3260    controller: Mutex<Option<ControllerLease>>,
3261    /// Fresh spawns waiting for their first successful HQ attach. Removed on
3262    /// Resize/TerminalBytes (sessionize) or when the grace reaper stops them.
3263    provisional_spawns: Mutex<BTreeMap<AgentInstanceId, (SessionAddress, Instant)>>,
3264    /// ACP launch options waiting for the agent to advertise its config options.
3265    pending_acp_options: Mutex<BTreeMap<AgentInstanceId, (Option<String>, Option<String>)>>,
3266    history: Mutex<NodeEventHistory>,
3267    event_tx: broadcast::Sender<NodeEventEnvelope>,
3268    terminal_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3269    /// The outbound `NodeEvent::AgentStream` push channel -- its own
3270    /// dedicated, capability-gated broadcast, mirroring `terminal_event_tx`
3271    /// exactly rather than riding the durable `event_tx` history (see
3272    /// `publish`'s own assertion): a subscriber who never negotiated
3273    /// `NODE_AGENT_STREAM_EVENTS_CAPABILITY` must never receive one of
3274    /// these, the same guarantee `include_terminal_frame_events` already
3275    /// gives terminal frames.
3276    agent_stream_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3277    /// The outbound `NodeEvent::HarnessMcpReadCall` push channel -- mirrors
3278    /// `agent_stream_event_tx` exactly, its own dedicated, capability-gated
3279    /// broadcast rather than a ride on the durable `event_tx` history. A
3280    /// `HarnessMcpReadCall` is a request/response call a live proxy call is
3281    /// waiting on (`harness_mcp_proxy.rs`'s `begin_call`); the shared bus's
3282    /// finite capacity and its willingness to silently evict a lagging
3283    /// subscriber's backlog is exactly how the call vanished with neither
3284    /// side ever logging it (see `publish_transient`'s own doc).
3285    harness_mcp_proxy_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3286    terminal_frame_watermarks:
3287        Mutex<BTreeMap<AgentInstanceId, (SessionAddress, u64)>>,
3288    /// Wakes the drive loop as soon as PTY input is queued (no 10ms nap).
3289    pty_input_wake: tokio::sync::Notify,
3290    /// Per session: when the last PTY input was dispatched (latency probe +
3291    /// a short fast-poll window so the echo is published within ~1ms).
3292    pty_input_marks: Mutex<BTreeMap<AgentInstanceId, (Instant, bool)>>,
3293    /// Provider error classifier + retry state per session (see `provider_error`).
3294    provider_errors: Mutex<crate::provider_error::Tracker>,
3295    next_connection_id: AtomicU64,
3296    next_instance_id: AtomicU64,
3297    next_command_id: AtomicU64,
3298    shutdown: AtomicBool,
3299    shutdown_notify: Notify,
3300    preauth_slots: Arc<Semaphore>,
3301    authenticated_slots: Arc<Semaphore>,
3302    inspection_slots: Arc<Semaphore>,
3303    mutation_gate: AsyncMutex<()>,
3304    session_bindings: Mutex<BTreeMap<AgentInstanceId, SessionBinding>>,
3305    session_records: Mutex<SessionRecords>,
3306    state_path: Option<PathBuf>,
3307    persistence_error: RwLock<Option<String>>,
3308    state_transaction: Mutex<()>,
3309    input_settle_timeout_ms: u64,
3310    /// Drive-loop phase/rate timings -- see `drive_loop_profile`'s own doc
3311    /// comment. Written once per iteration by
3312    /// `drive_runtime_until_shutdown`, read by `GET /metrics`.
3313    drive_loop_profile: Mutex<DriveLoopProfiler>,
3314    /// Latest copy of `NativeRuntime::tick_profile_snapshot()` -- `Copy`,
3315    /// so an overwrite each iteration is cheap. The runtime itself is not
3316    /// reachable from here (it is owned locally by
3317    /// `drive_runtime_until_shutdown`), so this is how its own per-phase
3318    /// tick timings reach `GET /metrics`.
3319    runtime_tick_profile: Mutex<TickProfileSnapshot>,
3320    /// The live shell-efficiency profile, shared with the per-instance
3321    /// worker loops that fold into it.
3322    ///
3323    /// Deliberately NOT the per-iteration snapshot copy `runtime_tick_
3324    /// profile` above keeps: producing a snapshot sorts every ring behind
3325    /// it, and that sort belongs on the read. A `OnceLock` rather than a
3326    /// field set at construction because `NodeShared` is built before the
3327    /// `NativeRuntime` that owns the profile exists -- the drive loop
3328    /// publishes it once, before its first iteration. Unset means the
3329    /// runtime has not started driving yet, which `GET /metrics` reports as
3330    /// empty series rather than as zeroes it never measured.
3331    shell_efficiency_profile: OnceLock<Arc<Mutex<ShellEfficiencyProfile>>>,
3332    /// Physical PTY sessions the native runtime currently owns
3333    /// (`NativeRuntime::active_native_sessions`), refreshed once per drive
3334    /// loop iteration -- deliberately a separate count from
3335    /// `handle.snapshot().sessions.len()` (the control-plane kernel's own
3336    /// session bookkeeping), since the two can disagree and the gap is
3337    /// itself a diagnostic signal `GET /metrics` exposes.
3338    native_session_gauge: AtomicUsize,
3339    /// Lifetime totals for the per-connection serve loop, summed across
3340    /// every live connection.
3341    ///
3342    /// The drive loop's own phase timings account for about ten
3343    /// microseconds an iteration on an idle node, yet the process burns a
3344    /// quarter of a core — and burns it ONLY while a c2 is attached (0.5%
3345    /// with none, 23% with two, measured). That gap is somewhere in
3346    /// serving a connection, and there was no way to see it: iterations
3347    /// says whether the loop is spinning rather than sleeping, and events
3348    /// says whether it is spinning over real work or over nothing.
3349    connection_loop_iterations: AtomicU64,
3350    connection_events_sent: AtomicU64,
3351    /// Lifetime totals for `drive_runtime_until_shutdown`'s own iterations
3352    /// -- `drive_loop_iterations_idle` counts iterations that applied zero
3353    /// `NativeRuntime::tick` observations AND forwarded zero control
3354    /// events; see the exact "produced nothing" definition at the site in
3355    /// `drive_runtime_until_shutdown` that increments these. Read twice
3356    /// and divide by the interval, same rule as `connections` above --
3357    /// `idle / total` is backlog item 5's "wake on work" number.
3358    /// Lifetime count of control commands the kernel refused.
3359    rejected_commands_total: AtomicU64,
3360    /// The named reason behind a recent kernel rejection, by the
3361    /// `CommandId` `dispatch_bounded` handed back when it enqueued that
3362    /// command -- populated by `drive_runtime_until_shutdown` for every
3363    /// `Err` in a tick's `command_outcomes`, and drained by whichever
3364    /// spawn-dispatch waiter recognizes its own `Register`/`Start`
3365    /// `CommandId` among them.
3366    ///
3367    /// A `ControlCommand` only ever fails synchronously (a `NodeFailure`
3368    /// out of `dispatch`/`dispatch_bounded` itself) for a port-level ingress
3369    /// problem -- full or disconnected -- never for the kernel refusing the
3370    /// command's content (an unsupported transport, an unknown agent, ...);
3371    /// that verdict exists only once `NativeRuntime::tick` has processed the
3372    /// command, one drive-loop iteration later, in a different task than
3373    /// whichever caller is awaiting the spawn. Without this map, a spawn
3374    /// wait loop that polls `self.handle.snapshot()` for a session that
3375    /// will now never appear has no way to learn why -- it can only spin
3376    /// until its own commit deadline and report a generic "did not commit"
3377    /// timeout, discarding a reason the kernel already named precisely (see
3378    /// `KernelCommandError`'s variants, e.g. `UnsupportedTransport`).
3379    rejected_command_reasons: Mutex<BTreeMap<CommandId, RejectedCommandReason>>,
3380    drive_loop_iterations_total: AtomicU64,
3381    drive_loop_iterations_idle: AtomicU64,
3382    /// The operator-chosen age/keep-N policy the retention sweep applies to
3383    /// `ManagedSessionState::Unavailable` records. Both `0` by default --
3384    /// see `SessionRecordRetentionConfig`'s own doc comment.
3385    session_record_retention: SessionRecordRetentionConfig,
3386    /// Lifetime count of managed session records the retention sweep has
3387    /// retired. Same tier as `rejected_commands_total` above: a live,
3388    /// process-lifetime instrument, never persisted and never reloaded from
3389    /// `state-v1.json` -- a restart legitimately resets it, matching every
3390    /// other `*_total` counter here.
3391    retired_records_total: AtomicU64,
3392    #[cfg(feature = "fixture")]
3393    fixture_semantic_hook_policy: bool,
3394    #[cfg(feature = "fixture")]
3395    fixture_spawn_managed_worktree_v2_failure_probe: SpawnManagedWorktreeV2FailureProbe,
3396}
3397
3398impl Drop for NodeShared {
3399    fn drop(&mut self) {
3400        SessionLifecycleHook::on_shutdown(self);
3401    }
3402}
3403
3404#[derive(Clone, Debug, Eq, PartialEq)]
3405struct SessionBinding {
3406    workspace_id: WorkspaceId,
3407    generation: SessionGeneration,
3408    runtime_policy: ProviderRuntimePolicy,
3409    pending_resume: Option<(SessionGeneration, CommandId, ProviderRuntimePolicy)>,
3410    record_id: Option<SessionRecordId>,
3411    managed_worktree_lease_id: Option<ManagedWorktreeLeaseId>,
3412    environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
3413    bundle: Option<ResolvedBundleReceipt>,
3414    context: Option<ResolvedContextPackReceipt>,
3415    materialization_id: Option<MaterializationId>,
3416}
3417
3418#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3419enum SpawnRecordPolicy {
3420    ProviderIdentityOnly,
3421    Always,
3422}
3423
3424struct NativeEnvironmentSelectionGuard {
3425    control: Option<NativeLaunchProfileControl>,
3426    instance_id: AgentInstanceId,
3427}
3428
3429struct NativeInstanceOverlayGuard {
3430    control: Option<NativeLaunchProfileControl>,
3431    instance_id: AgentInstanceId,
3432}
3433
3434struct NativeHarnessMcpOverlayGuard {
3435    control: NativeLaunchProfileControl,
3436    instance_id: AgentInstanceId,
3437}
3438
3439impl NativeHarnessMcpOverlayGuard {
3440    fn retain(self) { std::mem::forget(self); }
3441}
3442
3443impl Drop for NativeHarnessMcpOverlayGuard {
3444    fn drop(&mut self) {
3445        self.control
3446            .clear_native_mcp_server_launch_overlay(self.instance_id);
3447    }
3448}
3449
3450impl NativeInstanceOverlayGuard {
3451    fn retain(mut self) {
3452        self.control = None;
3453    }
3454}
3455
3456impl Drop for NativeInstanceOverlayGuard {
3457    fn drop(&mut self) {
3458        if let Some(control) = self.control.as_ref() {
3459            control.clear_native_instance_launch_overlay(self.instance_id);
3460        }
3461    }
3462}
3463
3464enum PreparedNativeLaunchOverlay {
3465    Environment(NativeLaunchEnvironmentOverlay),
3466    Instance(NativeInstanceLaunchOverlay),
3467}
3468
3469impl NativeEnvironmentSelectionGuard {
3470    fn retain(mut self) {
3471        self.control = None;
3472    }
3473}
3474
3475impl Drop for NativeEnvironmentSelectionGuard {
3476    fn drop(&mut self) {
3477        if let Some(control) = self.control.as_ref() {
3478            control.clear_native_launch_profile_selection(self.instance_id);
3479        }
3480    }
3481}
3482
3483struct SessionMaterializationGuard<'a> {
3484    shared: &'a NodeShared,
3485    id: Option<MaterializationId>,
3486    /// Absolute ProviderHome (`home/`) under the session materialization root.
3487    /// Used for Claude station network `--settings` sidecar writes.
3488    provider_home: Option<PathBuf>,
3489}
3490
3491impl SessionMaterializationGuard<'_> {
3492    fn id(&self) -> Option<&MaterializationId> {
3493        self.id.as_ref()
3494    }
3495
3496    fn provider_home(&self) -> Option<&Path> {
3497        self.provider_home.as_deref()
3498    }
3499
3500    fn retain(mut self) {
3501        self.id = None;
3502    }
3503}
3504
3505impl Drop for SessionMaterializationGuard<'_> {
3506    fn drop(&mut self) {
3507        if let Some(id) = self.id.take() {
3508            let _ = self.shared.cleanup_materialization(&id);
3509        }
3510    }
3511}
3512
3513
3514/// Port: product-side reactions to session lifecycle transitions
3515/// (station lease, harness MCP overlay/registry, environment launch overlays).
3516trait SessionLifecycleHook {
3517    /// A binding was removed (unbind or spawn rollback).
3518    fn on_binding_removed(&self, address: &SessionAddress, removed: Option<&SessionBinding>);
3519    /// A session reached a terminal control event that revokes its harness MCP door.
3520    fn on_session_ended(&self, address: &SessionAddress);
3521    /// Node shutdown (or drop): revoke every harness MCP reservation.
3522    fn on_shutdown(&self);
3523    /// A harness MCP reservation was aborted before activation.
3524    fn on_harness_reservation_aborted(&self, instance_id: AgentInstanceId);
3525    /// Release session-owned materialization for a removed binding.
3526    fn cleanup_session_materialization(
3527        &self,
3528        address: &SessionAddress,
3529        binding: &SessionBinding,
3530    ) -> Result<(), NodeFailure>;
3531}
3532
3533impl SessionLifecycleHook for NodeShared {
3534    fn on_session_ended(&self, address: &SessionAddress) {
3535        self.retire_session_grok_leader(address.session.instance_id);
3536        // A process-tree kill (Windows `taskkill /T`, a supervisor stop) ends the children before
3537        // the node: drop the journal entry only if the node is still up a moment later, so a
3538        // node killed with its children still restores them.
3539        if !self.shutdown.load(Ordering::Acquire) {
3540            if let Some(file) = self.session_resume_journal() {
3541                let id = address.session.instance_id.0;
3542                let _ = std::thread::Builder::new().name("resume-forget".into()).spawn(move || {
3543                    std::thread::sleep(std::time::Duration::from_secs(5));
3544                    crate::pty_resume::remove(&file, id);
3545                });
3546            }
3547        }
3548        if let Some(accounts) = self.m4a_accounts.as_ref() {
3549            let instance = address.session.instance_id;
3550            let live = self.m4a_live.lock().unwrap_or_else(|p| p.into_inner());
3551            for (nick, _) in live.iter().filter(|(_, (i, _))| *i == instance) {
3552                accounts.remove_session_file(nick);
3553            }
3554        }
3555        if let Some(registry) = self.harness_mcp_registry.as_ref() {
3556            registry.revoke_session(address);
3557        }
3558        if let Some(control) = self.native_launch_profile_control.as_ref() {
3559            control.clear_native_mcp_server_launch_overlay(address.session.instance_id);
3560        }
3561    }
3562
3563    fn on_shutdown(&self) {
3564        let leaders: Vec<_> = std::mem::take(&mut *self.grok_leaders.lock().unwrap_or_else(|p| p.into_inner()))
3565            .into_values()
3566            .collect();
3567        for socket in leaders {
3568            crate::grok_leader::retire(&socket);
3569        }
3570        if let Some(registry) = self.harness_mcp_registry.as_ref() {
3571            let instances = registry.shutdown();
3572            if let Some(control) = self.native_launch_profile_control.as_ref() {
3573                for instance_id in instances {
3574                    control.clear_native_mcp_server_launch_overlay(instance_id);
3575                }
3576            }
3577        }
3578    }
3579
3580    fn on_harness_reservation_aborted(&self, instance_id: AgentInstanceId) {
3581        if let Some(control) = self.native_launch_profile_control.as_ref() {
3582            control.clear_native_mcp_server_launch_overlay(instance_id);
3583        }
3584    }
3585
3586    fn cleanup_session_materialization(
3587        &self,
3588        address: &SessionAddress,
3589        binding: &SessionBinding,
3590    ) -> Result<(), NodeFailure> {
3591        self.cleanup_session_owned_materialization(address, binding)
3592    }
3593
3594    fn on_binding_removed(&self, address: &SessionAddress, removed: Option<&SessionBinding>) {
3595        // Exclusive dig2browser station profile lease is node-local; clear
3596        // on every session unbind / rollback. Never ImportSession/cookies.
3597        #[cfg(feature = "dig2-station-probe")]
3598        {
3599            crate::dig2_station_lease::release_lease_for_holder(
3600                &self.browser_station_leases,
3601                address.session.instance_id,
3602            );
3603        }
3604        if let Some(control) = self.native_launch_profile_control.as_ref() {
3605            control.clear_native_mcp_server_launch_overlay(
3606                address.session.instance_id,
3607            );
3608        }
3609        if let Some(registry) = self.harness_mcp_registry.as_ref() {
3610            registry.revoke_session(address);
3611        }
3612        if removed
3613            .is_some_and(|binding| {
3614                binding.environment_profile.is_some()
3615                    || binding.bundle.is_some()
3616                    || binding.context.is_some()
3617            })
3618        {
3619            if let Some(control) = self.native_launch_profile_control.as_ref() {
3620                control.clear_native_launch_profile_selection(
3621                    address.session.instance_id,
3622                );
3623                control.clear_native_instance_launch_overlay(
3624                    address.session.instance_id,
3625                );
3626            }
3627        }
3628    }
3629}
3630
3631struct SpawnPrepareRequest<'r> {
3632    instance_id: AgentInstanceId,
3633    address: &'r SessionAddress,
3634    provider: &'r AgentId,
3635    mode: SessionMode,
3636    environment_profile: Option<&'r ResolvedEnvironmentProfileReceipt>,
3637    bundle: Option<&'r ResolvedBundleReceipt>,
3638    context: Option<&'r ResolvedContextPackReceipt>,
3639    managed_authority: Option<&'r ManagedWorktreeLeaseId>,
3640    harness_mcp: Option<&'r PreparedHarnessMcpSpawn>,
3641    approval_level: ApprovalLevel,
3642    network_allowlist: Option<&'r crate::protocol::SpawnNetworkAllowlistId>,
3643    browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
3644}
3645
3646/// Guards held between "session identity minted" and "launch committed".
3647/// Field order is the drop order and matches the pre-refactor local order
3648/// (instance overlay, environment selection, materialization, harness, station lease).
3649struct PreparedSpawn<'a> {
3650    instance_overlay: Option<NativeInstanceOverlayGuard>,
3651    environment_selection: Option<NativeEnvironmentSelectionGuard>,
3652    materialization_guard: Option<SessionMaterializationGuard<'a>>,
3653    harness_mcp_overlay: Option<NativeHarnessMcpOverlayGuard>,
3654    #[cfg(feature = "dig2-station-probe")]
3655    browser_station_lease_guard: Option<crate::dig2_station_lease::BrowserStationLeaseGuard>,
3656    materialization_id: Option<MaterializationId>,
3657}
3658
3659impl PreparedSpawn<'_> {
3660    /// Session-owned launch state now belongs to the binding.
3661    fn retain_session_guards(&mut self) {
3662        if let Some(selection) = self.environment_selection.take() {
3663            selection.retain();
3664        }
3665        if let Some(overlay) = self.instance_overlay.take() {
3666            overlay.retain();
3667        }
3668        if let Some(guard) = self.materialization_guard.take() {
3669            guard.retain();
3670        }
3671    }
3672
3673    /// Launch committed: keep harness overlay and station lease for the session lifetime.
3674    fn retain_launch_guards(&mut self) {
3675        if let Some(overlay) = self.harness_mcp_overlay.take() {
3676            overlay.retain();
3677        }
3678        #[cfg(feature = "dig2-station-probe")]
3679        if let Some(guard) = self.browser_station_lease_guard.take() {
3680            guard.retain();
3681        }
3682    }
3683}
3684
3685/// Port: everything product-side that must be prepared before a session is
3686/// bound and started (station lease, harness MCP overlay, materialization,
3687/// environment selection, network overlays, launch overlay).
3688trait SpawnPreparer {
3689    fn prepare<'a>(&'a self, request: SpawnPrepareRequest<'_>) -> Result<PreparedSpawn<'a>, NodeFailure>;
3690    /// Select the native launch profile for an environment-profile session.
3691    fn select_environment(
3692        &self,
3693        instance_id: AgentInstanceId,
3694        provider: &AgentId,
3695        mode: SessionMode,
3696        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
3697    ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure>;
3698    /// Install a prepared launch overlay for the instance.
3699    fn install_launch_overlay(
3700        &self,
3701        instance_id: AgentInstanceId,
3702        overlay: PreparedNativeLaunchOverlay,
3703    ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure>;
3704}
3705
3706impl NodeShared {
3707    /// m4a session key of a new runtime: a resume reuses the key (and so the nick and mailbox)
3708    /// of the runtime it replaces; a fresh spawn gets `<incarnation>-<instance>`.
3709    fn m4a_session_key(&self, instance_id: AgentInstanceId) -> String {
3710        let reused = self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).remove(&instance_id);
3711        let key = reused.unwrap_or_else(|| format!("{}-{}", self.incarnation_id, instance_id.0));
3712        self.m4a_keys.lock().unwrap_or_else(|p| p.into_inner()).insert(instance_id, key.clone());
3713        if let Some(file) = self.session_resume_journal() {
3714            crate::pty_resume::set_session_key(&file, instance_id.0, &key);
3715        }
3716        key
3717    }
3718
3719    fn session_workdir(&self, root: &std::path::Path, instance_id: AgentInstanceId, provider: &AgentId) -> Option<std::path::PathBuf> {
3720        let accounts = self.m4a_accounts.as_ref()?;
3721        let nick = self.m4a_nick_of(instance_id)?;
3722        match accounts.session_workdir(root, provider.as_str(), &nick) {
3723            Ok(dir) => dir,
3724            Err(error) => {
3725                tracing::warn!(%nick, %error, "session folder not prepared; the session runs in the workspace root");
3726                None
3727            }
3728        }
3729    }
3730
3731    fn m4a_nick_of(&self, instance_id: AgentInstanceId) -> Option<String> {
3732        self.m4a_live.lock().unwrap_or_else(|p| p.into_inner()).iter().find(|(_, (i, _))| *i == instance_id).map(|(n, _)| n.clone())
3733    }
3734
3735    /// The managed record of a node session carries its m4a nick as its name (HQ shows it,
3736    /// `/api/spawn` returns it).
3737    fn name_record_after_nick(&self, instance_id: AgentInstanceId, nick: &str) {
3738        let mut records = self.session_records.lock().unwrap_or_else(|p| p.into_inner());
3739        for record in records.records.values_mut() {
3740            if record.active_session.as_ref().is_some_and(|a| a.session.instance_id == instance_id) {
3741                record.display_name = nick.to_owned();
3742            }
3743        }
3744    }
3745
3746    /// Gives a node session its own m4a account: identity env, session file, MCP entry and
3747    /// the mailbox listener that wakes it.
3748    fn m4a_session_overlay(
3749        &self,
3750        accounts: &crate::m4a_accounts::M4aSessionAccounts,
3751        m4a_provider: &str,
3752        provider: &AgentId,
3753        instance_id: AgentInstanceId,
3754        environment_overlay: Option<PreparedNativeLaunchOverlay>,
3755        session_key: &str,
3756        mode: SessionMode,
3757    ) -> Result<Option<PreparedNativeLaunchOverlay>, NodeFailure> {
3758        let session_key = session_key.to_owned();
3759        if mode == SessionMode::Acp {
3760            return self.m4a_acp_mcp_overlay(accounts, m4a_provider, provider, instance_id, environment_overlay, &session_key);
3761        }
3762        Ok(                match accounts.provision(m4a_provider, &session_key) {
3763                    Ok(account) => {
3764                        crate::pty_resume::note_account_ref(&session_key, &account.session_id);
3765                        let mut identity = account.env_mutations();
3766                        identity.extend(crate::m4a_accounts::provider_session_env(provider.as_str()));
3767                        let merged = match environment_overlay {
3768                            None => NativeInstanceLaunchOverlay::new_session_identity(provider.clone(), identity)
3769                                .map(PreparedNativeLaunchOverlay::Instance),
3770                            Some(PreparedNativeLaunchOverlay::Instance(mut overlay)) => {
3771                                overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
3772                            }
3773                            Some(PreparedNativeLaunchOverlay::Environment(mut overlay)) => {
3774                                overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Environment(overlay))
3775                            }
3776                        }
3777                        .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a session account overlay is invalid"))?;
3778                        tracing::info!(provider = %provider, instance_id = ?instance_id, nick = %account.nick, "m4a session account assigned at spawn");
3779                        let mut merged = merged;
3780                        match accounts.write_session_file(&account) {
3781                            Err(error) => tracing::warn!(nick = %account.nick, %error, "m4a session file not written"),
3782                            Ok(_) => match accounts.mcp_launch_args(provider.as_str(), &account.nick) {
3783                                Ok(args) if !args.is_empty() => {
3784                                    merged = match merged {
3785                                        PreparedNativeLaunchOverlay::Instance(mut overlay) => overlay
3786                                            .append_extra_args(args)
3787                                            .map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
3788                                            .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a mcp overlay is invalid"))?,
3789                                        PreparedNativeLaunchOverlay::Environment(overlay) => overlay
3790                                            .into_instance_with_extra_args(args)
3791                                            .map(PreparedNativeLaunchOverlay::Instance)
3792                                            .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a mcp overlay is invalid"))?,
3793                                    };
3794                                    tracing::info!(nick = %account.nick, "m4a mcp server delivered at spawn");
3795                                }
3796                                Ok(_) => {}
3797                                Err(error) => tracing::warn!(nick = %account.nick, %error, "m4a mcp server not delivered"),
3798                            },
3799                        }
3800                        self.m4a_live
3801                            .lock()
3802                            .unwrap_or_else(|p| p.into_inner())
3803                            .insert(account.nick.clone(), (instance_id, provider.clone()));
3804                        self.name_record_after_nick(instance_id, &account.nick);
3805                        tracing::info!(nick = %account.nick, instance = instance_id.0, "m4a nick bound to session");
3806                        // Letters to this account wake the session: the node listens on its mailbox.
3807                        crate::m4a_wake::watch_spawned(provider.as_str(), &account.nick, &account.session_id, account.store_root.as_deref(), None);
3808                        Some(merged)
3809                    }
3810                    Err(error) => {
3811                        tracing::warn!(provider = %provider, instance_id = ?instance_id, %error, "m4a session account not assigned");
3812                        environment_overlay
3813                    }
3814                })
3815    }
3816}
3817
3818impl NodeShared {
3819    /// ACP has no env/argv overlay for this: the session's m4a account rides as an MCP server
3820    /// handed to the agent in session/new, with the account env on that server only.
3821    fn m4a_acp_mcp_overlay(
3822        &self,
3823        accounts: &crate::m4a_accounts::M4aSessionAccounts,
3824        m4a_provider: &str,
3825        provider: &AgentId,
3826        instance_id: AgentInstanceId,
3827        environment_overlay: Option<PreparedNativeLaunchOverlay>,
3828        session_key: &str,
3829    ) -> Result<Option<PreparedNativeLaunchOverlay>, NodeFailure> {
3830        let Some(control) = self.native_launch_profile_control.as_ref() else {
3831            tracing::warn!(%provider, "m4a acp account not assigned: no launch profile control");
3832            return Ok(environment_overlay);
3833        };
3834        let account = match accounts.provision(m4a_provider, session_key) {
3835            Ok(a) => a,
3836            Err(error) => {
3837                tracing::warn!(%provider, instance_id = ?instance_id, %error, "m4a session account not assigned");
3838                return Ok(environment_overlay);
3839            }
3840        };
3841        let session_file = match accounts.write_session_file(&account) {
3842            Ok(p) => p,
3843            Err(error) => {
3844                tracing::warn!(nick = %account.nick, %error, "m4a session file not written");
3845                return Ok(environment_overlay);
3846            }
3847        };
3848        let env_entries: Vec<(OsString, OsString)> = account
3849            .env_mutations()
3850            .into_iter()
3851            .chain(crate::m4a_accounts::provider_session_env(provider.as_str()))
3852            .filter_map(|m| m.value.map(|v| (m.key, v)))
3853            .collect();
3854        let args: Vec<OsString> = vec![
3855            "--session-file".into(),
3856            session_file.into_os_string(),
3857            "mcp".into(),
3858            "--provider".into(),
3859            provider.as_str().into(),
3860        ];
3861        let overlay = NativeMcpServerLaunchOverlay::new(
3862            provider.clone(),
3863            crate::m4a_accounts::mcp_server_name(provider.as_str()).as_str(),
3864            accounts.bin().as_os_str().to_os_string(),
3865            args,
3866            env_entries,
3867            Vec::new(),
3868        );
3869        match overlay.map_err(|e| e.to_string()).and_then(|o| control.install_native_mcp_server_launch_overlay(instance_id, o).map_err(|e| e.to_string())) {
3870            Ok(_) => tracing::info!(nick = %account.nick, instance = instance_id.0, "m4a mcp server delivered over acp"),
3871            Err(error) => {
3872                tracing::warn!(nick = %account.nick, %error, "m4a acp mcp server not delivered");
3873                return Ok(environment_overlay);
3874            }
3875        }
3876        self.m4a_live
3877            .lock()
3878            .unwrap_or_else(|p| p.into_inner())
3879            .insert(account.nick.clone(), (instance_id, provider.clone()));
3880        self.name_record_after_nick(instance_id, &account.nick);
3881        crate::m4a_wake::watch_spawned(provider.as_str(), &account.nick, &account.session_id, account.store_root.as_deref(), None);
3882        Ok(environment_overlay)
3883    }
3884}
3885
3886impl SpawnPreparer for NodeShared {
3887    fn select_environment(
3888        &self,
3889        instance_id: AgentInstanceId,
3890        provider: &AgentId,
3891        mode: SessionMode,
3892        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
3893    ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
3894        self.select_environment_profile(instance_id, provider, mode, environment_profile)
3895    }
3896
3897    fn install_launch_overlay(
3898        &self,
3899        instance_id: AgentInstanceId,
3900        overlay: PreparedNativeLaunchOverlay,
3901    ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
3902        self.install_prepared_launch_overlay(instance_id, overlay)
3903    }
3904
3905    fn prepare<'a>(&'a self, request: SpawnPrepareRequest<'_>) -> Result<PreparedSpawn<'a>, NodeFailure> {
3906        let SpawnPrepareRequest {
3907            instance_id,
3908            address,
3909            provider,
3910            mode,
3911            environment_profile,
3912            bundle,
3913            context,
3914            managed_authority,
3915            harness_mcp,
3916            approval_level,
3917            network_allowlist,
3918            browser_profile_id,
3919        } = request;
3920        // Track A follow-on: exclusive node-local BrowserStationLease after
3921        // dig2 probe (feature dig2-station-probe). Opaque profile id only —
3922        // never ImportSession / cookies on C2. Linux/feature-off: probe
3923        // already refused or stub; this block is cfg'd out when feature off.
3924        #[cfg(feature = "dig2-station-probe")]
3925        let mut browser_station_lease_guard = if let Some(profile_id) = browser_profile_id {
3926            use crate::dig2_station_lease::{acquire_lease, BrowserStationLeaseError};
3927            match acquire_lease(
3928                &self.browser_station_leases,
3929                profile_id,
3930                instance_id,
3931                unix_time_ms(),
3932            ) {
3933                Ok(guard) => Some(guard),
3934                Err(BrowserStationLeaseError::ProfileBusy { .. }) => {
3935                    return Err(failure(
3936                        NodeFailureCode::BrowserStationProfileBusy,
3937                        "dig2browser station profile is already leased by another session",
3938                    ));
3939                }
3940                Err(BrowserStationLeaseError::CapacityExceeded) => {
3941                    return Err(failure(
3942                        NodeFailureCode::BrowserStationProfileBusy,
3943                        "dig2browser station profile lease capacity exhausted",
3944                    ));
3945                }
3946                Err(BrowserStationLeaseError::HolderAlreadyLeased) => {
3947                    return Err(failure(
3948                        NodeFailureCode::BrowserStationProfileBusy,
3949                        "session already holds a dig2browser station profile lease",
3950                    ));
3951                }
3952            }
3953        } else {
3954            None
3955        };
3956        #[cfg(not(feature = "dig2-station-probe"))]
3957        let _ = browser_profile_id;
3958        let mut harness_mcp_overlay = if let Some(prepared) = harness_mcp {
3959            // Slice A(ii) of gate4agent-arc-mailbox-and-task-layer: the door
3960            // exists for PTY (env vars) and ACP (`session/new.mcpServers`,
3961            // `gate4agent-shell-native`'s `mcp_server_acp_entry`) alike --
3962            // both read the identical overlay resolved below, one as child
3963            // environment, the other translated into one stdio MCP server
3964            // entry. `Inline`/`Pipe` stays refused: this arc's proof is ACP
3965            // only, and nothing downstream reads a harness-MCP overlay for
3966            // that transport yet.
3967            if !matches!(mode, SessionMode::Pty | SessionMode::Acp) || prepared.provider != *provider
3968            {
3969                return Err(failure(
3970                    NodeFailureCode::BindingMismatch,
3971                    "harness MCP reservation does not match the exact PTY/ACP provider",
3972                ));
3973            }
3974            prepared.verify_helper_program().map_err(harness_mcp_failure)?;
3975            let control = self.native_launch_profile_control.clone().ok_or_else(|| {
3976                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP launch control is unavailable")
3977            })?;
3978            // Every name below is the caller's, carried in the reservation's
3979            // launch description: which environment variables receive the
3980            // endpoint, token and helper program, what the MCP server is called,
3981            // which argv it gets and which stale variables are erased.
3982            let launch = prepared.launch();
3983            let mut env_entries = vec![
3984                (
3985                    OsString::from(launch.endpoint_env.as_str()),
3986                    prepared.endpoint().as_os_str().to_os_string(),
3987                ),
3988                (
3989                    OsString::from(launch.token_env.as_str()),
3990                    prepared.token().expose().into(),
3991                ),
3992                (
3993                    OsString::from(launch.program_env.as_str()),
3994                    prepared.helper_program().as_os_str().to_os_string(),
3995                ),
3996            ];
3997            if let Some(trace_entry) = harness_mcp_trace_env_entry(launch) {
3998                env_entries.push(trace_entry);
3999            }
4000            if let Some(door) = crate::door::door_listen_addr() {
4001                env_entries.push((
4002                    OsString::from("HATCHERY_NODE_DOOR"),
4003                    OsString::from(door.to_string()),
4004                ));
4005                // Same key shape as HQ browser/draft ids:
4006                // `pty-{incarnation}-{workspace}-{instance}` — never reuse a
4007                // slot across node boots when the instance counter resets.
4008                let session_key = format!(
4009                    "pty-{}-{}-{}",
4010                    self.incarnation_id,
4011                    address.workspace_id.as_str(),
4012                    address.session.instance_id.0
4013                );
4014                env_entries.push((
4015                    OsString::from("HATCHERY_LOCAL_SERVER_SESSION"),
4016                    OsString::from(session_key),
4017                ));
4018            }
4019            let overlay = NativeMcpServerLaunchOverlay::new(
4020                provider.clone(),
4021                launch.server_name.as_str(),
4022                prepared.helper_program().as_os_str().to_os_string(),
4023                launch.args.iter().map(OsString::from).collect(),
4024                env_entries,
4025                launch.scrub_env.iter().map(OsString::from).collect(),
4026            ).map_err(|_| failure(
4027                NodeFailureCode::HarnessMcpUnavailable,
4028                "harness MCP launch overlay is unavailable",
4029            ))?;
4030            control
4031                .install_native_mcp_server_launch_overlay(instance_id, overlay)
4032                .map_err(|_| failure(
4033                    NodeFailureCode::HarnessMcpUnavailable,
4034                    "harness MCP launch overlay could not be installed",
4035                ))?;
4036            // State-change line: the overlay is installed and will be read
4037            // back by the PTY child's environment or, for ACP,
4038            // `gate4agent-shell-native`'s `mcp_server_acp_entry` at spawn
4039            // time -- no secrets here, only which instance/provider/mode
4040            // now carries a harness-MCP door.
4041            tracing::info!(
4042                node_id = %self.node_id,
4043                instance_id = ?instance_id,
4044                provider = %provider,
4045                mode = ?mode,
4046                "harness MCP overlay installed for spawn",
4047            );
4048            Some(NativeHarnessMcpOverlayGuard { control, instance_id })
4049        } else {
4050            None
4051        };
4052        let prepared_materialization = self.prepare_session_materialization(
4053            &address,
4054            &provider,
4055            mode,
4056            environment_profile,
4057            bundle,
4058            context,
4059            managed_authority.cloned(),
4060        )?;
4061        let (environment_overlay, mut materialization_guard) = match prepared_materialization {
4062            Some((overlay, guard)) => (Some(overlay), Some(guard)),
4063            None => (None, None),
4064        };
4065        let materialization_id = materialization_guard
4066            .as_ref()
4067            .and_then(SessionMaterializationGuard::id)
4068            .cloned();
4069        let mut environment_selection = SpawnPreparer::select_environment(self, 
4070            instance_id,
4071            &provider,
4072            mode,
4073            environment_profile,
4074        )?;
4075        // Station network overlays: Codex `-c network_access` and/or Claude
4076        // ProviderHome `--settings` sidecar (permits → allowedDomains).
4077        let provider_home = materialization_guard
4078            .as_ref()
4079            .and_then(SessionMaterializationGuard::provider_home);
4080        let network_overlay_args = self.resolve_network_allowlist_overlay_args(
4081            &provider,
4082            mode,
4083            approval_level,
4084            network_allowlist,
4085            provider_home,
4086        )?;
4087        let mut network_overlay_args = network_overlay_args;
4088        // Auto-permit (default on): pre-trust cwd / onboarding in provider config + auto/yolo flags.
4089        {
4090            let cwd = self.workspace_root(&address.workspace_id).ok().map(PathBuf::from);
4091            let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
4092            if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") {
4093                homes.extend(std::env::split_paths(&extra));
4094            }
4095            if let Some(h) = provider_home { homes.push(h.to_path_buf()); }
4096            crate::auto_permit::prepare(provider.as_str(), &homes, cwd.as_deref());
4097            network_overlay_args.extend(crate::auto_permit::launch_args(provider.as_str(), mode));
4098        }
4099        if mode == SessionMode::Acp && crate::auto_permit::acp_auto_approve(provider.as_str()) {
4100            crate::auto_permit::register_acp(instance_id.0, provider.as_str());
4101        }
4102        // Session registry: journal the session so it is resumed after a node restart.
4103        if matches!(mode, SessionMode::Pty | SessionMode::Acp) && crate::pty_resume::enabled() && crate::pty_resume::journalled(provider.as_str(), mode == SessionMode::Acp) {
4104            if let Some(file) = self.session_resume_journal() {
4105                crate::pty_resume::upsert(&file, crate::pty_resume::Entry {
4106                    instance_id: instance_id.0,
4107                    provider: provider.as_str().to_owned(),
4108                    workspace_id: address.workspace_id.as_str().to_owned(),
4109                    provider_session_id: String::new(),
4110                    session_key: self.m4a_keys.lock().unwrap_or_else(|p| p.into_inner()).get(&instance_id).cloned()
4111                        .or_else(|| self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).get(&instance_id).cloned())
4112                        .unwrap_or_else(|| format!("{}-{}", self.incarnation_id, instance_id.0)),
4113                    cwd: String::new(),
4114                    acp: mode == SessionMode::Acp,
4115                    options: SPAWN_SESSION_OPTIONS.try_with(|o| o.clone()).ok().flatten(),
4116                    cols: 0,
4117                    rows: 0,
4118                    origin: SPAWN_ORIGIN.try_with(|o| o.clone()).ok().flatten(),
4119                    parent_session_id: SPAWN_PARENT.try_with(|o| o.clone()).ok().flatten(),
4120                });
4121                if let Some(dir) = file.parent() {
4122                    crate::session_meta::upsert(dir, instance_id.0, crate::session_meta::Meta {
4123                        origin: SPAWN_ORIGIN.try_with(|o| o.clone()).ok().flatten(),
4124                        parent_session_id: SPAWN_PARENT.try_with(|o| o.clone()).ok().flatten(),
4125                        provider: Some(provider.as_str().to_owned()),
4126                        ..crate::session_meta::Meta::default()
4127                    });
4128                    crate::session_meta::revive(dir, instance_id.0);
4129                }
4130                tracing::info!(instance_id = instance_id.0, provider = %provider, "session journalled for restart resume");
4131            }
4132        }
4133        if let Some(socket) = self.session_grok_leader_socket(&provider, mode, instance_id) {
4134            network_overlay_args.extend(crate::grok_leader::leader_socket_args(&socket));
4135        }
4136        let environment_overlay = match (environment_overlay.flatten(), network_overlay_args) {
4137            (prepared, args) if args.is_empty() => prepared,
4138            (Some(PreparedNativeLaunchOverlay::Instance(mut overlay)), args) => {
4139                overlay.append_extra_args(args).map_err(|_| {
4140                    failure(
4141                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4142                        "network allowlist overlay could not merge with instance launch args",
4143                    )
4144                })?;
4145                Some(PreparedNativeLaunchOverlay::Instance(overlay))
4146            }
4147            (Some(PreparedNativeLaunchOverlay::Environment(overlay)), args) => {
4148                Some(PreparedNativeLaunchOverlay::Instance(
4149                    overlay
4150                        .into_instance_with_extra_args(args)
4151                        .map_err(|_| {
4152                            failure(
4153                                NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4154                                "network allowlist overlay could not merge with environment overlay",
4155                            )
4156                        })?,
4157                ))
4158            }
4159            (None, args) => Some(PreparedNativeLaunchOverlay::Instance(
4160                NativeInstanceLaunchOverlay::new(
4161                    provider.clone(),
4162                    match mode {
4163                        SessionMode::Pty => TransportKind::Pty,
4164                        SessionMode::Acp => TransportKind::Acp,
4165                        SessionMode::Inline => TransportKind::Pipe,
4166                    },
4167                    Vec::new(),
4168                    args,
4169                )
4170                .map_err(|_| {
4171                    failure(
4172                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4173                        "network allowlist overlay is invalid for this transport",
4174                    )
4175                })?,
4176            )),
4177        };
4178        // Per-session m4a account: own nick + capability for this PTY child only, on its own
4179        // overlay or appended to the overlay the spawn already carries.
4180        #[cfg(unix)]
4181        // `HATCHERY_M4A_SIGNER_SHELL=1` also gives operator shells an identity (live canaries).
4182        let signer_provider = crate::m4a_accounts::m4a_provider(provider.as_str()).or_else(|| {
4183            (provider.as_str() == "shell" && std::env::var_os("HATCHERY_M4A_SIGNER_SHELL").is_some_and(|v| v == "1")).then_some("shell")
4184        });
4185        #[cfg(unix)]
4186        // A provider session with its own m4a account (MCP server + cap) takes that path; the
4187        // signer handle is for sessions without one (operator shells).
4188        let signer_provider = signer_provider.filter(|p| *p == "shell" || self.m4a_accounts.is_none());
4189        #[cfg(unix)]
4190        let signer_identity = match (mode, self.signer_proxy.get(), signer_provider) {
4191            (SessionMode::Pty, Some(proxy), Some(m4a_provider)) => {
4192                let session_key = format!("{}-{}", self.incarnation_id, instance_id.0);
4193                let uid = unsafe { libc_geteuid() };
4194                let handle = proxy.reserve(&session_key, m4a_provider, uid);
4195                let sock = std::env::var_os(crate::keystore_proxy::SOCKET_ENV).unwrap_or_default();
4196                tracing::info!(provider = %provider, instance_id = ?instance_id, "m4a signer handle reserved at spawn");
4197                Some(vec![
4198                    gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from(crate::keystore_proxy::HANDLE_ENV), value: Some(std::ffi::OsString::from(handle)) },
4199                    gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from(crate::keystore_proxy::SOCKET_ENV), value: Some(sock) },
4200                    gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from("M4A_SESSION_ID"), value: Some(std::ffi::OsString::from(session_key)) },
4201                ])
4202            }
4203            _ => None,
4204        };
4205        #[cfg(not(unix))]
4206        let signer_identity: Option<Vec<gate4agent_catalog::EnvMutation>> = None;
4207        let environment_overlay = match (mode, self.m4a_accounts.as_ref(), crate::m4a_accounts::m4a_provider(provider.as_str()), signer_identity) {
4208            (SessionMode::Pty, _, _, Some(identity)) => {
4209                let merged = match environment_overlay {
4210                    None => NativeInstanceLaunchOverlay::new_session_identity(provider.clone(), identity).map(PreparedNativeLaunchOverlay::Instance),
4211                    Some(PreparedNativeLaunchOverlay::Instance(mut overlay)) => {
4212                        overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
4213                    }
4214                    Some(PreparedNativeLaunchOverlay::Environment(mut overlay)) => {
4215                        overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Environment(overlay))
4216                    }
4217                }
4218                .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a signer handle overlay is invalid"))?;
4219                Some(merged)
4220            }
4221            (SessionMode::Pty | SessionMode::Acp, Some(accounts), Some(m4a_provider), None) => {
4222                let session_key = self.m4a_session_key(instance_id);
4223                self.m4a_session_overlay(accounts, m4a_provider, provider, instance_id, environment_overlay, &session_key, mode)?
4224            }
4225            (SessionMode::Pty, Some(_), None, _) => {
4226                tracing::warn!(provider = %provider, instance_id = ?instance_id, "m4a session account not assigned: provider has no m4a mapping");
4227                environment_overlay
4228            }
4229            _ => environment_overlay,
4230        };
4231        let mut instance_overlay = environment_overlay
4232            .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
4233            .transpose()?
4234            .flatten();
4235        Ok(PreparedSpawn {
4236            instance_overlay,
4237            environment_selection,
4238            materialization_guard,
4239            harness_mcp_overlay,
4240            #[cfg(feature = "dig2-station-probe")]
4241            browser_station_lease_guard,
4242            materialization_id,
4243        })
4244    }
4245}
4246
4247struct SessionRecords {
4248    records: BTreeMap<SessionRecordId, ManagedSessionRecord>,
4249}
4250
4251struct SpawnIdempotencyCache {
4252    entries: BTreeMap<SpawnIdempotencyKey, SpawnIdempotencyEntry>,
4253}
4254
4255struct SpawnIdempotencyEntry {
4256    value: SpawnIdempotencyValue,
4257    expires_at: Instant,
4258}
4259
4260enum SpawnIdempotencyValue {
4261    Standard {
4262        spec: SpawnSpec,
4263        result: Result<ResolvedSpawnReceipt, NodeFailure>,
4264    },
4265    Managed {
4266        request: ManagedWorktreeSpawnRequest,
4267        result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
4268    },
4269    HarnessMcp,
4270}
4271
4272enum DurableManagedSpawnReplayDecision {
4273    Reserved,
4274    PendingLinked,
4275    Committed(ManagedWorktreeSpawnReceipt),
4276}
4277
4278impl SpawnIdempotencyValue {
4279    fn references_context(&self, context_id: &SpawnContextId) -> bool {
4280        match self {
4281            Self::Standard { result, .. } => result
4282                .as_ref()
4283                .ok()
4284                .and_then(|receipt| receipt.context.as_ref()),
4285            Self::Managed { result, .. } => result
4286                .as_ref()
4287                .ok()
4288                .and_then(|receipt| receipt.spawn.context.as_ref()),
4289            Self::HarnessMcp => None,
4290        }
4291        .is_some_and(|receipt| &receipt.id == context_id)
4292    }
4293}
4294
4295#[derive(Clone, Copy)]
4296struct ControllerLease {
4297    connection_id: u64,
4298    expires_at: Instant,
4299}
4300
4301struct NodeEventHistory {
4302    last_sequence: u64,
4303    replay_floor_sequence: u64,
4304    events: VecDeque<NodeEventEnvelope>,
4305    record_providers: BTreeMap<SessionRecordId, AgentId>,
4306    removed_record_providers: BTreeMap<u64, AgentId>,
4307}
4308
4309impl NodeEventHistory {
4310    fn new(record_providers: BTreeMap<SessionRecordId, AgentId>) -> Self {
4311        Self {
4312            last_sequence: 0,
4313            replay_floor_sequence: 1,
4314            events: VecDeque::with_capacity(NODE_EVENT_HISTORY_MAX),
4315            record_providers,
4316            removed_record_providers: BTreeMap::new(),
4317        }
4318    }
4319}
4320
4321impl NodeShared {
4322    /// A node-spawned grok PTY session gets its own leader socket, so its agent backend runs
4323    /// as a descendant of this session's process (with the session's own environment) and
4324    /// never attaches to an operator's shared leader. Same grok home, same login.
4325    fn session_resume_journal(&self) -> Option<PathBuf> {
4326        self.state_path.as_ref().and_then(|p| p.parent()).map(crate::pty_resume::path)
4327    }
4328
4329    /// Node start: resume every journalled session through its provider's resume path,
4330    /// under its old instance number, so HQ shows the same chat.
4331    async fn restore_journalled_sessions(&self) {
4332        let Some(file) = self.session_resume_journal() else { return };
4333        if !crate::pty_resume::enabled() { return; }
4334        for e in crate::pty_resume::load(&file) {
4335            crate::pty_resume::remove(&file, e.instance_id);
4336            // Same m4a session key as before the restart: the session keeps its account and nick.
4337            if !e.session_key.is_empty() {
4338                self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).insert(AgentInstanceId(e.instance_id), e.session_key.clone());
4339            }
4340            let (Ok(ws), Ok(provider)) = (WorkspaceId::new(e.workspace_id.clone()), AgentId::new(e.provider.clone())) else { continue };
4341            if e.acp {
4342                let record_id = {
4343                    let records = self.session_records.lock().unwrap_or_else(|p| p.into_inner());
4344                    records.records.values().find(|r| r.mode == SessionMode::Acp && r.provider_session.as_ref().is_some_and(|i| !e.provider_session_id.is_empty() && i.id == e.provider_session_id)).map(|r| r.record_id.clone())
4345                };
4346                let cur = self.next_instance_id.load(Ordering::Acquire);
4347                if e.instance_id >= cur { self.next_instance_id.store(e.instance_id, Ordering::Release); }
4348                let size = gate4agent_types::TerminalSize { rows: 40, columns: 120 };
4349                let resumed = match record_id {
4350                    Some(rid) => SPAWN_SESSION_OPTIONS.scope(e.options.clone(), self.resume_session_record(&rid, size, None)).await.map(|(_, a)| a),
4351                    None => Err(failure(NodeFailureCode::SessionRecordNotResumable, "no record")),
4352                };
4353                // An agent without ACP session/load (claude) cannot resume: same number, fresh chat.
4354                let r = match resumed {
4355                    Ok(a) => Ok(a),
4356                    Err(err) => {
4357                        tracing::info!(instance_id = e.instance_id, message = %err.message, "acp resume unavailable; starting fresh under the same number");
4358                        self.next_instance_id.store(e.instance_id.max(self.next_instance_id.load(Ordering::Acquire).min(e.instance_id)), Ordering::Release);
4359                        SPAWN_SESSION_OPTIONS.scope(e.options.clone(), self.spawn_session(ws, provider, SessionMode::Acp, size, None)).await
4360                    }
4361                };
4362                match r {
4363                    Ok(addr) => tracing::info!(instance_id = addr.session.instance_id.0, provider = %e.provider, "journalled acp session restored"),
4364                    Err(err) => tracing::warn!(instance_id = e.instance_id, provider = %e.provider, message = %err.message, "journalled acp session restore failed"),
4365                }
4366                continue;
4367            }
4368            if e.provider_session_id.is_empty() || !provider_session_exists(&e.provider, &e.provider_session_id) {
4369                // Nothing said yet in that chat: start it fresh under the same number.
4370                let cur = self.next_instance_id.load(Ordering::Acquire);
4371                if e.instance_id >= cur { self.next_instance_id.store(e.instance_id, Ordering::Release); }
4372                let size = gate4agent_types::TerminalSize { rows: 40, columns: 120 };
4373                match SPAWN_SESSION_OPTIONS.scope(e.options.clone(), self.spawn_session(ws, provider, SessionMode::Pty, size, None)).await {
4374                    Ok(addr) => tracing::info!(instance_id = addr.session.instance_id.0, provider = %e.provider, "journalled session had no transcript; started fresh"),
4375                    Err(err) => tracing::warn!(instance_id = e.instance_id, provider = %e.provider, message = %err.message, "journalled session restart failed"),
4376                }
4377                continue;
4378            }
4379            let folder = std::path::Path::new(&e.cwd);
4380            let in_session_dir = folder.parent().and_then(|p| p.file_name()).is_some_and(|n| n == ".hatchery-sessions");
4381            let name = match (in_session_dir, folder.file_name()) {
4382                (true, Some(n)) => n.to_string_lossy().into_owned(),
4383                _ => format!("{} #{}", e.provider, e.instance_id),
4384            };
4385            let identity = ProviderSessionIdentity { key: gate4agent_types::ProviderSessionKey::SessionId, id: e.provider_session_id.clone(), transcript_path: None };
4386            let record = match self.index_provider_session(ws, provider, identity, name) {
4387                Ok(r) => r,
4388                Err(err) => { tracing::warn!(instance_id = e.instance_id, message = %err.message, "journalled session not indexed"); continue; }
4389            };
4390            let cur = self.next_instance_id.load(Ordering::Acquire);
4391            if e.instance_id >= cur { self.next_instance_id.store(e.instance_id, Ordering::Release); }
4392            let size = gate4agent_types::TerminalSize { rows: if e.rows > 0 { e.rows } else { 40 }, columns: if e.cols > 0 { e.cols } else { 120 } };
4393            let r = SPAWN_SESSION_OPTIONS.scope(e.options.clone(), self.resume_session_record(&record.record_id, size, None)).await;
4394            match r {
4395                Ok((_, addr)) => tracing::info!(instance_id = addr.session.instance_id.0, provider = %e.provider, "journalled session resumed"),
4396                Err(err) => tracing::warn!(instance_id = e.instance_id, provider = %e.provider, message = %err.message, "journalled session resume failed"),
4397            }
4398        }
4399    }
4400
4401    fn refresh_session_journal(&self) {
4402        let Some(file) = self.session_resume_journal() else { return };
4403        let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
4404        if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") { homes.extend(std::env::split_paths(&extra)); }
4405        crate::pty_resume::refresh(&file, &homes);
4406        let ids: Vec<(u64, String)> = {
4407            let records = self.session_records.lock().unwrap_or_else(|p| p.into_inner());
4408            records.records.values().filter_map(|r| Some((r.active_session.as_ref()?.session.instance_id.0, r.provider_session.as_ref()?.id.clone()))).collect()
4409        };
4410        for (inst, sid) in ids {
4411            crate::pty_resume::set_session_id(&file, inst, &sid);
4412        }
4413    }
4414
4415    fn session_grok_leader_socket(
4416        &self,
4417        provider: &AgentId,
4418        mode: SessionMode,
4419        instance_id: AgentInstanceId,
4420    ) -> Option<std::path::PathBuf> {
4421        if provider.as_str() != "grok" || mode != SessionMode::Pty || crate::grok_leader::disabled() {
4422            return None;
4423        }
4424        let root = self
4425            .m4a_accounts
4426            .as_ref()
4427            .and_then(|a| a.base().parent().map(std::path::Path::to_path_buf))
4428            .unwrap_or_else(std::env::temp_dir);
4429        let socket = crate::grok_leader::socket_path(&root, &format!("{}-{}", self.incarnation_id, instance_id.0));
4430        if let Some(previous) = self
4431            .grok_leaders
4432            .lock()
4433            .unwrap_or_else(|p| p.into_inner())
4434            .insert(instance_id, socket.clone())
4435        {
4436            crate::grok_leader::retire(&previous);
4437        }
4438        Some(socket)
4439    }
4440
4441    /// Stops this session's own grok leader, if it had one. Never touches any other leader.
4442    fn retire_session_grok_leader(&self, instance_id: AgentInstanceId) {
4443        let socket = self.grok_leaders.lock().unwrap_or_else(|p| p.into_inner()).remove(&instance_id);
4444        if let Some(socket) = socket {
4445            crate::grok_leader::retire(&socket);
4446        }
4447    }
4448
4449    fn new_with_incarnation(
4450        handle: Gate4AgentHandle,
4451        access_token: String,
4452        node_id: NodeId,
4453        incarnation_id: NodeIncarnationId,
4454        workspaces: Vec<WorkspaceConfig>,
4455        enabled_providers: Vec<AgentId>,
4456        provider_runtime_statuses: ProviderRuntimeStatuses,
4457        provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
4458        provider_contracts: Vec<ProviderContractSupport>,
4459        provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
4460        spawn_profiles: SpawnProfileRegistry,
4461        native_launch_profile_control: Option<NativeLaunchProfileControl>,
4462        native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
4463        session_environment_materializer: Option<SessionEnvironmentMaterializer>,
4464        state_path: Option<PathBuf>,
4465        records: Vec<ManagedSessionRecord>,
4466        managed_worktrees: Vec<ManagedWorktreeLeaseRecord>,
4467        managed_worktree_tombstones: Vec<ManagedWorktreeLeaseRecord>,
4468        materializations: Vec<MaterializationOwnershipRecord>,
4469        persistence_warning: Option<String>,
4470        input_settle_timeout_ms: u64,
4471    ) -> Self {
4472        let (event_tx, _) = broadcast::channel(NODE_BROADCAST_CAPACITY);
4473        let (terminal_event_tx, _) = broadcast::channel(NODE_TERMINAL_BROADCAST_CAPACITY);
4474        let (agent_stream_event_tx, _) = broadcast::channel(NODE_AGENT_STREAM_BROADCAST_CAPACITY);
4475        let (harness_mcp_proxy_event_tx, _) =
4476            broadcast::channel(NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY);
4477        let record_providers = records
4478            .iter()
4479            .map(|record| (record.record_id.clone(), record.provider.clone()))
4480            .collect();
4481        let mut worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode> = workspaces
4482            .iter()
4483            .map(|workspace| {
4484                (workspace.workspace_id.clone(), workspace.worktree_service_mode)
4485            })
4486            .collect();
4487        for lease in &managed_worktrees {
4488            worktree_service_modes.insert(lease.workspace_id.clone(), WorktreeServiceMode::Off);
4489        }
4490        let managed_worktree_profiles = workspaces.iter()
4491            .map(|workspace| (
4492                workspace.workspace_id.clone(),
4493                workspace.managed_worktree_profiles.clone(),
4494            ))
4495            .collect();
4496        let mut managed_worktrees = ManagedWorktreeRegistry::from_records(
4497            managed_worktrees,
4498            managed_worktree_tombstones,
4499        ).expect("durable managed worktree registry was validated while loading");
4500        managed_worktrees.clear_stale_session_holders(incarnation_id, unix_time_ms());
4501        managed_worktrees.reattach_record_holders(
4502            &managed_worktree_record_holders(&records),
4503            unix_time_ms(),
4504        );
4505        Self {
4506            handle,
4507            access_token,
4508            node_id,
4509            incarnation_id,
4510            started_at_unix_ms: std::time::SystemTime::now()
4511                .duration_since(std::time::UNIX_EPOCH)
4512                .unwrap_or_default()
4513                .as_millis()
4514                .min(u64::MAX as u128) as u64,
4515            workspaces: RwLock::new(
4516                workspaces
4517                    .into_iter()
4518                    .map(|workspace| (workspace.workspace_id, workspace.canonical_root))
4519                    .collect(),
4520            ),
4521            worktree_service_modes,
4522            managed_worktree_profiles,
4523            managed_worktrees: Mutex::new(managed_worktrees),
4524            enabled_providers,
4525            provider_runtime_statuses,
4526            provider_runtime_status_updates: RwLock::new(BTreeMap::new()),
4527            provider_runtime_monitor,
4528            provider_contracts,
4529            provider_adapter_contracts,
4530            spawn_profiles,
4531            environment_profiles: RwLock::new(BTreeMap::new()),
4532            environment_materialization_profiles: RwLock::new(BTreeMap::new()),
4533            network_allowlist_catalog: RwLock::new(crate::network_allowlist_catalog::NetworkAllowlistCatalog::new()),
4534            #[cfg(feature = "dig2-station-probe")]
4535            browser_station_leases: crate::dig2_station_lease::new_lease_table(),
4536            bundle_catalog: RwLock::new(BundleCatalog::default()),
4537            delivery_store: Mutex::new(None),
4538            context_pack_store: Mutex::new(None),
4539            context_catalog: RwLock::new(ContextPackCatalog::default()),
4540            native_launch_profile_control,
4541            harness_mcp_registry: None,
4542            native_session_catalog,
4543            session_environment_materializer,
4544            m4a_accounts: crate::m4a_accounts::M4aSessionAccounts::from_env(),
4545            m4a_live: Mutex::new(BTreeMap::new()),
4546            m4a_keys: Mutex::new(BTreeMap::new()),
4547            m4a_reuse_keys: Mutex::new(BTreeMap::new()),
4548            grok_leaders: Mutex::new(BTreeMap::new()),
4549            #[cfg(unix)]
4550            #[cfg(unix)]
4551            signer_proxy: std::sync::OnceLock::new(),
4552            materializations: Mutex::new(
4553                materializations
4554                    .into_iter()
4555                    .map(|record| (record.id().clone(), record))
4556                    .collect(),
4557            ),
4558            spawn_idempotency: Mutex::new(SpawnIdempotencyCache {
4559                entries: BTreeMap::new(),
4560            }),
4561            managed_spawn_replays: Mutex::new(BTreeMap::new()),
4562            controller: Mutex::new(None),
4563            provisional_spawns: Mutex::new(BTreeMap::new()),
4564            pending_acp_options: Mutex::new(BTreeMap::new()),
4565            history: Mutex::new(NodeEventHistory::new(record_providers)),
4566            event_tx,
4567            terminal_event_tx,
4568            agent_stream_event_tx,
4569            harness_mcp_proxy_event_tx,
4570            terminal_frame_watermarks: Mutex::new(BTreeMap::new()),
4571            pty_input_wake: tokio::sync::Notify::new(),
4572            pty_input_marks: Mutex::new(BTreeMap::new()),
4573            provider_errors: Mutex::new(crate::provider_error::Tracker::default()),
4574            next_connection_id: AtomicU64::new(1),
4575            next_instance_id: AtomicU64::new(1),
4576            next_command_id: AtomicU64::new(1),
4577            shutdown: AtomicBool::new(false),
4578            shutdown_notify: Notify::new(),
4579            preauth_slots: Arc::new(Semaphore::new(MAX_PREAUTH_CONNECTIONS)),
4580            authenticated_slots: Arc::new(Semaphore::new(MAX_AUTHENTICATED_CONNECTIONS)),
4581            inspection_slots: Arc::new(Semaphore::new(WORKSPACE_INSPECTION_MAX_CONCURRENCY)),
4582            mutation_gate: AsyncMutex::new(()),
4583            session_bindings: Mutex::new(BTreeMap::new()),
4584            session_records: Mutex::new(SessionRecords {
4585                records: records
4586                    .into_iter()
4587                    .map(|record| (record.record_id.clone(), record))
4588                    .collect(),
4589            }),
4590            state_path,
4591            persistence_error: RwLock::new(
4592                persistence_warning
4593                    .map(|message| session_registry::sanitized_persistence_summary(&message)),
4594            ),
4595            state_transaction: Mutex::new(()),
4596            input_settle_timeout_ms,
4597            drive_loop_profile: Mutex::new(DriveLoopProfiler::default()),
4598            runtime_tick_profile: Mutex::new(TickProfileSnapshot::default()),
4599            shell_efficiency_profile: OnceLock::new(),
4600            native_session_gauge: AtomicUsize::new(0),
4601            connection_loop_iterations: AtomicU64::new(0),
4602            connection_events_sent: AtomicU64::new(0),
4603            rejected_commands_total: AtomicU64::new(0),
4604            rejected_command_reasons: Mutex::new(BTreeMap::new()),
4605            drive_loop_iterations_total: AtomicU64::new(0),
4606            drive_loop_iterations_idle: AtomicU64::new(0),
4607            session_record_retention: SessionRecordRetentionConfig::default(),
4608            retired_records_total: AtomicU64::new(0),
4609            #[cfg(feature = "fixture")]
4610            fixture_semantic_hook_policy: false,
4611            #[cfg(feature = "fixture")]
4612            fixture_spawn_managed_worktree_v2_failure_probe:
4613                SpawnManagedWorktreeV2FailureProbe::default(),
4614        }
4615    }
4616
4617    #[cfg(test)]
4618    fn new(
4619        handle: Gate4AgentHandle,
4620        access_token: String,
4621        node_id: NodeId,
4622        workspaces: Vec<WorkspaceConfig>,
4623        enabled_providers: Vec<AgentId>,
4624    ) -> Self {
4625        Self::new_with_incarnation(
4626            handle,
4627            access_token,
4628            node_id,
4629            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
4630            workspaces,
4631            enabled_providers,
4632            ProviderRuntimeStatuses::default(),
4633            None,
4634            Vec::new(),
4635            Vec::new(),
4636            SpawnProfileRegistry::default(),
4637            None,
4638            None,
4639            None,
4640            None,
4641            Vec::new(),
4642            Vec::new(),
4643            Vec::new(),
4644            Vec::new(),
4645            None,
4646            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
4647        )
4648    }
4649
4650    fn persistence_error(&self) -> Option<String> {
4651        self.persistence_error
4652            .read()
4653            .unwrap_or_else(|poisoned| poisoned.into_inner())
4654            .clone()
4655    }
4656
4657    async fn admit_provider_runtime(
4658        &self,
4659        provider: &AgentId,
4660        requirement: ProviderRuntimeRequirement,
4661    ) -> Result<ProviderRuntimePolicy, NodeFailure> {
4662        let admission = if let Some(monitor) = self.provider_runtime_monitor.clone() {
4663            let pipe_transport = monitor.supports_pipe_transport(provider);
4664            let acp_transport = monitor.supports_acp_transport(provider);
4665            let provider = provider.clone();
4666            let refresh = tokio::task::spawn_blocking(move || monitor.evaluate(&provider));
4667            let (status, admission) = timeout(
4668                Duration::from_millis(PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS),
4669                refresh,
4670            )
4671            .await
4672            .map_err(|_| failure(
4673                NodeFailureCode::BackendBusy,
4674                "provider runtime probe exceeded its bounded deadline",
4675            ))?
4676            .map_err(|_| failure(
4677                NodeFailureCode::BackendOperationFailed,
4678                "provider runtime probe task failed",
4679            ))?;
4680            if let Some(status) = status {
4681                self
4682                    .provider_runtime_status_updates
4683                    .write()
4684                    .unwrap_or_else(|poisoned| poisoned.into_inner())
4685                    .insert(status.provider().clone(), status);
4686            }
4687            admission.and_then(|policy| {
4688                require_policy(policy, pipe_transport, acp_transport, requirement).map(|()| policy)
4689            })
4690        } else {
4691            // No live monitor means no catalog to consult -- there is
4692            // nothing here to confirm this provider declares a Pipe or ACP
4693            // transport, so Inline/Acp are rejected the same conservative
4694            // way an undeclared provider always was, rather than admitted
4695            // on trust.
4696            crate::provider_runtime::admit_status(
4697                &self.provider_runtime_statuses,
4698                provider,
4699                false,
4700                false,
4701                requirement,
4702            )
4703        };
4704        let admission = admission.map(|policy| self.admit_declared_hook_semantics(provider, policy));
4705        admission.map_err(|error| match error {
4706            ProviderRuntimeAdmissionError::LauncherUnavailable => failure(
4707                NodeFailureCode::BackendOperationFailed,
4708                "provider launcher is unavailable",
4709            ),
4710            ProviderRuntimeAdmissionError::SemanticCapabilityUnverified => failure(
4711                NodeFailureCode::UnsupportedCapability,
4712                "provider semantic capability is not verified",
4713            ),
4714            ProviderRuntimeAdmissionError::ProbeBusy => failure(
4715                NodeFailureCode::BackendBusy,
4716                "provider runtime probe is already in progress",
4717            ),
4718        })
4719    }
4720
4721    /// Whether the catalog declares a Pipe transport for `provider` --
4722    /// the fact `require_policy` needs to admit or reject
4723    /// `ProviderRuntimeRequirement::Inline`. With no live monitor there is no
4724    /// catalog to consult, so this stays conservative and rejects, matching
4725    /// `admit_provider_runtime`'s own no-monitor branch.
4726    fn provider_supports_pipe_transport(&self, provider: &AgentId) -> bool {
4727        match self.provider_runtime_monitor.as_deref() {
4728            Some(monitor) => monitor.supports_pipe_transport(provider),
4729            None => false,
4730        }
4731    }
4732
4733    /// Whether the catalog declares an ACP transport for `provider` -- the
4734    /// fact `require_policy` needs to admit or reject
4735    /// `ProviderRuntimeRequirement::Acp`. Mirrors
4736    /// `provider_supports_pipe_transport` exactly, including the
4737    /// conservative no-monitor default.
4738    fn provider_supports_acp_transport(&self, provider: &AgentId) -> bool {
4739        match self.provider_runtime_monitor.as_deref() {
4740            Some(monitor) => monitor.supports_acp_transport(provider),
4741            None => false,
4742        }
4743    }
4744
4745    /// Grants hook ingestion to a provider whose catalog entry declares a
4746    /// Hook adapter, whatever runtime mode its launcher probe landed in.
4747    ///
4748    /// `admit_status`'s `RawPassthrough` arm hands back
4749    /// `ProviderRuntimePolicy::raw_pty()`, which grants nothing semantic --
4750    /// correct for everything it was written to answer, and wrong for hooks
4751    /// specifically. A hook event is not inferred from terminal behaviour,
4752    /// so it has nothing to do with whether a launcher probe verified this
4753    /// CLI version's PTY contract; the authority is the catalog declaring
4754    /// the adapter plus the ingress authenticating the route. Without this
4755    /// the node installs a provider's hooks, the CLI faithfully calls them,
4756    /// and every event is refused -- which is exactly the state grok was in.
4757    ///
4758    /// Applied to both admission paths so the monitor's own derivation and
4759    /// this coarse fallback cannot disagree about the same provider.
4760    fn admit_declared_hook_semantics(
4761        &self,
4762        provider: &AgentId,
4763        policy: ProviderRuntimePolicy,
4764    ) -> ProviderRuntimePolicy {
4765        if policy.hook_semantics || !policy.raw_pty_lifecycle {
4766            return policy;
4767        }
4768        let declares_hook = self.provider_adapter_contracts.iter().any(|contract| {
4769            contract.provider == *provider && contract.family == AdapterFamily::Hook
4770        });
4771        if !declares_hook {
4772            return policy;
4773        }
4774        ProviderRuntimePolicy::new(
4775            policy.raw_pty_lifecycle,
4776            policy.semantic_readiness,
4777            policy.structured_prompt,
4778            policy.provider_session_identity,
4779            policy.semantic_resume,
4780            true,
4781        )
4782        .expect("granting hook semantics over a raw-PTY policy is internally valid")
4783    }
4784
4785    /// Station network allowlist → provider launch overlay argv.
4786    ///
4787    /// - **Codex**: first-slice `-c sandbox_workspace_write.network_access` from
4788    ///   `provider_native` under Moderate (PTY or ACP).
4789    /// - **Claude**: catalog `permits` → ProviderHome sidecar settings JSON +
4790    ///   documented `--settings <path>` on **PTY** only (Linux/macOS/WSL2).
4791    ///   ACP refuses (no measured ACP settings-path surface). Native Windows
4792    ///   refuses (no vendor Bash sandbox). Membership-only (empty permits) =
4793    ///   no overlay.
4794    /// - **Kimi / Grok**: no first-party settings/argv network surface here —
4795    ///   empty overlay when no `provider_native`; invented native keys already
4796    ///   refuse at load / resolve.
4797    ///
4798    /// Empty when no allowlist / nothing to map. Never cookies / secrets.
4799    fn resolve_network_allowlist_overlay_args(
4800        &self,
4801        provider: &AgentId,
4802        mode: SessionMode,
4803        approval_level: ApprovalLevel,
4804        network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4805        provider_home: Option<&Path>,
4806    ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4807        let Some(allowlist_id) = network_allowlist else {
4808            return Ok(Vec::new());
4809        };
4810        let catalog = self
4811            .network_allowlist_catalog
4812            .read()
4813            .unwrap_or_else(|poisoned| poisoned.into_inner());
4814        let Some(entry) = catalog.get(allowlist_id) else {
4815            return Err(failure(
4816                NodeFailureCode::UnknownNetworkAllowlist,
4817                "spawn network allowlist is unavailable on this node",
4818            ));
4819        };
4820
4821        // Claude: settings-shaped overlay from permits (not provider_native).
4822        if provider.as_str() == "claude" && !entry.permits.is_empty() {
4823            crate::network_allowlist_catalog::claude_bash_sandbox_network_os_supported().map_err(
4824                |message| {
4825                    failure(
4826                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4827                        &message,
4828                    )
4829                },
4830            )?;
4831            if !matches!(mode, SessionMode::Pty) {
4832                return Err(failure(
4833                    NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4834                    "Claude sandbox.network --settings overlay requires PTY transport; ACP has no measured settings-path surface (refuse rather than invent RPC)",
4835                ));
4836            }
4837            let Some(home) = provider_home else {
4838                return Err(failure(
4839                    NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4840                    "Claude sandbox.network --settings overlay requires ProviderHome materialization (CLAUDE_CONFIG_DIR)",
4841                ));
4842            };
4843            let domains =
4844                crate::network_allowlist_catalog::claude_allowed_domains_from_permits(&entry.permits);
4845            if domains.is_empty() {
4846                return Err(failure(
4847                    NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4848                    "Claude sandbox.network overlay could not map catalog permits to allowedDomains",
4849                ));
4850            }
4851            let settings_path = home.join(
4852                crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
4853            );
4854            let json =
4855                crate::network_allowlist_catalog::claude_station_network_settings_json(&domains);
4856            crate::session_environment::secure_create_file(&settings_path, json.as_bytes()).map_err(
4857                |_| {
4858                    failure(
4859                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4860                        "Claude station network settings sidecar could not be written under ProviderHome",
4861                    )
4862                },
4863            )?;
4864            let overlay =
4865                crate::network_allowlist_catalog::claude_settings_network_overlay_args(&settings_path);
4866            return Ok(overlay.into_iter().map(std::ffi::OsString::from).collect());
4867        }
4868
4869        let Some(native) = entry.provider_native.as_ref() else {
4870            return Ok(Vec::new());
4871        };
4872        let overlay = crate::network_allowlist_catalog::resolve_provider_native_launch_overlay(
4873            provider.as_str(),
4874            approval_level,
4875            native,
4876        )
4877        .map_err(|message| {
4878            failure(
4879                NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4880                &message,
4881            )
4882        })?;
4883        if overlay.is_empty() {
4884            return Ok(Vec::new());
4885        }
4886        if !matches!(mode, SessionMode::Pty | SessionMode::Acp) {
4887            return Err(failure(
4888                NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4889                "Codex network_access -c overlay requires PTY or ACP transport",
4890            ));
4891        }
4892        Ok(overlay.into_iter().map(std::ffi::OsString::from).collect())
4893    }
4894
4895    /// Backward-compatible alias used by Codex-focused unit tests.
4896    fn resolve_codex_network_access_overlay_args(
4897        &self,
4898        provider: &AgentId,
4899        mode: SessionMode,
4900        approval_level: ApprovalLevel,
4901        network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4902    ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4903        self.resolve_network_allowlist_overlay_args(
4904            provider,
4905            mode,
4906            approval_level,
4907            network_allowlist,
4908            None,
4909        )
4910    }
4911
4912    fn resolve_environment_profile(
4913        &self,
4914        resolved: &ResolvedSpawnSpec,
4915    ) -> Result<Option<ResolvedEnvironmentProfileReceipt>, NodeFailure> {
4916        // Station network allowlist catalog enforce (plan
4917        // station-network-and-browser-profile-knobs §2.1 / §4): empty-default
4918        // catalog — unknown ids refuse rather than silent ambient. Runs even
4919        // when no environment_profile_id is present so a bare allowlist still
4920        // cannot bypass the catalog. Never cookies / OAuth / proxy credentials.
4921        if let Some(allowlist_id) = resolved.network_allowlist.as_ref() {
4922            let catalog = self
4923                .network_allowlist_catalog
4924                .read()
4925                .unwrap_or_else(|poisoned| poisoned.into_inner());
4926            let Some(entry) = catalog.get(allowlist_id) else {
4927                return Err(failure(
4928                    NodeFailureCode::UnknownNetworkAllowlist,
4929                    "spawn network allowlist is unavailable on this node",
4930                ));
4931            };
4932            // Track B: refuse provider-native mappings this provider cannot
4933            // honor (e.g. codex_network_access on Claude). Never silent ambient.
4934            if let Some(native) = entry.provider_native.as_ref() {
4935                if let Err(message) = crate::network_allowlist_catalog::provider_native_mapping_supported(
4936                    resolved.provider.as_str(),
4937                    resolved.approval_level,
4938                    native,
4939                ) {
4940                    return Err(failure(
4941                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4942                        &message,
4943                    ));
4944                }
4945            }
4946        }
4947        // Station knobs Track A (dig2browser-station-probe-and-network-permit-set):
4948        // when feature `dig2-station-probe` is on and browser_profile_id is set,
4949        // cheap named-pipe path reachability must succeed (Windows
4950        // `\\.\pipe\{suffix}`); platform-unsupported or unreachable → clear
4951        // NodeFailureCode. Runs even without environment_profile_id (same
4952        // placement spirit as allowlist catalog). Feature off keeps stub echo.
4953        // Never cookies / OAuth / proxy credentials. Exclusive lease is
4954        // acquired later in spawn_session_with_deadline (after instance id).
4955        #[cfg(feature = "dig2-station-probe")]
4956        if resolved.browser_profile_id.is_some() {
4957            use crate::dig2_station_probe::{probe_station_reachable, StationProbeError};
4958            match probe_station_reachable() {
4959                Ok(()) => {}
4960                Err(StationProbeError::UnsupportedPlatform)
4961                | Err(StationProbeError::InvalidPipeSuffix) => {
4962                    return Err(failure(
4963                        NodeFailureCode::BrowserStationProbeUnavailable,
4964                        "dig2browser station probe unavailable on this platform",
4965                    ));
4966                }
4967                Err(StationProbeError::Unreachable) => {
4968                    return Err(failure(
4969                        NodeFailureCode::BrowserStationUnreachable,
4970                        "dig2browser station named-pipe is unreachable",
4971                    ));
4972                }
4973            }
4974        }
4975        let Some(profile_id) = resolved.environment_profile_id.as_ref() else {
4976            return Ok(None);
4977        };
4978        let profiles = self
4979            .environment_profiles
4980            .read()
4981            .unwrap_or_else(|poisoned| poisoned.into_inner());
4982        let binding = profiles.get(profile_id).ok_or_else(|| {
4983            failure(
4984                NodeFailureCode::UnknownEnvironmentProfile,
4985                "spawn environment profile is unavailable on this node",
4986            )
4987        })?;
4988        if binding.provider != resolved.provider
4989            || binding.native_profile_id(resolved.mode).is_none()
4990        {
4991            return Err(failure(
4992                NodeFailureCode::EnvironmentProfileBindingMismatch,
4993                "spawn environment profile does not match the provider and mode",
4994            ));
4995        }
4996        // Echo opaque network_allowlist / browser_profile_id onto the receipt
4997        // after catalog + optional dig2 probe above. Empty/whitespace ids
4998        // already refuse at Spawn*Id construction.
4999        Ok(Some(ResolvedEnvironmentProfileReceipt {
5000            profile_id: binding.id.clone(),
5001            profile_revision: binding.revision.clone(),
5002            network_allowlist: resolved.network_allowlist.clone(),
5003            browser_profile_id: resolved.browser_profile_id.clone(),
5004        }))
5005    }
5006
5007    fn resolve_bundle(
5008        &self,
5009        resolved: &ResolvedSpawnSpec,
5010        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5011    ) -> Result<Option<ResolvedBundleReceipt>, NodeFailure> {
5012        let Some(bundle_id) = resolved.bundle_id.as_ref() else {
5013            return Ok(None);
5014        };
5015        let catalog = self.bundle_catalog
5016            .read()
5017            .unwrap_or_else(|poisoned| poisoned.into_inner());
5018        let bundle = catalog.get(bundle_id).ok_or_else(|| {
5019                failure(
5020                    NodeFailureCode::UnknownBundle,
5021                    "spawn bundle is unavailable on this node",
5022                )
5023            })?;
5024        self.bundle_layout(
5025            &resolved.provider,
5026            resolved.mode,
5027            environment_profile,
5028            bundle,
5029        )?;
5030        Ok(Some(bundle.receipt()))
5031    }
5032
5033    fn begin_delivery_stage(
5034        &self,
5035        manifest: DeliveryBundleManifestV2,
5036    ) -> Result<NodeResponse, NodeFailure> {
5037        let mut store = self
5038            .delivery_store
5039            .lock()
5040            .unwrap_or_else(|poisoned| poisoned.into_inner());
5041        let store = store.as_mut().ok_or_else(|| {
5042            failure(
5043                NodeFailureCode::DeliveryStageStorageFailed,
5044                "node delivery store is unavailable",
5045            )
5046        })?;
5047        let begun = store.begin(manifest).map_err(delivery_failure)?;
5048        Ok(NodeResponse::DeliveryStageBegun {
5049            stage_id: begun.stage_id,
5050            manifest_digest: begun.manifest_digest,
5051            missing_blobs: begun.missing_blobs,
5052        })
5053    }
5054
5055    fn put_delivery_blob_chunk(
5056        &self,
5057        stage_id: DeliveryStageId,
5058        blob_digest: DeliveryBlobDigestV1,
5059        offset: u64,
5060        chunk_hex: DeliveryBlobChunkHexV1,
5061    ) -> Result<NodeResponse, NodeFailure> {
5062        let mut store = self
5063            .delivery_store
5064            .lock()
5065            .unwrap_or_else(|poisoned| poisoned.into_inner());
5066        let store = store.as_mut().ok_or_else(|| {
5067            failure(
5068                NodeFailureCode::DeliveryStageStorageFailed,
5069                "node delivery store is unavailable",
5070            )
5071        })?;
5072        let accepted = store
5073            .put_chunk(&stage_id, &blob_digest, offset, &chunk_hex.decode())
5074            .map_err(delivery_failure)?;
5075        Ok(NodeResponse::DeliveryBlobChunkAccepted {
5076            stage_id: accepted.stage_id,
5077            blob_digest: accepted.blob_digest,
5078            next_offset: accepted.next_offset,
5079        })
5080    }
5081
5082    fn commit_delivery_stage(
5083        &self,
5084        stage_id: DeliveryStageId,
5085    ) -> Result<DeliveryCommitReceiptV1, NodeFailure> {
5086        let mut store = self
5087            .delivery_store
5088            .lock()
5089            .unwrap_or_else(|poisoned| poisoned.into_inner());
5090        let store = store.as_mut().ok_or_else(|| {
5091            failure(
5092                NodeFailureCode::DeliveryStageStorageFailed,
5093                "node delivery store is unavailable",
5094            )
5095        })?;
5096        let prepared = store.prepare_commit(&stage_id).map_err(delivery_failure)?;
5097        let mut catalog = self
5098            .bundle_catalog
5099            .write()
5100            .unwrap_or_else(|poisoned| poisoned.into_inner());
5101        let mut next_catalog = catalog.clone();
5102        next_catalog
5103            .insert_idempotent(prepared.bundle().clone())
5104            .map_err(|_| {
5105                failure(
5106                    NodeFailureCode::DeliveryStageConflict,
5107                    "delivery bundle identity conflicts with installed content",
5108                )
5109            })?;
5110        let receipt = store.publish_commit(prepared).map_err(delivery_failure)?;
5111        *catalog = next_catalog;
5112        Ok(receipt)
5113    }
5114
5115    fn abort_delivery_stage(
5116        &self,
5117        stage_id: &DeliveryStageId,
5118    ) -> Result<(), NodeFailure> {
5119        let mut store = self
5120            .delivery_store
5121            .lock()
5122            .unwrap_or_else(|poisoned| poisoned.into_inner());
5123        let store = store.as_mut().ok_or_else(|| {
5124            failure(
5125                NodeFailureCode::DeliveryStageStorageFailed,
5126                "node delivery store is unavailable",
5127            )
5128        })?;
5129        store.abort(stage_id).map_err(delivery_failure)
5130    }
5131
5132    fn resolve_context(
5133        &self,
5134        resolved: &ResolvedSpawnSpec,
5135    ) -> Result<Option<ResolvedContextPackReceipt>, NodeFailure> {
5136        let Some(context_id) = resolved.context_id.as_ref() else {
5137            return Ok(None);
5138        };
5139        self.context_catalog
5140            .read()
5141            .unwrap_or_else(|poisoned| poisoned.into_inner())
5142            .get(context_id)
5143            .map(|context| context.receipt().clone())
5144            .ok_or_else(|| {
5145                failure(
5146                    NodeFailureCode::UnknownContextPack,
5147                    "spawn context pack is unavailable on this node",
5148                )
5149            })
5150            .map(Some)
5151    }
5152
5153    fn bundle_layout(
5154        &self,
5155        provider: &AgentId,
5156        mode: SessionMode,
5157        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5158        bundle: &NodeBundle,
5159    ) -> Result<BundleProviderLayout, NodeFailure> {
5160        let layout = validate_bundle_binding(provider, mode, bundle).map_err(|_| {
5161            failure(
5162                NodeFailureCode::BundleBindingMismatch,
5163                "spawn bundle does not match the provider and mode",
5164            )
5165        })?;
5166        if layout != BundleProviderLayout::Codex {
5167            return Ok(layout);
5168        }
5169        let profile = environment_profile.and_then(|receipt| {
5170            let profiles = self
5171                .environment_profiles
5172                .read()
5173                .unwrap_or_else(|poisoned| poisoned.into_inner());
5174            let binding = profiles.get(&receipt.profile_id)?;
5175            if binding.revision != receipt.profile_revision
5176                || &binding.provider != provider
5177                || binding.native_profile_id(mode).is_none()
5178            {
5179                return None;
5180            }
5181            self.environment_materialization_profiles
5182                .read()
5183                .unwrap_or_else(|poisoned| poisoned.into_inner())
5184                .get(&receipt.profile_id)
5185                .cloned()
5186        });
5187        if !profile.is_some_and(|profile| profile.supports_bundle_layout(layout)) {
5188            return Err(failure(
5189                NodeFailureCode::BundleBindingMismatch,
5190                "Codex bundle requires an exact isolated CODEX_HOME profile",
5191            ));
5192        }
5193        Ok(layout)
5194    }
5195
5196    fn select_environment_profile(
5197        &self,
5198        instance_id: AgentInstanceId,
5199        provider: &AgentId,
5200        mode: SessionMode,
5201        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5202    ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
5203        let Some(environment_profile) = environment_profile else {
5204            return Ok(None);
5205        };
5206        let native_profile_id = {
5207            let profiles = self
5208                .environment_profiles
5209                .read()
5210                .unwrap_or_else(|poisoned| poisoned.into_inner());
5211            let binding = profiles
5212                .get(&environment_profile.profile_id)
5213                .ok_or_else(|| {
5214                    failure(
5215                        NodeFailureCode::UnknownEnvironmentProfile,
5216                        "session environment profile is unavailable on this node",
5217                    )
5218                })?;
5219            if &binding.revision != &environment_profile.profile_revision
5220                || &binding.provider != provider
5221            {
5222                return Err(failure(
5223                    NodeFailureCode::EnvironmentProfileBindingMismatch,
5224                    "session environment profile revision or provider changed",
5225                ));
5226            }
5227            binding.native_profile_id(mode).cloned().ok_or_else(|| {
5228                failure(
5229                    NodeFailureCode::EnvironmentProfileBindingMismatch,
5230                    "session environment profile does not support this mode",
5231                )
5232            })?
5233        };
5234        let control = self.native_launch_profile_control.clone().ok_or_else(|| {
5235            failure(
5236                NodeFailureCode::BackendOperationFailed,
5237                "native environment profile controller is unavailable",
5238            )
5239        })?;
5240        control
5241            .select_native_launch_profile(instance_id, native_profile_id)
5242            .map_err(|_| {
5243                failure(
5244                    NodeFailureCode::BackendOperationFailed,
5245                    "native environment profile selection failed",
5246                )
5247            })?;
5248        Ok(Some(NativeEnvironmentSelectionGuard {
5249            control: Some(control),
5250            instance_id,
5251        }))
5252    }
5253
5254    fn install_prepared_launch_overlay(
5255        &self,
5256        instance_id: AgentInstanceId,
5257        overlay: PreparedNativeLaunchOverlay,
5258    ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
5259        let control = self.native_launch_profile_control.clone().ok_or_else(|| {
5260            failure(
5261                NodeFailureCode::BackendOperationFailed,
5262                "native launch profile controller is unavailable",
5263            )
5264        })?;
5265        match overlay {
5266            PreparedNativeLaunchOverlay::Environment(overlay) => {
5267                control
5268                    .install_native_launch_environment_overlay(instance_id, overlay)
5269                    .map_err(|_| failure(
5270                        NodeFailureCode::BackendOperationFailed,
5271                        "session environment overlay installation failed",
5272                    ))?;
5273                Ok(None)
5274            }
5275            PreparedNativeLaunchOverlay::Instance(overlay) => {
5276                control
5277                    .install_native_instance_launch_overlay(instance_id, overlay)
5278                    .map_err(|_| failure(
5279                        NodeFailureCode::BundleBindingMismatch,
5280                        "session bundle launch overlay installation failed",
5281                    ))?;
5282                Ok(Some(NativeInstanceOverlayGuard {
5283                    control: Some(control),
5284                    instance_id,
5285                }))
5286            }
5287        }
5288    }
5289
5290    fn environment_materialization_profile(
5291        &self,
5292        environment_profile: &ResolvedEnvironmentProfileReceipt,
5293    ) -> Option<NodeSessionMaterializationProfile> {
5294        self.environment_materialization_profiles
5295            .read()
5296            .unwrap_or_else(|poisoned| poisoned.into_inner())
5297            .get(&environment_profile.profile_id)
5298            .cloned()
5299    }
5300
5301    fn materialization_profile(
5302        &self,
5303        provider: &AgentId,
5304        mode: SessionMode,
5305        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5306        bundle: Option<&ResolvedBundleReceipt>,
5307        context: Option<&ResolvedContextPackReceipt>,
5308    ) -> Result<Option<(
5309        NodeSessionMaterializationProfile,
5310        bool,
5311        Option<BundleProviderLayout>,
5312    )>, NodeFailure> {
5313        let environment = environment_profile
5314            .and_then(|receipt| self.environment_materialization_profile(receipt));
5315        let (bundle, bundle_layout) = match bundle {
5316            Some(receipt) => {
5317                let catalog = self.bundle_catalog.read()
5318                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5319                let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
5320                    NodeFailureCode::UnknownBundle,
5321                    "session bundle is unavailable on this node",
5322                ))?;
5323                if installed.receipt() != *receipt {
5324                    return Err(failure(
5325                        NodeFailureCode::BundleBindingMismatch,
5326                        "session bundle revision or digest changed",
5327                    ));
5328                }
5329                let layout = self.bundle_layout(
5330                    provider,
5331                    mode,
5332                    environment_profile,
5333                    installed,
5334                )?;
5335                (Some(installed.clone()), Some(layout))
5336            }
5337            None => (None, None),
5338        };
5339        let has_environment_overlay = environment.is_some() || context.is_some();
5340        let profile = match (environment, bundle.as_ref(), bundle_layout) {
5341            (None, None, None) => None,
5342            (Some(profile), None, None) => Some(Ok(profile)),
5343            (None, Some(bundle), Some(layout)) => {
5344                Some(NodeSessionMaterializationProfile::from_bundle(bundle, layout))
5345            }
5346            (Some(profile), Some(bundle), Some(layout)) => {
5347                Some(profile.with_bundle(bundle, layout))
5348            }
5349            _ => {
5350                return Err(failure(
5351                    NodeFailureCode::BundleBindingMismatch,
5352                    "session bundle provider layout is unavailable",
5353                ));
5354            }
5355        };
5356        let profile = profile
5357            .transpose()
5358            .map_err(|_| failure(
5359                NodeFailureCode::BundleMaterializationFailed,
5360                "session bundle materialization profile is invalid",
5361            ))?;
5362        let profile = match (profile, context) {
5363            (Some(profile), Some(receipt)) => {
5364                let catalog = self.context_catalog
5365                    .read()
5366                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5367                let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
5368                    NodeFailureCode::UnknownContextPack,
5369                    "session context pack is unavailable on this node",
5370                ))?;
5371                if pack.receipt() != receipt {
5372                    return Err(failure(
5373                        NodeFailureCode::ContextPackMaterializationFailed,
5374                        "session context pack identity changed",
5375                    ));
5376                }
5377                profile.with_context(pack)
5378            }
5379            (None, Some(receipt)) => {
5380                let catalog = self.context_catalog
5381                    .read()
5382                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5383                let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
5384                    NodeFailureCode::UnknownContextPack,
5385                    "session context pack is unavailable on this node",
5386                ))?;
5387                if pack.receipt() != receipt {
5388                    return Err(failure(
5389                        NodeFailureCode::ContextPackMaterializationFailed,
5390                        "session context pack identity changed",
5391                    ));
5392                }
5393                NodeSessionMaterializationProfile::from_context(pack)
5394            }
5395            (Some(profile), None) => Ok(profile),
5396            (None, None) => return Ok(None),
5397        }
5398        .map_err(|_| failure(
5399            NodeFailureCode::ContextPackMaterializationFailed,
5400            "session context pack materialization profile is invalid",
5401        ))?;
5402        Ok(Some((profile, has_environment_overlay, bundle_layout)))
5403    }
5404
5405    fn prepare_session_materialization<'a>(
5406        &'a self,
5407        address: &SessionAddress,
5408        provider: &AgentId,
5409        mode: SessionMode,
5410        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5411        bundle: Option<&ResolvedBundleReceipt>,
5412        context: Option<&ResolvedContextPackReceipt>,
5413        managed_lease_id: Option<ManagedWorktreeLeaseId>,
5414    ) -> Result<Option<(Option<PreparedNativeLaunchOverlay>, SessionMaterializationGuard<'a>)>, NodeFailure> {
5415        let Some((profile, has_environment_overlay, bundle_layout)) =
5416            self.materialization_profile(provider, mode, environment_profile, bundle, context)?
5417        else {
5418            return Ok(None);
5419        };
5420        let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5421            failure(
5422                NodeFailureCode::BackendOperationFailed,
5423                "session environment materializer is unavailable",
5424            )
5425        })?;
5426        let materialization_failure_code = if context.is_some() {
5427            NodeFailureCode::ContextPackMaterializationFailed
5428        } else if bundle.is_some() {
5429            NodeFailureCode::BundleMaterializationFailed
5430        } else {
5431            NodeFailureCode::BackendOperationFailed
5432        };
5433        let id = MaterializationId::new(format!(
5434            "mat-{}-{}-{}",
5435            self.incarnation_id,
5436            address.session.instance_id.0,
5437            address.session.generation.0,
5438        ))
5439        .map_err(|_| {
5440            failure(
5441                NodeFailureCode::BackendOperationFailed,
5442                "session environment materialization identity failed",
5443            )
5444        })?;
5445        let owner = MaterializationOwner::Session {
5446            incarnation_id: self.incarnation_id,
5447            instance_id: address.session.instance_id,
5448            generation: address.session.generation,
5449        };
5450        let mut ownership = materializer
5451            .begin(
5452                id.clone(),
5453                environment_profile.cloned(),
5454                bundle.cloned(),
5455                context.cloned(),
5456                owner,
5457                managed_lease_id,
5458                &profile,
5459                unix_time_ms(),
5460            )
5461            .map_err(|_| {
5462                failure(
5463                    materialization_failure_code,
5464                    "session materialization preparation failed",
5465                )
5466            })?;
5467        {
5468            let _transaction = self
5469                .state_transaction
5470                .lock()
5471                .unwrap_or_else(|poisoned| poisoned.into_inner());
5472            let duplicate = self
5473                .materializations
5474                .lock()
5475                .unwrap_or_else(|poisoned| poisoned.into_inner())
5476                .insert(id.clone(), ownership.clone());
5477            if duplicate.is_some() {
5478                return Err(failure(
5479                    NodeFailureCode::BackendOperationFailed,
5480                    "session environment materialization identity conflict",
5481                ));
5482            }
5483            if let Err(error) = self.persist_state_locked() {
5484                self.materializations
5485                    .lock()
5486                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5487                    .remove(&id);
5488                return Err(persistence_failure(error));
5489            }
5490        }
5491        let environment = match materializer.materialize(&mut ownership, &profile, unix_time_ms()) {
5492            Ok(environment) => environment,
5493            Err(_) => {
5494                let transaction = self
5495                    .state_transaction
5496                    .lock()
5497                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5498                self.materializations
5499                    .lock()
5500                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5501                    .insert(id.clone(), ownership.clone());
5502                if self.persist_state_locked().is_err() {
5503                    self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5504                }
5505                drop(transaction);
5506                if ownership.state() == MaterializationState::CleanupRequired {
5507                    let _ = self.cleanup_materialization(&id);
5508                }
5509                return Err(failure(
5510                    materialization_failure_code,
5511                    "session materialization failed",
5512                ));
5513            }
5514        };
5515        {
5516            let _transaction = self
5517                .state_transaction
5518                .lock()
5519                .unwrap_or_else(|poisoned| poisoned.into_inner());
5520            self.materializations
5521                .lock()
5522                .unwrap_or_else(|poisoned| poisoned.into_inner())
5523                .insert(id.clone(), ownership.clone());
5524            if let Err(error) = self.persist_state_locked() {
5525                drop(_transaction);
5526                let _ = self.cleanup_materialization(&id);
5527                return Err(persistence_failure(error));
5528            }
5529        }
5530        let guard = SessionMaterializationGuard {
5531            shared: self,
5532            id: Some(id),
5533            provider_home: Some(ownership.provider_home().to_path_buf()),
5534        };
5535        let bundle_arguments = match bundle {
5536            Some(receipt) => {
5537                let catalog = self.bundle_catalog.read()
5538                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5539                let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
5540                    NodeFailureCode::UnknownBundle,
5541                    "session bundle is unavailable on this node",
5542                ))?;
5543                if installed.receipt() != *receipt {
5544                    return Err(failure(
5545                        NodeFailureCode::BundleBindingMismatch,
5546                        "session bundle revision or digest changed",
5547                    ));
5548                }
5549                let layout = bundle_layout.ok_or_else(|| failure(
5550                    NodeFailureCode::BundleBindingMismatch,
5551                    "materialized bundle provider layout is unavailable",
5552                ))?;
5553                materializer.revalidate_bundle(&ownership, installed, layout).map_err(|_| {
5554                    failure(
5555                        NodeFailureCode::BundleMaterializationFailed,
5556                        "session bundle byte revalidation failed",
5557                    )
5558                })?;
5559                Some(bundle_launch_arguments(
5560                    layout,
5561                    ownership.bundle_root(),
5562                    ownership.provider_home(),
5563                ).map_err(|_| failure(
5564                    NodeFailureCode::BundleBindingMismatch,
5565                    "session bundle launch binding is invalid",
5566                ))?)
5567            }
5568            None => None,
5569        };
5570        if let Some(receipt) = context {
5571            materializer
5572                .revalidate_context(&ownership, receipt)
5573                .map_err(|_| failure(
5574                    NodeFailureCode::ContextPackMaterializationFailed,
5575                    "session context pack byte revalidation failed",
5576                ))?;
5577        }
5578        let transport = match mode {
5579            SessionMode::Pty => TransportKind::Pty,
5580            SessionMode::Inline => TransportKind::Pipe,
5581            SessionMode::Acp => TransportKind::Acp,
5582        };
5583        let overlay = if let Some(extra_args) = bundle_arguments {
5584            Some(PreparedNativeLaunchOverlay::Instance(
5585                NativeInstanceLaunchOverlay::new(
5586                    provider.clone(),
5587                    transport,
5588                    if has_environment_overlay {
5589                        environment
5590                    } else {
5591                        Vec::new()
5592                    },
5593                    extra_args,
5594                )
5595                .map_err(|_| {
5596                    failure(
5597                        NodeFailureCode::BundleBindingMismatch,
5598                        "session bundle launch overlay is invalid",
5599                    )
5600                })?,
5601            ))
5602        } else if has_environment_overlay {
5603            let context_only = environment_profile.is_none()
5604                && bundle.is_none()
5605                && context.is_some();
5606            let overlay = if context_only {
5607                NativeLaunchEnvironmentOverlay::new_context_root(
5608                    provider.clone(),
5609                    transport,
5610                    environment,
5611                )
5612                .map_err(|_| {
5613                    failure(
5614                        NodeFailureCode::ContextPackMaterializationFailed,
5615                        "context-only launch overlay is invalid",
5616                    )
5617                })?
5618            } else {
5619                NativeLaunchEnvironmentOverlay::new(
5620                    provider.clone(),
5621                    transport,
5622                    environment,
5623                )
5624                .map_err(|_| {
5625                    failure(
5626                        NodeFailureCode::BackendOperationFailed,
5627                        "session environment overlay is invalid",
5628                    )
5629                })?
5630            };
5631            Some(PreparedNativeLaunchOverlay::Environment(overlay))
5632        } else {
5633            None
5634        };
5635        Ok(Some((overlay, guard)))
5636    }
5637
5638    fn cleanup_materialization(&self, id: &MaterializationId) -> Result<(), NodeFailure> {
5639        let Some(mut ownership) = self
5640            .materializations
5641            .lock()
5642            .unwrap_or_else(|poisoned| poisoned.into_inner())
5643            .get(id)
5644            .cloned()
5645        else {
5646            return Ok(());
5647        };
5648        if ownership.state() != MaterializationState::CleanupRequired {
5649            ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5650                failure(
5651                    NodeFailureCode::BackendOperationFailed,
5652                    "session environment cleanup state is invalid",
5653                )
5654            })?;
5655        }
5656        {
5657            let _transaction = self
5658                .state_transaction
5659                .lock()
5660                .unwrap_or_else(|poisoned| poisoned.into_inner());
5661            let previous = self
5662                .materializations
5663                .lock()
5664                .unwrap_or_else(|poisoned| poisoned.into_inner())
5665                .insert(id.clone(), ownership.clone());
5666            if let Err(error) = self.persist_state_locked() {
5667                let mut materializations = self
5668                    .materializations
5669                    .lock()
5670                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5671                if let Some(previous) = previous {
5672                    materializations.insert(id.clone(), previous);
5673                }
5674                return Err(persistence_failure(error));
5675            }
5676        }
5677        let cleanup = self
5678            .session_environment_materializer
5679            .as_ref()
5680            .ok_or(())
5681            .and_then(|materializer| materializer.cleanup(&ownership).map_err(|_| ()));
5682        if cleanup.is_err() {
5683            let _ = ownership.mark_recovery_required(unix_time_ms());
5684            self.materializations
5685                .lock()
5686                .unwrap_or_else(|poisoned| poisoned.into_inner())
5687                .insert(id.clone(), ownership);
5688            if self.persist_state().is_err() {
5689                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5690            }
5691            return Err(failure(
5692                NodeFailureCode::BackendOperationFailed,
5693                "session environment cleanup requires recovery",
5694            ));
5695        }
5696        let _transaction = self
5697            .state_transaction
5698            .lock()
5699            .unwrap_or_else(|poisoned| poisoned.into_inner());
5700        self.materializations
5701            .lock()
5702            .unwrap_or_else(|poisoned| poisoned.into_inner())
5703            .remove(id);
5704        if let Err(error) = self.persist_state_locked() {
5705            self.materializations
5706                .lock()
5707                .unwrap_or_else(|poisoned| poisoned.into_inner())
5708                .insert(id.clone(), ownership);
5709            return Err(persistence_failure(error));
5710        }
5711        Ok(())
5712    }
5713
5714    fn resolve_record_materialization(
5715        &self,
5716        record_id: &SessionRecordId,
5717        provider: &AgentId,
5718        mode: SessionMode,
5719        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5720        bundle: Option<&ResolvedBundleReceipt>,
5721        context: Option<&ResolvedContextPackReceipt>,
5722    ) -> Result<Option<(MaterializationId, Option<PreparedNativeLaunchOverlay>)>, NodeFailure> {
5723        let ownership = {
5724            let materializations = self
5725                .materializations
5726                .lock()
5727                .unwrap_or_else(|poisoned| poisoned.into_inner());
5728            let mut matches = materializations.values().filter(|record| {
5729                record.owner()
5730                    == &MaterializationOwner::Record {
5731                        record_id: record_id.clone(),
5732                    }
5733            });
5734            let ownership = matches.next().cloned();
5735            if matches.next().is_some() {
5736                return Err(failure(
5737                    NodeFailureCode::BackendOperationFailed,
5738                    "managed session has conflicting environment ownership",
5739                ));
5740            }
5741            ownership
5742        };
5743        if let (Some(ownership), Some(receipt)) = (ownership.as_ref(), context) {
5744            let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5745                failure(
5746                    NodeFailureCode::ContextPackMaterializationFailed,
5747                    "session context materializer is unavailable",
5748                )
5749            })?;
5750            let pack = materializer
5751                .revalidate_context(ownership, receipt)
5752                .map_err(|_| {
5753                    self.mark_materialization_recovery_required(ownership.id());
5754                    failure(
5755                        NodeFailureCode::ContextPackMaterializationFailed,
5756                        "managed session context pack revalidation failed",
5757                    )
5758                })?;
5759            self.context_catalog
5760                .write()
5761                .unwrap_or_else(|poisoned| poisoned.into_inner())
5762                .insert(pack)
5763                .map_err(|_| failure(
5764                    NodeFailureCode::ContextPackMaterializationFailed,
5765                    "managed session context pack catalog restore failed",
5766                ))?;
5767        }
5768        let materialization = self.materialization_profile(
5769            provider,
5770            mode,
5771            environment_profile,
5772            bundle,
5773            context,
5774        )?;
5775        match (ownership, materialization) {
5776            (None, None) => Ok(None),
5777            (None, Some(_)) | (Some(_), None) => Err(failure(
5778                NodeFailureCode::BackendOperationFailed,
5779                "managed session materialization is unavailable",
5780            )),
5781            (Some(ownership), Some((profile, has_environment_materialization, bundle_layout))) => {
5782                if ownership.environment_profile() != environment_profile
5783                    || ownership.bundle() != bundle
5784                    || ownership.context() != context
5785                    || ownership.state() != MaterializationState::Ready
5786                {
5787                    self.mark_materialization_recovery_required(ownership.id());
5788                    return Err(failure(
5789                        NodeFailureCode::BackendOperationFailed,
5790                        "managed session environment ownership requires recovery",
5791                    ));
5792                }
5793                let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5794                    failure(
5795                        NodeFailureCode::BackendOperationFailed,
5796                        "session environment materializer is unavailable",
5797                    )
5798                })?;
5799                if materializer.revalidate(&ownership).is_err() {
5800                    self.mark_materialization_recovery_required(ownership.id());
5801                    return Err(failure(
5802                        NodeFailureCode::BackendOperationFailed,
5803                        "managed session environment revalidation failed",
5804                    ));
5805                }
5806                let bundle_arguments = if let Some(bundle_receipt) = bundle {
5807                    let catalog = self.bundle_catalog.read()
5808                        .unwrap_or_else(|poisoned| poisoned.into_inner());
5809                    let Some(installed) = catalog.get(&bundle_receipt.id) else {
5810                        return Err(failure(
5811                            NodeFailureCode::UnknownBundle,
5812                            "managed session bundle is unavailable",
5813                        ));
5814                    };
5815                    let layout = bundle_layout.ok_or_else(|| failure(
5816                        NodeFailureCode::BundleBindingMismatch,
5817                        "record bundle provider layout is unavailable",
5818                    ))?;
5819                    if materializer.revalidate_bundle(&ownership, installed, layout).is_err() {
5820                        drop(catalog);
5821                        self.mark_materialization_recovery_required(ownership.id());
5822                        return Err(failure(
5823                            NodeFailureCode::BundleMaterializationFailed,
5824                            "managed session bundle revalidation failed",
5825                        ));
5826                    }
5827                    Some(bundle_launch_arguments(
5828                        layout,
5829                        ownership.bundle_root(),
5830                        ownership.provider_home(),
5831                    ).map_err(|_| failure(
5832                        NodeFailureCode::BundleBindingMismatch,
5833                        "managed session bundle launch binding is invalid",
5834                    ))?)
5835                } else {
5836                    None
5837                };
5838                if !has_environment_materialization {
5839                    let overlay = bundle_arguments.map(|extra_args| {
5840                        NativeInstanceLaunchOverlay::new(
5841                            provider.clone(),
5842                            TransportKind::Pty,
5843                            Vec::new(),
5844                            extra_args,
5845                        )
5846                        .map(PreparedNativeLaunchOverlay::Instance)
5847                        .map_err(|_| failure(
5848                            NodeFailureCode::BundleBindingMismatch,
5849                            "managed session bundle launch overlay is invalid",
5850                        ))
5851                    }).transpose()?;
5852                    return Ok(Some((ownership.id().clone(), overlay)));
5853                }
5854                let environment = match materializer.resolve_environment(&ownership, &profile) {
5855                    Ok(environment) => environment,
5856                    Err(
5857                        SessionEnvironmentMaterializeError::SecretUnavailable
5858                        | SessionEnvironmentMaterializeError::SecretDenied
5859                        | SessionEnvironmentMaterializeError::InvalidSecretValue,
5860                    ) => {
5861                        return Err(failure(
5862                            NodeFailureCode::BackendOperationFailed,
5863                            "managed session environment resolution failed",
5864                        ));
5865                    }
5866                    Err(
5867                        SessionEnvironmentMaterializeError::InvalidRoot
5868                        | SessionEnvironmentMaterializeError::OwnershipMismatch
5869                        | SessionEnvironmentMaterializeError::Filesystem(_)
5870                        | SessionEnvironmentMaterializeError::Record(_),
5871                    ) => {
5872                        self.mark_materialization_recovery_required(ownership.id());
5873                        return Err(failure(
5874                            NodeFailureCode::BackendOperationFailed,
5875                            "managed session environment resolution failed",
5876                        ));
5877                    }
5878                };
5879                let transport = match mode {
5880                    SessionMode::Pty => TransportKind::Pty,
5881                    SessionMode::Inline => TransportKind::Pipe,
5882                    SessionMode::Acp => TransportKind::Acp,
5883                };
5884                let overlay = match bundle_arguments {
5885                    Some(extra_args) => PreparedNativeLaunchOverlay::Instance(
5886                        NativeInstanceLaunchOverlay::new(
5887                            provider.clone(),
5888                            transport,
5889                            environment,
5890                            extra_args,
5891                        )
5892                        .map_err(|_| failure(
5893                            NodeFailureCode::BundleBindingMismatch,
5894                            "managed session bundle launch overlay is invalid",
5895                        ))?,
5896                    ),
5897                    None => PreparedNativeLaunchOverlay::Environment(
5898                        NativeLaunchEnvironmentOverlay::new(
5899                            provider.clone(),
5900                            transport,
5901                            environment,
5902                        )
5903                        .map_err(|_| {
5904                            failure(
5905                                NodeFailureCode::BackendOperationFailed,
5906                                "managed session environment overlay is invalid",
5907                            )
5908                        })?,
5909                    ),
5910                };
5911                Ok(Some((ownership.id().clone(), Some(overlay))))
5912            }
5913        }
5914    }
5915
5916    fn destroy_record_materialization(
5917        &self,
5918        record_id: &SessionRecordId,
5919    ) -> Result<bool, NodeFailure> {
5920        let ids = self
5921            .materializations
5922            .lock()
5923            .unwrap_or_else(|poisoned| poisoned.into_inner())
5924            .values()
5925            .filter_map(|record| {
5926                (record.owner()
5927                    == &MaterializationOwner::Record {
5928                        record_id: record_id.clone(),
5929                    })
5930                .then(|| record.id().clone())
5931            })
5932            .collect::<Vec<_>>();
5933        if ids.len() > 1 {
5934            return Err(failure(
5935                NodeFailureCode::BackendOperationFailed,
5936                "managed session has conflicting environment ownership",
5937            ));
5938        }
5939        let Some(id) = ids.first().cloned() else {
5940            return Ok(false);
5941        };
5942        let (terminal_record, mut cleanup_ownership) = {
5943            let _transaction = self
5944                .state_transaction
5945                .lock()
5946                .unwrap_or_else(|poisoned| poisoned.into_inner());
5947            let original_record = self.record(record_id)?;
5948            let mut terminal_record = original_record.clone();
5949            terminal_record.provider_session = None;
5950            terminal_record.active_session = None;
5951            terminal_record.state = ManagedSessionState::Unavailable;
5952            terminal_record.last_error = Some("environment-profile-unavailable".to_owned());
5953            terminal_record.updated_at_unix_ms = unix_time_ms();
5954            let original_ownership = self
5955                .materializations
5956                .lock()
5957                .unwrap_or_else(|poisoned| poisoned.into_inner())
5958                .get(&id)
5959                .cloned()
5960                .ok_or_else(|| {
5961                    failure(
5962                        NodeFailureCode::BackendOperationFailed,
5963                        "managed session environment ownership disappeared during cleanup",
5964                    )
5965                })?;
5966            let mut cleanup_ownership = original_ownership.clone();
5967            if cleanup_ownership.state() != MaterializationState::CleanupRequired {
5968                cleanup_ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5969                    failure(
5970                        NodeFailureCode::BackendOperationFailed,
5971                        "managed session environment cleanup state is invalid",
5972                    )
5973                })?;
5974            }
5975            self.session_records
5976                .lock()
5977                .unwrap_or_else(|poisoned| poisoned.into_inner())
5978                .records
5979                .insert(record_id.clone(), terminal_record.clone());
5980            self.materializations
5981                .lock()
5982                .unwrap_or_else(|poisoned| poisoned.into_inner())
5983                .insert(id.clone(), cleanup_ownership.clone());
5984            if let Err(error) = self.persist_state_locked() {
5985                self.session_records
5986                    .lock()
5987                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5988                    .records
5989                    .insert(record_id.clone(), original_record);
5990                self.materializations
5991                    .lock()
5992                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5993                    .insert(id.clone(), original_ownership);
5994                return Err(persistence_failure(error));
5995            }
5996            (terminal_record, cleanup_ownership)
5997        };
5998        let cleanup = self
5999            .session_environment_materializer
6000            .as_ref()
6001            .ok_or(())
6002            .and_then(|materializer| materializer.cleanup(&cleanup_ownership).map_err(|_| ()));
6003        if cleanup.is_err() {
6004            let _ = cleanup_ownership.mark_recovery_required(unix_time_ms());
6005            self.materializations
6006                .lock()
6007                .unwrap_or_else(|poisoned| poisoned.into_inner())
6008                .insert(id.clone(), cleanup_ownership);
6009            if self.persist_state().is_err() {
6010                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6011            }
6012            return Err(failure(
6013                NodeFailureCode::BackendOperationFailed,
6014                "managed session environment cleanup requires recovery",
6015            ));
6016        }
6017        let _transaction = self
6018            .state_transaction
6019            .lock()
6020            .unwrap_or_else(|poisoned| poisoned.into_inner());
6021        self.remove_record_memory(record_id);
6022        self.materializations
6023            .lock()
6024            .unwrap_or_else(|poisoned| poisoned.into_inner())
6025            .remove(&id);
6026        if let Err(error) = self.persist_state_locked() {
6027            self.session_records
6028                .lock()
6029                .unwrap_or_else(|poisoned| poisoned.into_inner())
6030                .records
6031                .insert(record_id.clone(), terminal_record);
6032            self.materializations
6033                .lock()
6034                .unwrap_or_else(|poisoned| poisoned.into_inner())
6035                .insert(id, cleanup_ownership);
6036            return Err(persistence_failure(error));
6037        }
6038        Ok(true)
6039    }
6040
6041    fn mark_materialization_recovery_required(&self, id: &MaterializationId) {
6042        let changed = {
6043            let mut materializations = self
6044                .materializations
6045                .lock()
6046                .unwrap_or_else(|poisoned| poisoned.into_inner());
6047            let Some(ownership) = materializations.get_mut(id) else {
6048                return;
6049            };
6050            ownership.mark_recovery_required(unix_time_ms()).is_ok()
6051        };
6052        if changed && self.persist_state().is_err() {
6053            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6054        }
6055    }
6056
6057    fn reconcile_materializations(&self) {
6058        let records = self
6059            .materializations
6060            .lock()
6061            .unwrap_or_else(|poisoned| poisoned.into_inner())
6062            .values()
6063            .cloned()
6064            .collect::<Vec<_>>();
6065        for mut ownership in records {
6066            let result = match (ownership.state(), ownership.owner()) {
6067                (MaterializationState::Preparing, _)
6068                | (MaterializationState::CleanupRequired, _) => {
6069                    self.cleanup_materialization(ownership.id())
6070                }
6071                (MaterializationState::Ready, MaterializationOwner::Session { .. }) => {
6072                    if ownership.mark_recovery_required(unix_time_ms()).is_ok() {
6073                        self.materializations
6074                            .lock()
6075                            .unwrap_or_else(|poisoned| poisoned.into_inner())
6076                            .insert(ownership.id().clone(), ownership.clone());
6077                        self.persist_state().map_err(persistence_failure)
6078                    } else {
6079                        Err(failure(
6080                            NodeFailureCode::BackendOperationFailed,
6081                            "session environment reconciliation failed",
6082                        ))
6083                    }
6084                }
6085                (MaterializationState::Ready, MaterializationOwner::Record { record_id }) => self
6086                    .session_environment_materializer
6087                    .as_ref()
6088                    .ok_or_else(|| {
6089                        failure(
6090                            NodeFailureCode::BackendOperationFailed,
6091                            "session environment materializer is unavailable",
6092                        )
6093                    })
6094                    .and_then(|materializer| {
6095                        materializer.revalidate(&ownership).map_err(|_| failure(
6096                            NodeFailureCode::BackendOperationFailed,
6097                            "session materialization revalidation failed",
6098                        ))?;
6099                        let record = self.record(record_id)?;
6100                        if record.environment_profile.as_ref() != ownership.environment_profile()
6101                            || record.bundle.as_ref() != ownership.bundle()
6102                            || record.context.as_ref() != ownership.context()
6103                        {
6104                            return Err(failure(
6105                                NodeFailureCode::BackendOperationFailed,
6106                                "session materialization ownership changed after restart",
6107                            ));
6108                        }
6109                        if let Some(receipt) = ownership.bundle() {
6110                            let catalog = self.bundle_catalog.read()
6111                                .unwrap_or_else(|poisoned| poisoned.into_inner());
6112                            let bundle = catalog.get(&receipt.id).ok_or_else(|| failure(
6113                                NodeFailureCode::UnknownBundle,
6114                                "session bundle is unavailable after restart",
6115                            ))?;
6116                            if bundle.receipt() != *receipt {
6117                                return Err(failure(
6118                                    NodeFailureCode::BundleBindingMismatch,
6119                                    "session bundle changed after restart",
6120                                ));
6121                            }
6122                            let layout = self.bundle_layout(
6123                                &record.provider,
6124                                record.mode,
6125                                record.environment_profile.as_ref(),
6126                                bundle,
6127                            )?;
6128                            materializer
6129                                .revalidate_bundle(&ownership, bundle, layout)
6130                                .map_err(|_| failure(
6131                                    NodeFailureCode::BundleMaterializationFailed,
6132                                    "session bundle revalidation failed",
6133                                ))?;
6134                        }
6135                        if let Some(receipt) = ownership.context() {
6136                            let pack = materializer
6137                                .revalidate_context(&ownership, receipt)
6138                                .map_err(|_| {
6139                                    failure(
6140                                        NodeFailureCode::ContextPackMaterializationFailed,
6141                                        "session context revalidation failed",
6142                                    )
6143                                })?;
6144                            self.context_catalog
6145                                .write()
6146                                .unwrap_or_else(|poisoned| poisoned.into_inner())
6147                                .insert(pack)
6148                                .map_err(|_| {
6149                                    failure(
6150                                        NodeFailureCode::ContextPackMaterializationFailed,
6151                                        "session context catalog restore failed",
6152                                    )
6153                                })?;
6154                        }
6155                        Ok(())
6156                    }),
6157                (MaterializationState::RecoveryRequired, _) => Ok(()),
6158            };
6159            if result.is_err() && ownership.state() != MaterializationState::RecoveryRequired {
6160                let _ = ownership.mark_recovery_required(unix_time_ms());
6161                self.materializations
6162                    .lock()
6163                    .unwrap_or_else(|poisoned| poisoned.into_inner())
6164                    .insert(ownership.id().clone(), ownership);
6165                if self.persist_state().is_err() {
6166                    self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6167                }
6168            }
6169        }
6170        let materialized_profiles = self
6171            .environment_materialization_profiles
6172            .read()
6173            .unwrap_or_else(|poisoned| poisoned.into_inner())
6174            .keys()
6175            .cloned()
6176            .collect::<Vec<_>>();
6177        let ownership = self
6178            .materializations
6179            .lock()
6180            .unwrap_or_else(|poisoned| poisoned.into_inner())
6181            .values()
6182            .cloned()
6183            .collect::<Vec<_>>();
6184        let changed = {
6185            let mut session_records = self
6186                .session_records
6187                .lock()
6188                .unwrap_or_else(|poisoned| poisoned.into_inner());
6189            let mut changed = false;
6190            for record in session_records.records.values_mut() {
6191                let environment_profile = record.environment_profile.as_ref();
6192                if environment_profile.is_none()
6193                    && record.bundle.is_none()
6194                    && record.context.is_none()
6195                {
6196                    continue;
6197                }
6198                if record.bundle.is_none()
6199                    && environment_profile.is_some_and(|profile| {
6200                        !materialized_profiles.contains(&profile.profile_id)
6201                    })
6202                {
6203                    continue;
6204                }
6205                let has_exact_ownership = ownership.iter().any(|candidate| {
6206                    candidate.state() == MaterializationState::Ready
6207                        && candidate.environment_profile() == environment_profile
6208                        && candidate.bundle() == record.bundle.as_ref()
6209                        && candidate.context() == record.context.as_ref()
6210                        && candidate.owner()
6211                            == &MaterializationOwner::Record {
6212                                record_id: record.record_id.clone(),
6213                            }
6214                });
6215                if !has_exact_ownership {
6216                    record.provider_session = None;
6217                    record.active_session = None;
6218                    record.state = ManagedSessionState::Unavailable;
6219                    record.last_error = Some(if record.bundle.is_some() {
6220                        "bundle-unavailable".to_owned()
6221                    } else if record.context.is_some() {
6222                        "context-unavailable".to_owned()
6223                    } else {
6224                        "environment-profile-unavailable".to_owned()
6225                    });
6226                    record.updated_at_unix_ms = unix_time_ms();
6227                    changed = true;
6228                }
6229            }
6230            changed
6231        };
6232        if changed && self.persist_state().is_err() {
6233            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6234        }
6235    }
6236
6237    fn replay_spawn_spec(
6238        &self,
6239        spec: &SpawnSpec,
6240        now: Instant,
6241    ) -> Result<Option<Result<ResolvedSpawnReceipt, NodeFailure>>, NodeFailure> {
6242        let mut cache = self
6243            .spawn_idempotency
6244            .lock()
6245            .unwrap_or_else(|poisoned| poisoned.into_inner());
6246        cache.entries.retain(|_, entry| entry.expires_at > now);
6247        if let Some(entry) = cache.entries.get(&spec.idempotency_key) {
6248            let SpawnIdempotencyValue::Standard { spec: existing, result } = &entry.value else {
6249                return Err(failure(
6250                    NodeFailureCode::SpawnIdempotencyConflict,
6251                    "spawn idempotency key was reused across spawn kinds",
6252                ));
6253            };
6254            if existing != spec {
6255                return Err(failure(
6256                    NodeFailureCode::SpawnIdempotencyConflict,
6257                    "spawn idempotency key was reused with a different specification",
6258                ));
6259            }
6260            return Ok(Some(result.clone()));
6261        }
6262        if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
6263            return Err(failure(
6264                NodeFailureCode::SpawnIdempotencyCapacity,
6265                "spawn idempotency cache reached its bounded capacity",
6266            ));
6267        }
6268        Ok(None)
6269    }
6270
6271    fn remember_spawn_spec(
6272        &self,
6273        spec: SpawnSpec,
6274        result: Result<ResolvedSpawnReceipt, NodeFailure>,
6275        accepted_at: Instant,
6276    ) {
6277        self
6278            .spawn_idempotency
6279            .lock()
6280            .unwrap_or_else(|poisoned| poisoned.into_inner())
6281            .entries
6282            .insert(
6283                spec.idempotency_key.clone(),
6284                SpawnIdempotencyEntry {
6285                    value: SpawnIdempotencyValue::Standard { spec, result },
6286                    expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
6287                },
6288            );
6289    }
6290
6291    fn claim_harness_mcp_spawn(
6292        &self,
6293        _reservation_id: &HarnessMcpReservationId,
6294        _activation_digest: &HarnessMcpActivationDigest,
6295        spec: &SpawnSpec,
6296        now: Instant,
6297    ) -> Result<(), NodeFailure> {
6298        let mut cache = self.spawn_idempotency.lock()
6299            .unwrap_or_else(|poisoned| poisoned.into_inner());
6300        cache.entries.retain(|_, entry| entry.expires_at > now);
6301        if cache.entries.contains_key(&spec.idempotency_key) {
6302            return Err(failure(
6303                NodeFailureCode::SpawnIdempotencyConflict,
6304                "spawn idempotency key was reused across spawn kinds",
6305            ));
6306        }
6307        if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
6308            return Err(failure(
6309                NodeFailureCode::SpawnIdempotencyCapacity,
6310                "spawn idempotency cache reached its bounded capacity",
6311            ));
6312        }
6313        cache.entries.insert(spec.idempotency_key.clone(), SpawnIdempotencyEntry {
6314            value: SpawnIdempotencyValue::HarnessMcp,
6315            expires_at: now + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
6316        });
6317        Ok(())
6318    }
6319
6320    fn replay_managed_spawn(
6321        &self,
6322        request: &ManagedWorktreeSpawnRequest,
6323        now: Instant,
6324    ) -> Result<Option<Result<ManagedWorktreeSpawnReceipt, NodeFailure>>, NodeFailure> {
6325        let mut cache = self.spawn_idempotency.lock()
6326            .unwrap_or_else(|poisoned| poisoned.into_inner());
6327        cache.entries.retain(|_, entry| entry.expires_at > now);
6328        if let Some(entry) = cache.entries.get(&request.spawn_spec.idempotency_key) {
6329            let SpawnIdempotencyValue::Managed { request: existing, result } = &entry.value else {
6330                return Err(failure(
6331                    NodeFailureCode::SpawnIdempotencyConflict,
6332                    "spawn idempotency key was reused across spawn kinds",
6333                ));
6334            };
6335            if existing != request {
6336                return Err(failure(
6337                    NodeFailureCode::SpawnIdempotencyConflict,
6338                    "spawn idempotency key was reused with a different managed specification",
6339                ));
6340            }
6341            return Ok(Some(result.clone()));
6342        }
6343        if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
6344            return Err(failure(
6345                NodeFailureCode::SpawnIdempotencyCapacity,
6346                "spawn idempotency cache reached its bounded capacity",
6347            ));
6348        }
6349        Ok(None)
6350    }
6351
6352    fn remember_managed_spawn(
6353        &self,
6354        request: ManagedWorktreeSpawnRequest,
6355        result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
6356        accepted_at: Instant,
6357    ) {
6358        self.spawn_idempotency.lock()
6359            .unwrap_or_else(|poisoned| poisoned.into_inner())
6360            .entries
6361            .insert(
6362                request.spawn_spec.idempotency_key.clone(),
6363                SpawnIdempotencyEntry {
6364                    value: SpawnIdempotencyValue::Managed { request, result },
6365                    expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
6366                },
6367            );
6368    }
6369
6370    fn remember_managed_spawn_attempt(
6371        &self,
6372        durable_v2_key: Option<&SpawnIdempotencyKey>,
6373        request: ManagedWorktreeSpawnRequest,
6374        result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
6375        accepted_at: Instant,
6376    ) {
6377        if durable_v2_key.is_none() {
6378            self.remember_managed_spawn(request, result, accepted_at);
6379        }
6380    }
6381
6382    fn acquire_durable_managed_spawn_v2(
6383        &self,
6384        request: &ManagedWorktreeSpawnRequestV2,
6385        request_digest: String,
6386    ) -> Result<DurableManagedSpawnReplayDecision, NodeFailure> {
6387        if self.state_path.is_none() {
6388            return Err(failure(
6389                NodeFailureCode::BackendOperationFailed,
6390                "managed worktree V2 requires durable node state",
6391            ));
6392        }
6393        let _transaction = self.state_transaction
6394            .lock()
6395            .unwrap_or_else(|poisoned| poisoned.into_inner());
6396        let key = request.spawn_spec.idempotency_key.clone();
6397        let mut previous = None;
6398        {
6399            let mut replays = self.managed_spawn_replays
6400                .lock()
6401                .unwrap_or_else(|poisoned| poisoned.into_inner());
6402            if let Some(record) = replays.get_mut(&key) {
6403                if record.request_digest != request_digest {
6404                    return Err(failure(
6405                        NodeFailureCode::SpawnIdempotencyConflict,
6406                        "managed worktree V2 idempotency key was reused with a different request",
6407                    ));
6408                }
6409                match &record.state {
6410                    ManagedWorktreeSpawnReplayStateV10::Pending {
6411                        owner_incarnation_id,
6412                        lease_id: None,
6413                    } if owner_incarnation_id != &self.incarnation_id => {
6414                        previous = Some(record.clone());
6415                        record.state = ManagedWorktreeSpawnReplayStateV10::Pending {
6416                            owner_incarnation_id: self.incarnation_id,
6417                            lease_id: None,
6418                        };
6419                        record.updated_at_unix_ms = unix_time_ms();
6420                    }
6421                    ManagedWorktreeSpawnReplayStateV10::Pending {
6422                        lease_id: None,
6423                        ..
6424                    } => {
6425                        return Err(failure(
6426                            NodeFailureCode::BackendBusy,
6427                            "managed worktree V2 reservation is already active",
6428                        ));
6429                    }
6430                    ManagedWorktreeSpawnReplayStateV10::Pending {
6431                        lease_id: Some(_),
6432                        ..
6433                    } => {
6434                        return Ok(DurableManagedSpawnReplayDecision::PendingLinked);
6435                    }
6436                    ManagedWorktreeSpawnReplayStateV10::Committed { receipt } => {
6437                        return Ok(DurableManagedSpawnReplayDecision::Committed(
6438                            receipt.clone(),
6439                        ));
6440                    }
6441                }
6442            } else if replays.len() >= MAX_MANAGED_WORKTREE_SPAWN_REPLAYS {
6443                return Err(failure(
6444                    NodeFailureCode::SpawnIdempotencyCapacity,
6445                    "durable managed worktree replay ledger reached its bounded capacity",
6446                ));
6447            } else {
6448                let now = unix_time_ms();
6449                replays.insert(key.clone(), ManagedWorktreeSpawnReplayRecordV10 {
6450                    idempotency_key: key.clone(),
6451                    request_digest,
6452                    source_workspace_id: request.spawn_spec.target.workspace_id.clone(),
6453                    profile_id: request.worktree_profile_id.clone(),
6454                    expected_profile_revision: request.expected_profile_revision.clone(),
6455                    state: ManagedWorktreeSpawnReplayStateV10::Pending {
6456                        owner_incarnation_id: self.incarnation_id,
6457                        lease_id: None,
6458                    },
6459                    created_at_unix_ms: now,
6460                    updated_at_unix_ms: now,
6461                });
6462            }
6463        }
6464        if let Err(error) = self.persist_state_locked() {
6465            let mut replays = self.managed_spawn_replays
6466                .lock()
6467                .unwrap_or_else(|poisoned| poisoned.into_inner());
6468            match previous {
6469                Some(previous) => {
6470                    replays.insert(key, previous);
6471                }
6472                None => {
6473                    replays.remove(&key);
6474                }
6475            }
6476            return Err(persistence_failure(error));
6477        }
6478        Ok(DurableManagedSpawnReplayDecision::Reserved)
6479    }
6480
6481    fn link_durable_managed_spawn_v2_lease_locked(
6482        &self,
6483        key: &SpawnIdempotencyKey,
6484        lease_id: &ManagedWorktreeLeaseId,
6485    ) -> Result<ManagedWorktreeSpawnReplayRecordV10, NodeFailure> {
6486        let mut replays = self.managed_spawn_replays
6487            .lock()
6488            .unwrap_or_else(|poisoned| poisoned.into_inner());
6489        let record = replays.get_mut(key).ok_or_else(|| {
6490            failure(
6491                NodeFailureCode::ManagedWorktreeRecoveryRequired,
6492                "managed worktree V2 reservation disappeared before allocation",
6493            )
6494        })?;
6495        let previous = record.clone();
6496        match &mut record.state {
6497            ManagedWorktreeSpawnReplayStateV10::Pending {
6498                lease_id: current,
6499                ..
6500            }
6501                if current.is_none() =>
6502            {
6503                *current = Some(lease_id.clone());
6504                record.updated_at_unix_ms = unix_time_ms();
6505                Ok(previous)
6506            }
6507            _ => Err(failure(
6508                NodeFailureCode::ManagedWorktreeRecoveryRequired,
6509                "managed worktree V2 reservation is not allocation-ready",
6510            )),
6511        }
6512    }
6513
6514    fn restore_durable_managed_spawn_v2_locked(
6515        &self,
6516        key: &SpawnIdempotencyKey,
6517        previous: ManagedWorktreeSpawnReplayRecordV10,
6518    ) {
6519        self.managed_spawn_replays
6520            .lock()
6521            .unwrap_or_else(|poisoned| poisoned.into_inner())
6522            .insert(key.clone(), previous);
6523    }
6524
6525    fn commit_durable_managed_spawn_v2(
6526        &self,
6527        request: &ManagedWorktreeSpawnRequestV2,
6528        receipt: ManagedWorktreeSpawnReceipt,
6529    ) -> Result<(), NodeFailure> {
6530        let _transaction = self.state_transaction
6531            .lock()
6532            .unwrap_or_else(|poisoned| poisoned.into_inner());
6533        let key = &request.spawn_spec.idempotency_key;
6534        let previous = {
6535            let mut replays = self.managed_spawn_replays
6536                .lock()
6537                .unwrap_or_else(|poisoned| poisoned.into_inner());
6538            let record = replays.get_mut(key).ok_or_else(|| {
6539                failure(
6540                    NodeFailureCode::ManagedWorktreeRecoveryRequired,
6541                    "managed worktree V2 reservation disappeared before commit",
6542                )
6543            })?;
6544            let previous = record.clone();
6545            match &record.state {
6546                ManagedWorktreeSpawnReplayStateV10::Pending {
6547                    lease_id: Some(lease_id),
6548                    ..
6549                } if lease_id == &receipt.lease.lease_id => {}
6550                _ => {
6551                    return Err(failure(
6552                        NodeFailureCode::ManagedWorktreeRecoveryRequired,
6553                        "managed worktree V2 reservation is not linked to the spawned lease",
6554                    ));
6555                }
6556            }
6557            record.state = ManagedWorktreeSpawnReplayStateV10::Committed { receipt };
6558            record.updated_at_unix_ms = unix_time_ms();
6559            previous
6560        };
6561        if let Err(error) = self.persist_state_locked() {
6562            self.restore_durable_managed_spawn_v2_locked(key, previous);
6563            return Err(persistence_failure(error));
6564        }
6565        Ok(())
6566    }
6567
6568    fn remove_unallocated_durable_managed_spawn_v2(
6569        &self,
6570        request: &ManagedWorktreeSpawnRequestV2,
6571    ) -> Result<(), NodeFailure> {
6572        let _transaction = self.state_transaction
6573            .lock()
6574            .unwrap_or_else(|poisoned| poisoned.into_inner());
6575        let key = &request.spawn_spec.idempotency_key;
6576        let removed = {
6577            let mut replays = self.managed_spawn_replays
6578                .lock()
6579                .unwrap_or_else(|poisoned| poisoned.into_inner());
6580            match replays.get(key).map(|record| &record.state) {
6581                Some(ManagedWorktreeSpawnReplayStateV10::Pending {
6582                    lease_id: None,
6583                    ..
6584                }) => {
6585                    replays.remove(key)
6586                }
6587                _ => None,
6588            }
6589        };
6590        let Some(removed) = removed else {
6591            return Ok(());
6592        };
6593        if let Err(error) = self.persist_state_locked() {
6594            self.managed_spawn_replays
6595                .lock()
6596                .unwrap_or_else(|poisoned| poisoned.into_inner())
6597                .insert(key.clone(), removed);
6598            return Err(persistence_failure(error));
6599        }
6600        Ok(())
6601    }
6602
6603    fn resolve_spawn_spec(&self, spec: &SpawnSpec) -> Result<ResolvedSpawnSpec, NodeFailure> {
6604        if spec.target.node_id != self.node_id {
6605            return Err(failure(
6606                NodeFailureCode::SpawnTargetMismatch,
6607                "spawn target does not match this node",
6608            ));
6609        }
6610        let defaults = self.spawn_profiles.get(&spec.profile_id).ok_or_else(|| {
6611            failure(
6612                NodeFailureCode::UnknownSpawnProfile,
6613                "spawn profile is unavailable on this node",
6614            )
6615        })?;
6616        let resolved = spec.resolve(defaults).map_err(|error| match error {
6617            SpawnSpecResolveError::ProfileRevisionMismatch { .. } => failure(
6618                NodeFailureCode::SpawnProfileRevisionMismatch,
6619                "spawn profile revision does not match the loaded profile",
6620            ),
6621            _ => failure(
6622                NodeFailureCode::InvalidRequest,
6623                "spawn specification could not be resolved",
6624            ),
6625        })?;
6626        if resolved.target.worktree_id.is_some() {
6627            self.require_worktree_service(&resolved.target.workspace_id)?;
6628        }
6629        let environment_profile = self.resolve_environment_profile(&resolved)?;
6630        self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6631        self.resolve_context(&resolved)?;
6632        Ok(resolved)
6633    }
6634
6635    async fn resolve_spawn_workspace(
6636        &self,
6637        resolved: &ResolvedSpawnSpec,
6638        deadline: Instant,
6639    ) -> Result<WorkspaceId, NodeFailure> {
6640        let Some(worktree_id) = resolved.target.worktree_id.as_ref() else {
6641            return Ok(resolved.target.workspace_id.clone());
6642        };
6643        if self.managed_worktrees.lock()
6644            .unwrap_or_else(|poisoned| poisoned.into_inner())
6645            .lease_for_workspace(worktree_id)
6646            .is_some()
6647        {
6648            return Err(failure(
6649                NodeFailureCode::ManagedWorktreeOwnershipConflict,
6650                "managed worktree targets may only be spawned through their lease request",
6651            ));
6652        }
6653        self.require_worktree_service(&resolved.target.workspace_id)?;
6654        let source_root = self.workspace_root(&resolved.target.workspace_id)?;
6655        let selected_root = self.workspace_root(worktree_id)?;
6656        let remaining = spawn_deadline_remaining(deadline)?;
6657        let worktrees = timeout(remaining, list_git_worktrees(&source_root))
6658            .await
6659            .map_err(|_| {
6660                failure(
6661                    NodeFailureCode::SpawnDeadlineExceeded,
6662                    "Git worktree selection exceeded the spawn deadline",
6663                )
6664            })?
6665            .map_err(git_worktree_failure)?;
6666        validate_selected_worktree(&source_root, &selected_root, &worktrees)?;
6667        Ok(worktree_id.clone())
6668    }
6669
6670    async fn spawn_from_spec(
6671        &self,
6672        spec: SpawnSpec,
6673    ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6674        self.spawn_from_spec_with_proxy(spec, None).await
6675    }
6676
6677    async fn spawn_from_spec_with_proxy(
6678        &self,
6679        spec: SpawnSpec,
6680        harness_mcp: Option<&PreparedHarnessMcpSpawn>,
6681    ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6682        let accepted_at = Instant::now();
6683        if harness_mcp.is_none() {
6684            if let Some(replayed) = self.replay_spawn_spec(&spec, accepted_at)? {
6685                return replayed;
6686            }
6687        }
6688        let resolved = self.resolve_spawn_spec(&spec)?;
6689        let environment_profile = self.resolve_environment_profile(&resolved)?;
6690        let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6691        let context = self.resolve_context(&resolved)?;
6692        let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6693        let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6694        let spawn_workspace_id = self.resolve_spawn_workspace(&resolved, deadline).await;
6695        let spawn_workspace_id = match spawn_workspace_id {
6696            Ok(workspace_id) => workspace_id,
6697            Err(error) => {
6698                let result = Err(error);
6699                if harness_mcp.is_none() {
6700                    self.remember_spawn_spec(spec, result.clone(), accepted_at);
6701                }
6702                return result;
6703            }
6704        };
6705        if self.managed_worktrees.lock()
6706            .unwrap_or_else(|poisoned| poisoned.into_inner())
6707            .lease_for_workspace(&spawn_workspace_id)
6708            .is_some()
6709        {
6710            let result = Err(failure(
6711                NodeFailureCode::ManagedWorktreeOwnershipConflict,
6712                "managed worktree targets may only be spawned through their lease request",
6713            ));
6714            if harness_mcp.is_none() {
6715                self.remember_spawn_spec(spec, result.clone(), accepted_at);
6716            }
6717            return result;
6718        }
6719        let result = self
6720            .spawn_session_with_deadline(
6721                spawn_workspace_id,
6722                resolved.provider.clone(),
6723                resolved.mode,
6724                resolved.terminal_size,
6725                resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
6726                environment_profile.clone(),
6727                bundle.clone(),
6728                context.clone(),
6729                None,
6730                None,
6731                SpawnRecordPolicy::Always,
6732                Some(deadline),
6733                &required_capabilities,
6734                harness_mcp,
6735                resolved.approval_level,
6736                resolved.network_allowlist.clone(),
6737                resolved.browser_profile_id.clone(),
6738            )
6739            .await
6740            .map(|(session, _runtime_policy)| {
6741                resolved.receipt_with_materialization(
6742                    self.incarnation_id,
6743                    session,
6744                    environment_profile,
6745                    bundle,
6746                    context,
6747                )
6748            });
6749        if harness_mcp.is_none() {
6750            self.remember_spawn_spec(spec, result.clone(), accepted_at);
6751        }
6752        result
6753    }
6754
6755    async fn spawn_from_spec_with_harness_mcp(
6756        &self,
6757        reservation_id: HarnessMcpReservationId,
6758        activation_digest: HarnessMcpActivationDigest,
6759        spec: SpawnSpec,
6760        deadline_unix_ms: u64,
6761    ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6762        let registry = self.harness_mcp_registry.as_ref().ok_or_else(|| {
6763            failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
6764        })?;
6765        if let Some(receipt) = registry
6766            .replay_spawn(&reservation_id, &activation_digest, &spec)
6767            .map_err(harness_mcp_failure)?
6768        {
6769            return Ok(receipt);
6770        }
6771        let resolved = self.resolve_spawn_spec(&spec)?;
6772        let prepared = registry
6773            .prepare_spawn(
6774                &reservation_id,
6775                &activation_digest,
6776                &spec,
6777                deadline_unix_ms,
6778                resolved.provider.clone(),
6779            )
6780            .map_err(harness_mcp_failure)?;
6781        self.claim_harness_mcp_spawn(
6782            &reservation_id,
6783            &activation_digest,
6784            &spec,
6785            Instant::now(),
6786        )?;
6787        let spawn = self.spawn_from_spec_with_proxy(spec, Some(&prepared)).await;
6788        let mut receipt = match spawn {
6789            Ok(receipt) => receipt,
6790            Err(error) => {
6791                let _ = registry.abort(&reservation_id, &activation_digest);
6792                return Err(error);
6793            }
6794        };
6795        let record_id = self
6796            .session_bindings
6797            .lock()
6798            .unwrap_or_else(|poisoned| poisoned.into_inner())
6799            .get(&receipt.session.session.instance_id)
6800            .and_then(|binding| binding.record_id.clone());
6801        let record_id = match record_id {
6802            Some(record_id) => record_id,
6803            None => {
6804                let rollback = self.remove_session(&receipt.session).await;
6805                let _ = registry.abort(&reservation_id, &activation_digest);
6806                if let Err(rollback_error) = rollback {
6807                    return Err(rollback_error);
6808                }
6809                return Err(failure(
6810                    NodeFailureCode::BindingMismatch,
6811                    "harness MCP spawn did not retain its exact managed record",
6812                ));
6813            }
6814        };
6815        receipt.harness_mcp_proxy = Some(ResolvedHarnessMcpProxyReceiptV1 {
6816            reservation_id: reservation_id.clone(),
6817            activation_digest: activation_digest.clone(),
6818        });
6819        if let Err(error) = registry.mark_spawned(
6820            &prepared,
6821            receipt.session.clone(),
6822            record_id,
6823            receipt.clone(),
6824        ) {
6825            let rollback = self.remove_session(&receipt.session).await;
6826            let _ = registry.abort(&reservation_id, &activation_digest);
6827            if let Err(rollback_error) = rollback {
6828                return Err(rollback_error);
6829            }
6830            return Err(harness_mcp_failure(error));
6831        }
6832        Ok(receipt)
6833    }
6834
6835    async fn spawn_managed_worktree(
6836        &self,
6837        request: ManagedWorktreeSpawnRequest,
6838    ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6839        self.spawn_managed_worktree_inner(request, None).await
6840    }
6841
6842    async fn spawn_managed_worktree_inner(
6843        &self,
6844        request: ManagedWorktreeSpawnRequest,
6845        durable_v2_key: Option<&SpawnIdempotencyKey>,
6846    ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6847        let accepted_at = Instant::now();
6848        if durable_v2_key.is_none() {
6849            if let Some(replayed) = self.replay_managed_spawn(&request, accepted_at)? {
6850                return replayed;
6851            }
6852        }
6853        let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
6854        if request.spawn_spec.target.worktree_id.is_some() {
6855            let result = Err(failure(
6856                NodeFailureCode::InvalidRequest,
6857                "managed worktree spawn must not select a caller-provided worktree",
6858            ));
6859            self.remember_managed_spawn_attempt(
6860                durable_v2_key,
6861                request,
6862                result.clone(),
6863                accepted_at,
6864            );
6865            return result;
6866        }
6867        let environment_profile = self.resolve_environment_profile(&resolved)?;
6868        let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6869        let context = self.resolve_context(&resolved)?;
6870        let source_workspace_id = resolved.target.workspace_id.clone();
6871        let profile = self.managed_profile(&source_workspace_id, &request.worktree_profile_id)?;
6872        let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6873        let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6874        let runtime_requirement = match (resolved.mode, resolved.prompt.is_some()) {
6875            (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
6876            (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
6877            (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
6878            (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
6879        };
6880        let runtime_policy = timeout(
6881            spawn_deadline_remaining(deadline)?,
6882            self.admit_provider_runtime(&resolved.provider, runtime_requirement),
6883        ).await.map_err(|_| failure(
6884            NodeFailureCode::SpawnDeadlineExceeded,
6885            "provider admission exceeded the managed spawn deadline",
6886        ))??;
6887        if required_capabilities.iter().any(|capability| !runtime_policy.admits(*capability)) {
6888            return Err(failure(
6889                NodeFailureCode::UnsupportedSpawnCapability,
6890                "provider runtime does not admit a required spawn capability",
6891            ));
6892        }
6893        self.ensure_binding_capacity()?;
6894        let source_root = self.workspace_root(&source_workspace_id)?;
6895        profile.validate_for_workspace(&source_root)
6896            .map_err(|message| failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message))?;
6897        let base_timeout_ms = u64::try_from(
6898            spawn_deadline_remaining(deadline)?.as_millis(),
6899        ).unwrap_or(u64::MAX).max(1);
6900        let base_commit = resolve_base_commit_with_timeout(
6901            &source_root,
6902            profile.base(),
6903            base_timeout_ms,
6904        ).await.map_err(|error| {
6905            if Instant::now() >= deadline || error.message.contains("timed out") {
6906                failure(
6907                    NodeFailureCode::SpawnDeadlineExceeded,
6908                    "managed worktree base resolution exceeded the spawn deadline",
6909                )
6910            } else {
6911                managed_git_worktree_failure(error)
6912            }
6913        })?;
6914
6915        let lease = {
6916            let _transaction = self.state_transaction.lock()
6917                .unwrap_or_else(|poisoned| poisoned.into_inner());
6918            let previous = self.managed_worktrees.lock()
6919                .unwrap_or_else(|poisoned| poisoned.into_inner()).clone();
6920            let lease = self.managed_worktrees.lock()
6921                .unwrap_or_else(|poisoned| poisoned.into_inner())
6922                .allocate(source_workspace_id.clone(), &profile, base_commit.clone(), unix_time_ms())
6923                .map_err(|message| failure(NodeFailureCode::BackendBusy, &message))?;
6924            let previous_replay = match durable_v2_key
6925                .map(|key| self.link_durable_managed_spawn_v2_lease_locked(key, &lease.lease_id))
6926                .transpose()
6927            {
6928                Ok(previous) => previous,
6929                Err(error) => {
6930                    *self.managed_worktrees.lock()
6931                        .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6932                    return Err(error);
6933                }
6934            };
6935            if let Err(error) = self.persist_state_locked() {
6936                *self.managed_worktrees.lock()
6937                    .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6938                if let (Some(key), Some(previous_replay)) =
6939                    (durable_v2_key, previous_replay)
6940                {
6941                    self.restore_durable_managed_spawn_v2_locked(key, previous_replay);
6942                }
6943                return Err(persistence_failure(error));
6944            }
6945            lease
6946        };
6947        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() });
6948        if let Err(message) = profile.validate_target_authority(&lease.lease_id, &lease.target_root) {
6949            self.managed_worktrees.lock()
6950                .unwrap_or_else(|poisoned| poisoned.into_inner())
6951                .remove_unmutated(&lease.lease_id);
6952            if self.persist_state().is_err() {
6953                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6954            }
6955            let result = Err(failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message));
6956            self.remember_managed_spawn_attempt(
6957                durable_v2_key,
6958                request,
6959                result.clone(),
6960                accepted_at,
6961            );
6962            return result;
6963        }
6964        let mutation_timeout_ms = match spawn_deadline_remaining(deadline) {
6965            Ok(remaining) => u64::try_from(remaining.as_millis()).unwrap_or(u64::MAX).max(1),
6966            Err(error) => {
6967                self.reconcile_failed_allocation(&lease, &source_root).await;
6968                let result = Err(error);
6969                self.remember_managed_spawn_attempt(
6970                    durable_v2_key,
6971                    request,
6972                    result.clone(),
6973                    accepted_at,
6974                );
6975                return result;
6976            }
6977        };
6978        let created = match create_git_worktree_with_timeout(
6979                &source_root,
6980                &lease.target_root,
6981                &lease.branch,
6982                Some(&base_commit),
6983                mutation_timeout_ms,
6984            ).await {
6985            Ok(created) => created,
6986            Err(error) => {
6987                self.reconcile_failed_allocation(&lease, &source_root).await;
6988                let result = Err(if Instant::now() >= deadline
6989                    || error.message.contains("timed out")
6990                    || error.message.contains("deadline")
6991                {
6992                    failure(
6993                        NodeFailureCode::SpawnDeadlineExceeded,
6994                        "managed worktree creation exceeded the spawn deadline",
6995                    )
6996                } else {
6997                    managed_git_worktree_failure(error)
6998                });
6999                self.remember_managed_spawn_attempt(
7000                    durable_v2_key,
7001                    request,
7002                    result.clone(),
7003                    accepted_at,
7004                );
7005                return result;
7006            }
7007        };
7008        if !exact_created_worktree(&lease, &created.path, created.branch.as_deref(), &created.head) {
7009            self.mark_managed_recovery_required(&lease.lease_id, ManagedWorktreeCleanupFailure::OwnershipConflict);
7010            let result = Err(failure(
7011                NodeFailureCode::ManagedWorktreeRecoveryRequired,
7012                "created worktree identity did not match its durable lease",
7013            ));
7014            self.remember_managed_spawn_attempt(
7015                durable_v2_key,
7016                request,
7017                result.clone(),
7018                accepted_at,
7019            );
7020            return result;
7021        }
7022        {
7023            let mut registry = self.managed_worktrees.lock()
7024                .unwrap_or_else(|poisoned| poisoned.into_inner());
7025            let current = registry.get_mut(&lease.lease_id)
7026                .expect("allocated managed worktree lease remains present");
7027            current.expected_head = Some(created.head.clone());
7028            current.state = ManagedWorktreeLeaseState::Ready;
7029            current.updated_at_unix_ms = unix_time_ms();
7030        }
7031        if let Err(error) = self.persist_state() {
7032            self.mark_managed_recovery_required(
7033                &lease.lease_id,
7034                ManagedWorktreeCleanupFailure::Backend,
7035            );
7036            let result = Err(persistence_failure(error));
7037            self.remember_managed_spawn_attempt(
7038                durable_v2_key,
7039                request,
7040                result.clone(),
7041                accepted_at,
7042            );
7043            return result;
7044        }
7045        let ready = self.managed_worktrees.lock()
7046            .unwrap_or_else(|poisoned| poisoned.into_inner())
7047            .get(&lease.lease_id)
7048            .expect("ready managed worktree lease remains present")
7049            .snapshot();
7050        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: ready });
7051        if let Err(error) = self.register_workspace(lease.workspace_id.clone(), created.path.clone()).await {
7052            let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
7053            let result = Err(error);
7054            self.remember_managed_spawn_attempt(
7055                durable_v2_key,
7056                request,
7057                result.clone(),
7058                accepted_at,
7059            );
7060            return result;
7061        }
7062        let spawn = self.spawn_session_with_deadline(
7063            lease.workspace_id.clone(),
7064            resolved.provider.clone(),
7065            resolved.mode,
7066            resolved.terminal_size,
7067            resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
7068            environment_profile.clone(),
7069            bundle.clone(),
7070            context.clone(),
7071            Some(lease.lease_id.clone()),
7072            Some(runtime_policy),
7073            SpawnRecordPolicy::Always,
7074            Some(deadline),
7075            &required_capabilities,
7076            None,
7077            resolved.approval_level,
7078            resolved.network_allowlist.clone(),
7079            resolved.browser_profile_id.clone(),
7080        ).await;
7081        let (session, effective_runtime_policy) = match spawn {
7082            Ok(spawned) => spawned,
7083            Err(error) => {
7084                if self
7085                    .cleanup_managed_worktree(&lease.lease_id, true)
7086                    .await
7087                    .is_err()
7088                {
7089                    self.mark_managed_recovery_required(
7090                        &lease.lease_id,
7091                        ManagedWorktreeCleanupFailure::Backend,
7092                    );
7093                }
7094                let result = Err(error);
7095                self.remember_managed_spawn_attempt(
7096                    durable_v2_key,
7097                    request,
7098                    result.clone(),
7099                    accepted_at,
7100                );
7101                return result;
7102            }
7103        };
7104        let (record_id, raw_inventory_record_id) = {
7105            let mut bindings = self.session_bindings.lock()
7106                .unwrap_or_else(|poisoned| poisoned.into_inner());
7107            let binding = bindings.get_mut(&session.session.instance_id)
7108                .expect("successful spawn retains its session binding");
7109            binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
7110            managed_spawn_record_ownership(binding, effective_runtime_policy)
7111        };
7112        let bound = self.managed_worktrees.lock()
7113            .unwrap_or_else(|poisoned| poisoned.into_inner())
7114            .bind_session(
7115                &lease.lease_id,
7116                ManagedWorktreeSessionHolder {
7117                    incarnation_id: self.incarnation_id,
7118                    instance_id: session.session.instance_id,
7119                    generation: session.session.generation,
7120                },
7121                record_id,
7122                unix_time_ms(),
7123            )
7124            .map_err(|message| failure(NodeFailureCode::ManagedWorktreeRecoveryRequired, &message));
7125        let snapshot = match bound {
7126            Ok(snapshot) => snapshot,
7127            Err(error) => {
7128                if raw_inventory_record_id
7129                    .as_ref()
7130                    .is_some_and(|record_id| self.discard_record(record_id).is_err())
7131                {
7132                    self.mark_managed_recovery_required(
7133                        &lease.lease_id,
7134                        ManagedWorktreeCleanupFailure::Backend,
7135                    );
7136                }
7137                let _ = self.remove_session(&session).await;
7138                self.mark_managed_recovery_required(
7139                    &lease.lease_id,
7140                    ManagedWorktreeCleanupFailure::Backend,
7141                );
7142                let result = Err(error);
7143                self.remember_managed_spawn_attempt(
7144                    durable_v2_key,
7145                    request,
7146                    result.clone(),
7147                    accepted_at,
7148                );
7149                return result;
7150            }
7151        };
7152        if let Err(error) = self.persist_state() {
7153            if raw_inventory_record_id
7154                .as_ref()
7155                .is_some_and(|record_id| self.discard_record(record_id).is_err())
7156            {
7157                self.mark_managed_recovery_required(
7158                    &lease.lease_id,
7159                    ManagedWorktreeCleanupFailure::Backend,
7160                );
7161            }
7162            let _ = self.remove_session(&session).await;
7163            if self
7164                .cleanup_managed_worktree(&lease.lease_id, true)
7165                .await
7166                .is_err()
7167            {
7168                self.mark_managed_recovery_required(
7169                    &lease.lease_id,
7170                    ManagedWorktreeCleanupFailure::Backend,
7171                );
7172            }
7173            let result = Err(persistence_failure(error));
7174            self.remember_managed_spawn_attempt(
7175                durable_v2_key,
7176                request,
7177                result.clone(),
7178                accepted_at,
7179            );
7180            return result;
7181        }
7182        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
7183        let receipt = managed_spawn_receipt(
7184            &resolved,
7185            self.incarnation_id,
7186            session,
7187            snapshot,
7188            environment_profile,
7189            bundle,
7190            context,
7191        );
7192        let result = Ok(receipt);
7193        self.remember_managed_spawn_attempt(
7194            durable_v2_key,
7195            request,
7196            result.clone(),
7197            accepted_at,
7198        );
7199        result
7200    }
7201
7202    async fn spawn_managed_worktree_v2(
7203        &self,
7204        request: ManagedWorktreeSpawnRequestV2,
7205    ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
7206        if request.spawn_spec.target.node_id != self.node_id {
7207            return Err(failure(
7208                NodeFailureCode::SpawnTargetMismatch,
7209                "spawn target does not match this node",
7210            ));
7211        }
7212        if request.spawn_spec.target.worktree_id.is_some() {
7213            return Err(failure(
7214                NodeFailureCode::InvalidRequest,
7215                "managed worktree spawn must not select a caller-provided worktree",
7216            ));
7217        }
7218        let request_digest = managed_spawn_request_digest_v2(&request)?;
7219        let replay = self.acquire_durable_managed_spawn_v2(&request, request_digest)?;
7220        match replay {
7221            DurableManagedSpawnReplayDecision::Committed(receipt) => return Ok(receipt),
7222            DurableManagedSpawnReplayDecision::PendingLinked => {
7223                return Err(failure(
7224                    NodeFailureCode::ManagedWorktreeRecoveryRequired,
7225                    "managed worktree V2 reservation has an incomplete durable allocation",
7226                ));
7227            }
7228            DurableManagedSpawnReplayDecision::Reserved => {}
7229        }
7230        let validated = (|| {
7231            let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
7232            let profile = self.managed_profile(
7233                &resolved.target.workspace_id,
7234                &request.worktree_profile_id,
7235            )?;
7236            if profile.revision() != &request.expected_profile_revision {
7237                return Err(failure(
7238                    NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
7239                    "managed worktree profile revision does not match the loaded profile",
7240                ));
7241            }
7242            Ok(())
7243        })();
7244        match validated {
7245            Ok(()) => {}
7246            Err(error) => {
7247                self.remove_unallocated_durable_managed_spawn_v2(&request)?;
7248                return Err(error);
7249            }
7250        }
7251        let legacy = ManagedWorktreeSpawnRequest {
7252            spawn_spec: request.spawn_spec.clone(),
7253            worktree_profile_id: request.worktree_profile_id.clone(),
7254        };
7255        match self
7256            .spawn_managed_worktree_inner(
7257                legacy,
7258                Some(&request.spawn_spec.idempotency_key),
7259            )
7260            .await
7261        {
7262            Ok(receipt) => {
7263                self.commit_durable_managed_spawn_v2(&request, receipt.clone())?;
7264                Ok(receipt)
7265            }
7266            Err(error) => {
7267                self.remove_unallocated_durable_managed_spawn_v2(&request)?;
7268                Err(error)
7269            }
7270        }
7271    }
7272
7273    fn managed_profile(
7274        &self,
7275        workspace_id: &WorkspaceId,
7276        profile_id: &WorktreeProfileId,
7277    ) -> Result<ManagedWorktreeProfile, NodeFailure> {
7278        if self.worktree_service_modes.get(workspace_id).copied()
7279            != Some(WorktreeServiceMode::Managed)
7280        {
7281            return Err(failure(
7282                NodeFailureCode::UnsupportedSpawnCapability,
7283                "workspace does not enable managed worktrees",
7284            ));
7285        }
7286        self.managed_worktree_profiles.get(workspace_id)
7287            .and_then(|profiles| profiles.get(profile_id))
7288            .cloned()
7289            .ok_or_else(|| failure(
7290                NodeFailureCode::InvalidRequest,
7291                "managed worktree profile is unavailable for this workspace",
7292            ))
7293    }
7294
7295    async fn reconcile_failed_allocation(
7296        &self,
7297        lease: &ManagedWorktreeLeaseRecord,
7298        source_root: &str,
7299    ) {
7300        match list_git_worktrees(source_root).await {
7301            Ok(worktrees) => {
7302                match worktrees.iter().find(|item| worktree_paths_equal(&item.path, &lease.target_root)) {
7303                    None => {
7304                        match std::fs::symlink_metadata(&lease.target_root) {
7305                            Err(error) if error.kind() == io::ErrorKind::NotFound => {
7306                                self.managed_worktrees.lock()
7307                                    .unwrap_or_else(|poisoned| poisoned.into_inner())
7308                                    .remove_unmutated(&lease.lease_id);
7309                                let _ = self.persist_state();
7310                            }
7311                            _ => self.mark_managed_recovery_required(
7312                                &lease.lease_id,
7313                                ManagedWorktreeCleanupFailure::OwnershipConflict,
7314                            ),
7315                        }
7316                    }
7317                    Some(item) if exact_owned_worktree(
7318                        lease,
7319                        &item.path,
7320                        item.branch.as_deref(),
7321                        &item.head,
7322                    ) => {
7323                        let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
7324                    }
7325                    Some(_) => self.mark_managed_recovery_required(
7326                        &lease.lease_id,
7327                        ManagedWorktreeCleanupFailure::OwnershipConflict,
7328                    ),
7329                }
7330            }
7331            Err(_) => self.mark_managed_recovery_required(
7332                &lease.lease_id,
7333                ManagedWorktreeCleanupFailure::Backend,
7334            ),
7335        }
7336    }
7337
7338    async fn reconcile_managed_worktrees(&self) {
7339        let leases = self.managed_worktrees.lock()
7340            .unwrap_or_else(|poisoned| poisoned.into_inner())
7341            .active_records()
7342            .cloned()
7343            .collect::<Vec<_>>();
7344        for lease in leases {
7345            let profile = match self.managed_profile(&lease.source_workspace_id, &lease.profile_id) {
7346                Ok(profile) if profile.revision() == &lease.profile_revision => profile,
7347                _ => {
7348                    self.mark_managed_recovery_required(
7349                        &lease.lease_id,
7350                        ManagedWorktreeCleanupFailure::OwnershipConflict,
7351                    );
7352                    continue;
7353                }
7354            };
7355            if profile.validate_target_authority(&lease.lease_id, &lease.target_root).is_err() {
7356                self.mark_managed_recovery_required(
7357                    &lease.lease_id,
7358                    ManagedWorktreeCleanupFailure::OwnershipConflict,
7359                );
7360                continue;
7361            }
7362            let source_root = match self.workspace_root(&lease.source_workspace_id) {
7363                Ok(root) => root,
7364                Err(_) => {
7365                    self.mark_managed_recovery_required(
7366                        &lease.lease_id,
7367                        ManagedWorktreeCleanupFailure::OwnershipConflict,
7368                    );
7369                    continue;
7370                }
7371            };
7372            let worktrees = match list_git_worktrees(&source_root).await {
7373                Ok(worktrees) => worktrees,
7374                Err(_) => {
7375                    self.mark_managed_recovery_required(
7376                        &lease.lease_id,
7377                        ManagedWorktreeCleanupFailure::Backend,
7378                    );
7379                    continue;
7380                }
7381            };
7382            let listed = worktrees.iter()
7383                .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
7384            let Some(listed) = listed else {
7385                match std::fs::symlink_metadata(&lease.target_root) {
7386                    Err(error) if error.kind() == io::ErrorKind::NotFound
7387                        && lease.state == ManagedWorktreeLeaseState::Allocating
7388                        && !lease.has_holders() => {
7389                            let removed = self.managed_worktrees.lock()
7390                                .unwrap_or_else(|poisoned| poisoned.into_inner())
7391                                .tombstone(&lease.lease_id, unix_time_ms());
7392                            if self.persist_state().is_ok() && removed.is_some() {
7393                                self.publish(NodeEvent::ManagedWorktreeRemoved {
7394                                    lease_id: lease.lease_id.clone(),
7395                                });
7396                            }
7397                        }
7398                    _ => self.mark_managed_recovery_required(
7399                        &lease.lease_id,
7400                        ManagedWorktreeCleanupFailure::OwnershipConflict,
7401                    ),
7402                }
7403                continue;
7404            };
7405            if listed.is_main || listed.is_bare || !exact_owned_worktree(
7406                &lease,
7407                &listed.path,
7408                listed.branch.as_deref(),
7409                &listed.head,
7410            ) {
7411                self.mark_managed_recovery_required(
7412                    &lease.lease_id,
7413                    ManagedWorktreeCleanupFailure::OwnershipConflict,
7414                );
7415                continue;
7416            }
7417            if !self.workspaces.read()
7418                .unwrap_or_else(|poisoned| poisoned.into_inner())
7419                .contains_key(&lease.workspace_id)
7420            {
7421                if self.register_workspace(lease.workspace_id.clone(), listed.path.clone()).await.is_err() {
7422                    self.mark_managed_recovery_required(
7423                        &lease.lease_id,
7424                        ManagedWorktreeCleanupFailure::Backend,
7425                    );
7426                    continue;
7427                }
7428            }
7429            let snapshot = {
7430                let mut registry = self.managed_worktrees.lock()
7431                    .unwrap_or_else(|poisoned| poisoned.into_inner());
7432                let current = registry.get_mut(&lease.lease_id)
7433                    .expect("reconciled managed lease remains present");
7434                current.expected_head = Some(listed.head.clone());
7435                current.cleanup_failure = None;
7436                current.state = if current.has_holders() {
7437                    ManagedWorktreeLeaseState::InUse
7438                } else if current.retention == ManagedWorktreeRetention::Retain {
7439                    ManagedWorktreeLeaseState::Retained
7440                } else {
7441                    ManagedWorktreeLeaseState::Ready
7442                };
7443                current.updated_at_unix_ms = unix_time_ms();
7444                current.snapshot()
7445            };
7446            if self.persist_state().is_err() {
7447                self.mark_managed_recovery_required(
7448                    &lease.lease_id,
7449                    ManagedWorktreeCleanupFailure::Backend,
7450                );
7451                continue;
7452            }
7453            self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
7454            if snapshot.state == ManagedWorktreeLeaseState::Ready {
7455                let _ = self.cleanup_managed_worktree(&lease.lease_id, false).await;
7456            }
7457        }
7458    }
7459
7460    /// Crash-recovery startup sweep: durably capture the pack for every
7461    /// `Live` managed record whose most recent session has not yet produced
7462    /// one (recovers a session whose `Exited{0}` event was observed but the
7463    /// Node crashed before the export finished). The reactive fast path for
7464    /// a single just-exited record does not go through this — see
7465    /// `drive_runtime_until_shutdown`, which resolves and spawns directly so
7466    /// it can capture the exact `SessionAddress` before `publish_control`
7467    /// clears it, rather than re-deriving it from (by then likely already
7468    /// `Dormant`) record state the way a rescan naturally would.
7469    ///
7470    /// Returns as soon as every eligible record's export has been *spawned*,
7471    /// not completed — each one runs on its own detached task (see
7472    /// `reconcile_context_pack_export_for_record`'s own doc comment for why:
7473    /// `NativeRuntime::tick()` has not even started its own loop yet at the
7474    /// point this is called from `NodeServer::run()`, so nothing could ever
7475    /// drain the history round trip if this awaited it inline here either).
7476    /// Best-effort throughout: never panics, no unwrap/expect; every failure
7477    /// is a silent skip for THIS record — an unusable source or a racing
7478    /// record is not surfaced to any caller.
7479    async fn reconcile_context_pack_exports(self: &Arc<Self>) {
7480        let candidates: Vec<(SessionRecordId, SessionAddress)> = self
7481            .session_records
7482            .lock()
7483            .unwrap_or_else(|poisoned| poisoned.into_inner())
7484            .records
7485            .values()
7486            .filter(|record| {
7487                record.state == ManagedSessionState::Live
7488                    && record.exported_context.is_none()
7489                    && record.provider_session.is_some()
7490            })
7491            .filter_map(|record| {
7492                record
7493                    .active_session
7494                    .clone()
7495                    .map(|session| (record.record_id.clone(), session))
7496            })
7497            .collect();
7498        for (record_id, session) in candidates {
7499            let shared = Arc::clone(self);
7500            tokio::spawn(async move {
7501                shared
7502                    .reconcile_context_pack_export_for_record(&record_id, session)
7503                    .await;
7504            });
7505        }
7506    }
7507
7508    /// Runs on its own detached task (`tokio::spawn`, see both call sites),
7509    /// never inline on `drive_runtime_until_shutdown`'s own task: the history
7510    /// round trip this performs (`discover_history`/`load_history`, via
7511    /// `export_context_pack_for_session_record`) can only ever settle once
7512    /// `NativeRuntime::tick()` runs again to drain the authority worker's
7513    /// completion — and `tick()` is exclusively owned by, and only ever
7514    /// called from, that same drive-loop task. Awaiting this inline there
7515    /// would starve `tick()` and this call could never observe its own
7516    /// command complete.
7517    ///
7518    /// Because this now genuinely races the drive loop's own
7519    /// `publish_control` (which downgrades this exact record from `Live` to
7520    /// `Dormant`, clearing `active_session`, for the very `Exited` event that
7521    /// triggered this reconcile), the record legitimately may already be
7522    /// `Dormant` by the time this reads it, or may transition mid-flight —
7523    /// `allow_clean_detachment: true` on the downstream export call is what
7524    /// tolerates exactly that transition (and only that transition; see
7525    /// `session_record_export_target_matches`).
7526    async fn reconcile_context_pack_export_for_record(
7527        &self,
7528        record_id: &SessionRecordId,
7529        session: SessionAddress,
7530    ) {
7531        let Ok(record) = self.record(record_id) else {
7532            return;
7533        };
7534        if !matches!(
7535            record.state,
7536            ManagedSessionState::Live | ManagedSessionState::Dormant
7537        ) || record.exported_context.is_some()
7538            || record.provider_session.is_none()
7539        {
7540            return;
7541        }
7542        // Pack bytes are already durably committed inside this call, via
7543        // commit_context_pack_for_session_record's own durable
7544        // write-through (context_pack_store) — no separate step here.
7545        // Unclean exits (non-zero/forced/no loadable history) are already
7546        // rejected by context_pack_source_status_is_usable inside
7547        // materialize_context_pack: this simply returns Err and is skipped.
7548        let Ok(receipt) = self
7549            .export_context_pack_for_session_record(&record.record_id, &session, true)
7550            .await
7551        else {
7552            return;
7553        };
7554        self.set_exported_context(&record.record_id, receipt);
7555    }
7556
7557    /// Records the record's most recent clean-exit pack. No-op (not an
7558    /// error) if the record disappeared, or if it already carries an
7559    /// `exported_context` — first-write-wins, matching the harness-side
7560    /// idempotent reconciliation that consumes this field.
7561    fn set_exported_context(&self, record_id: &SessionRecordId, receipt: ResolvedContextPackReceipt) {
7562        let transaction = self
7563            .state_transaction
7564            .lock()
7565            .unwrap_or_else(|poisoned| poisoned.into_inner());
7566        let (previous, updated) = {
7567            let mut records = self
7568                .session_records
7569                .lock()
7570                .unwrap_or_else(|poisoned| poisoned.into_inner());
7571            let Some(record) = records.records.get_mut(record_id) else {
7572                return;
7573            };
7574            if record.exported_context.is_some() {
7575                return;
7576            }
7577            let previous = record.clone();
7578            record.exported_context = Some(receipt);
7579            record.updated_at_unix_ms = unix_time_ms().max(record.updated_at_unix_ms);
7580            (previous, record.clone())
7581        };
7582        if self.persist_state_locked().is_err() {
7583            self.session_records
7584                .lock()
7585                .unwrap_or_else(|poisoned| poisoned.into_inner())
7586                .records
7587                .insert(record_id.clone(), previous);
7588            return;
7589        }
7590        drop(transaction);
7591        self.publish_record(updated);
7592    }
7593
7594    /// Resolves the managed record and its exact `SessionAddress` currently
7595    /// bound to `instance_id` at exactly `generation`, used by the reactive
7596    /// `Exited{0}` hook in `drive_runtime_until_shutdown` to target a single
7597    /// record without a full rescan. Mirrors the lookup already inlined in
7598    /// `publish_control`. Captured synchronously, before `publish_control`
7599    /// for the same event clears the record's own `active_session` — the
7600    /// caller passes the resolved address through to a detached
7601    /// reconciliation task rather than re-deriving it from the record later,
7602    /// after that race has necessarily already happened.
7603    fn managed_record_export_target_for_instance(
7604        &self,
7605        instance_id: AgentInstanceId,
7606        generation: SessionGeneration,
7607    ) -> Option<(SessionRecordId, SessionAddress)> {
7608        let bindings = self
7609            .session_bindings
7610            .lock()
7611            .unwrap_or_else(|poisoned| poisoned.into_inner());
7612        let binding = bindings.get(&instance_id).filter(|binding| binding.generation == generation)?;
7613        let record_id = binding.record_id.clone()?;
7614        Some((
7615            record_id,
7616            SessionAddress {
7617                workspace_id: binding.workspace_id.clone(),
7618                session: SessionKey { instance_id, generation },
7619            },
7620        ))
7621    }
7622
7623    fn mark_managed_recovery_required(
7624        &self,
7625        lease_id: &ManagedWorktreeLeaseId,
7626        failure_kind: ManagedWorktreeCleanupFailure,
7627    ) {
7628        let snapshot = {
7629            let mut registry = self.managed_worktrees.lock()
7630                .unwrap_or_else(|poisoned| poisoned.into_inner());
7631            let Some(lease) = registry.get_mut(lease_id) else { return };
7632            lease.state = ManagedWorktreeLeaseState::RecoveryRequired;
7633            lease.cleanup_failure = Some(failure_kind);
7634            lease.updated_at_unix_ms = unix_time_ms();
7635            lease.snapshot()
7636        };
7637        let _ = self.persist_state();
7638        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
7639    }
7640
7641    fn set_persistence_error(&self, error: Option<String>) {
7642        *self
7643            .persistence_error
7644            .write()
7645            .unwrap_or_else(|poisoned| poisoned.into_inner()) = error
7646            .map(|message| session_registry::sanitized_persistence_summary(&message));
7647    }
7648
7649    fn persist_state(&self) -> io::Result<()> {
7650        let _transaction = self
7651            .state_transaction
7652            .lock()
7653            .unwrap_or_else(|poisoned| poisoned.into_inner());
7654        self.persist_state_locked()
7655    }
7656
7657    fn persist_state_locked(&self) -> io::Result<()> {
7658        let workspaces = self
7659            .workspaces
7660            .read()
7661            .unwrap_or_else(|poisoned| poisoned.into_inner())
7662            .clone();
7663        let records = self
7664            .session_records
7665            .lock()
7666            .unwrap_or_else(|poisoned| poisoned.into_inner())
7667            .records
7668            .values()
7669            .cloned()
7670            .collect::<Vec<_>>();
7671        let mut managed = self.managed_worktrees
7672            .lock()
7673            .unwrap_or_else(|poisoned| poisoned.into_inner());
7674        managed.reattach_record_holders(
7675            &managed_worktree_record_holders(&records),
7676            unix_time_ms(),
7677        );
7678        let materializations = self
7679            .materializations
7680            .lock()
7681            .unwrap_or_else(|poisoned| poisoned.into_inner())
7682            .values()
7683            .cloned()
7684            .collect::<Vec<_>>();
7685        let managed_spawn_replays = self.managed_spawn_replays
7686            .lock()
7687            .unwrap_or_else(|poisoned| poisoned.into_inner())
7688            .values()
7689            .cloned()
7690            .collect::<Vec<_>>();
7691        let result = session_registry::save_v10(
7692            self.state_path.as_deref(),
7693            &self.node_id,
7694            &workspaces,
7695            &records,
7696            &managed.records(),
7697            &managed.tombstones(),
7698            &materializations,
7699            &managed_spawn_replays,
7700        );
7701        match &result {
7702            Ok(warning) => self.set_persistence_error(warning.clone()),
7703            Err(_) => self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned())),
7704        }
7705        result.map(|_| ())
7706    }
7707
7708    async fn begin_shutdown(&self) -> Result<(), NodeFailure> {
7709        let _mutation_guard = self.mutation_gate.lock().await;
7710        self.begin_shutdown_locked().await
7711    }
7712
7713    async fn begin_shutdown_locked(&self) -> Result<(), NodeFailure> {
7714        if self.shutdown.swap(true, Ordering::AcqRel) {
7715            return Ok(());
7716        }
7717        SessionLifecycleHook::on_shutdown(self);
7718        let deadline = Instant::now() + Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
7719        let mut first_error = None;
7720        for session in &self.handle.snapshot().sessions {
7721            let remaining = deadline.saturating_duration_since(Instant::now());
7722            if remaining.is_zero() {
7723                first_error.get_or_insert_with(|| {
7724                    failure(NodeFailureCode::BackendBusy, "stop-all dispatch exceeded its bounded deadline")
7725                });
7726                break;
7727            }
7728            if let Err(error) = self
7729                .dispatch_bounded(
7730                    ControlCommand::Stop {
7731                        instance_id: session.instance_id,
7732                        force: true,
7733                    },
7734                    remaining,
7735                )
7736                .await
7737            {
7738                first_error.get_or_insert(error);
7739            }
7740        }
7741        self.shutdown_notify.notify_waiters();
7742        match first_error {
7743            Some(error) => Err(error),
7744            None => Ok(()),
7745        }
7746    }
7747
7748    fn ensure_binding_capacity(&self) -> Result<(), NodeFailure> {
7749        let bindings = self
7750            .session_bindings
7751            .lock()
7752            .unwrap_or_else(|poisoned| poisoned.into_inner());
7753        if bindings.len() >= CONTROL_SESSIONS_MAX {
7754            Err(failure(
7755                NodeFailureCode::BackendBusy,
7756                "node session-binding capacity is full; remove an existing session first",
7757            ))
7758        } else {
7759            Ok(())
7760        }
7761    }
7762
7763    /// One-owner workspace/env collision guard: a live session on
7764    /// `workspace_id` with a different `environment_profile` (option
7765    /// equality on the resolved receipt, including Some vs None) blocks
7766    /// another spawn on that same cwd. Same profile may still share the
7767    /// workspace; distinct profiles need distinct workspace ids (managed
7768    /// worktree).
7769    fn ensure_workspace_environment_profile_compatible(
7770        &self,
7771        workspace_id: &WorkspaceId,
7772        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
7773    ) -> Result<(), NodeFailure> {
7774        let bindings = self
7775            .session_bindings
7776            .lock()
7777            .unwrap_or_else(|poisoned| poisoned.into_inner());
7778        for binding in bindings.values() {
7779            if &binding.workspace_id != workspace_id {
7780                continue;
7781            }
7782            if binding.environment_profile.as_ref() != environment_profile {
7783                return Err(failure(
7784                    NodeFailureCode::WorkspaceBusy,
7785                    "workspace already has a live session with a different environment profile; use a managed worktree for concurrent distinct ProviderHome/env bindings on one repo",
7786                ));
7787            }
7788        }
7789        Ok(())
7790    }
7791
7792    #[cfg(test)]
7793    fn bind_session(
7794        &self,
7795        instance_id: AgentInstanceId,
7796        workspace_id: WorkspaceId,
7797        generation: SessionGeneration,
7798    ) {
7799        self.bind_session_with_policy(
7800            &SessionAddress {
7801                workspace_id,
7802                session: SessionKey { instance_id, generation },
7803            },
7804            ProviderRuntimePolicy::raw_pty(),
7805            None,
7806        );
7807    }
7808
7809    #[cfg(test)]
7810    fn bind_session_with_policy(
7811        &self,
7812        address: &SessionAddress,
7813        runtime_policy: ProviderRuntimePolicy,
7814        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7815    ) {
7816        self.bind_session_with_materialization(
7817            address,
7818            runtime_policy,
7819            environment_profile,
7820            None,
7821            None,
7822            None,
7823        );
7824    }
7825
7826    fn bind_session_with_materialization(
7827        &self,
7828        address: &SessionAddress,
7829        runtime_policy: ProviderRuntimePolicy,
7830        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7831        bundle: Option<ResolvedBundleReceipt>,
7832        context: Option<ResolvedContextPackReceipt>,
7833        materialization_id: Option<MaterializationId>,
7834    ) {
7835        let mut bindings = self
7836            .session_bindings
7837            .lock()
7838            .unwrap_or_else(|poisoned| poisoned.into_inner());
7839        debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7840        debug_assert!(!bindings.contains_key(&address.session.instance_id));
7841        bindings.insert(address.session.instance_id, SessionBinding {
7842            workspace_id: address.workspace_id.clone(),
7843            generation: address.session.generation,
7844            runtime_policy,
7845            pending_resume: None,
7846            record_id: None,
7847            managed_worktree_lease_id: None,
7848            environment_profile,
7849            bundle,
7850            context,
7851            materialization_id,
7852        });
7853    }
7854
7855    #[cfg(test)]
7856    fn bind_managed_session(
7857        &self,
7858        address: &SessionAddress,
7859        record_id: SessionRecordId,
7860        runtime_policy: ProviderRuntimePolicy,
7861        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7862    ) {
7863        self.bind_managed_session_with_materialization(
7864            address,
7865            record_id,
7866            runtime_policy,
7867            environment_profile,
7868            None,
7869            None,
7870            None,
7871        );
7872    }
7873
7874    fn bind_managed_session_with_materialization(
7875        &self,
7876        address: &SessionAddress,
7877        record_id: SessionRecordId,
7878        runtime_policy: ProviderRuntimePolicy,
7879        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7880        bundle: Option<ResolvedBundleReceipt>,
7881        context: Option<ResolvedContextPackReceipt>,
7882        materialization_id: Option<MaterializationId>,
7883    ) {
7884        let mut bindings = self
7885            .session_bindings
7886            .lock()
7887            .unwrap_or_else(|poisoned| poisoned.into_inner());
7888        debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7889        debug_assert!(!bindings.contains_key(&address.session.instance_id));
7890        bindings.insert(
7891            address.session.instance_id,
7892            SessionBinding {
7893                workspace_id: address.workspace_id.clone(),
7894                generation: address.session.generation,
7895                runtime_policy,
7896                pending_resume: None,
7897                record_id: Some(record_id),
7898                managed_worktree_lease_id: None,
7899                environment_profile,
7900                bundle,
7901                context,
7902                materialization_id,
7903            },
7904        );
7905    }
7906
7907    #[cfg(test)]
7908    fn bind_spawn_session(
7909        &self,
7910        address: &SessionAddress,
7911        provider: AgentId,
7912        mode: SessionMode,
7913        runtime_policy: ProviderRuntimePolicy,
7914        record_policy: SpawnRecordPolicy,
7915        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7916    ) -> Result<Option<SessionRecordId>, NodeFailure> {
7917        self.bind_spawn_session_with_materialization(
7918            address,
7919            provider,
7920            mode,
7921            runtime_policy,
7922            record_policy,
7923            environment_profile,
7924            None,
7925            None,
7926            None,
7927        )
7928    }
7929
7930    fn bind_spawn_session_with_materialization(
7931        &self,
7932        address: &SessionAddress,
7933        provider: AgentId,
7934        mode: SessionMode,
7935        runtime_policy: ProviderRuntimePolicy,
7936        record_policy: SpawnRecordPolicy,
7937        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7938        bundle: Option<ResolvedBundleReceipt>,
7939        context: Option<ResolvedContextPackReceipt>,
7940        materialization_id: Option<MaterializationId>,
7941    ) -> Result<Option<SessionRecordId>, NodeFailure> {
7942        if record_policy == SpawnRecordPolicy::ProviderIdentityOnly
7943            && !runtime_policy.provider_session_identity
7944        {
7945            self.bind_session_with_materialization(
7946                address,
7947                runtime_policy,
7948                environment_profile,
7949                bundle,
7950                context,
7951                materialization_id,
7952            );
7953            return Ok(None);
7954        }
7955        let record_owns_lifecycle = runtime_policy.provider_session_identity;
7956        let record = self.new_record(
7957            address,
7958            provider,
7959            mode,
7960            active_record_state(runtime_policy.provider_session_identity, false),
7961            None,
7962            record_owns_lifecycle.then(|| environment_profile.clone()).flatten(),
7963            record_owns_lifecycle.then(|| bundle.clone()).flatten(),
7964            record_owns_lifecycle.then(|| context.clone()).flatten(),
7965        )?;
7966        let record_id = record.record_id.clone();
7967        let transaction = self
7968            .state_transaction
7969            .lock()
7970            .unwrap_or_else(|poisoned| poisoned.into_inner());
7971        self.insert_record(record.clone())?;
7972        let previous_materialization = if record_owns_lifecycle {
7973            if let Some(materialization_id) = materialization_id.as_ref() {
7974                let mut materializations = self
7975                    .materializations
7976                    .lock()
7977                    .unwrap_or_else(|poisoned| poisoned.into_inner());
7978                let Some(ownership) = materializations.get_mut(materialization_id) else {
7979                    drop(materializations);
7980                    self.remove_record_memory(&record_id);
7981                    return Err(failure(
7982                        NodeFailureCode::BackendOperationFailed,
7983                        "session environment ownership disappeared before binding",
7984                    ));
7985                };
7986                let previous = ownership.clone();
7987                if ownership
7988                    .transfer_to_record(record_id.clone(), unix_time_ms())
7989                    .is_err()
7990                {
7991                    drop(materializations);
7992                    self.remove_record_memory(&record_id);
7993                    return Err(failure(
7994                        NodeFailureCode::BackendOperationFailed,
7995                        "session environment ownership transfer failed",
7996                    ));
7997                }
7998                Some(previous)
7999            } else {
8000                None
8001            }
8002        } else {
8003            None
8004        };
8005        self.bind_managed_session_with_materialization(
8006            address,
8007            record_id.clone(),
8008            runtime_policy,
8009            environment_profile,
8010            bundle,
8011            context,
8012            materialization_id.clone(),
8013        );
8014        if let Err(error) = self.persist_state_locked() {
8015            self.remove_binding(address);
8016            self.remove_record_memory(&record_id);
8017            if let (Some(materialization_id), Some(previous)) =
8018                (materialization_id.as_ref(), previous_materialization)
8019            {
8020                self.materializations
8021                    .lock()
8022                    .unwrap_or_else(|poisoned| poisoned.into_inner())
8023                    .insert(materialization_id.clone(), previous);
8024            }
8025            return Err(persistence_failure(error));
8026        }
8027        drop(transaction);
8028        self.publish_record(record);
8029        Ok(Some(record_id))
8030    }
8031
8032    fn allocate_record_id(&self) -> Result<SessionRecordId, NodeFailure> {
8033        for _ in 0..8 {
8034            let bytes = random_nonce().map_err(|error| {
8035                failure(
8036                    NodeFailureCode::BackendOperationFailed,
8037                    &format!("session record identity generation failed: {error}"),
8038                )
8039            })?;
8040            let mut value = String::with_capacity(27);
8041            value.push_str("sr-");
8042            for byte in &bytes[..12] {
8043                use std::fmt::Write as _;
8044                let _ = write!(&mut value, "{byte:02x}");
8045            }
8046            let record_id = SessionRecordId::new(value).map_err(|error| {
8047                failure(NodeFailureCode::BackendOperationFailed, &error.to_string())
8048            })?;
8049            let records = self
8050                .session_records
8051                .lock()
8052                .unwrap_or_else(|poisoned| poisoned.into_inner());
8053            if !records.records.contains_key(&record_id) {
8054                return Ok(record_id);
8055            }
8056        }
8057        Err(failure(
8058            NodeFailureCode::BackendBusy,
8059            "could not allocate a unique session record ID",
8060        ))
8061    }
8062
8063    fn allocate_task_id(&self) -> Result<TaskId, NodeFailure> {
8064        for _ in 0..8 {
8065            let bytes = random_nonce().map_err(|error| {
8066                failure(
8067                    NodeFailureCode::BackendOperationFailed,
8068                    &format!("task identity generation failed: {error}"),
8069                )
8070            })?;
8071            let mut nonce = [0_u8; 12];
8072            nonce.copy_from_slice(&bytes[..12]);
8073            let task_id = TaskId::from_nonce(nonce);
8074            let records = self
8075                .session_records
8076                .lock()
8077                .unwrap_or_else(|poisoned| poisoned.into_inner());
8078            if !records.records.values().any(|record| {
8079                record.task_binding.as_ref().and_then(|binding| binding.task_id.as_ref())
8080                    == Some(&task_id)
8081            }) {
8082                return Ok(task_id);
8083            }
8084        }
8085        Err(failure(
8086            NodeFailureCode::BackendBusy,
8087            "could not allocate a unique task ID",
8088        ))
8089    }
8090
8091    fn default_record_name(&self, provider: &AgentId) -> String {
8092        let records = self
8093            .session_records
8094            .lock()
8095            .unwrap_or_else(|poisoned| poisoned.into_inner());
8096        let ordinal = records
8097            .records
8098            .values()
8099            .filter(|record| &record.provider == provider)
8100            .count()
8101            .saturating_add(1);
8102        format!("{} #{ordinal}", provider.as_str())
8103    }
8104
8105    fn new_record(
8106        &self,
8107        address: &SessionAddress,
8108        provider: AgentId,
8109        mode: SessionMode,
8110        state: ManagedSessionState,
8111        provider_session: Option<gate4agent_types::ProviderSessionIdentity>,
8112        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
8113        bundle: Option<ResolvedBundleReceipt>,
8114        context: Option<ResolvedContextPackReceipt>,
8115    ) -> Result<ManagedSessionRecord, NodeFailure> {
8116        let canonical_root = self.workspace_root(&address.workspace_id)?;
8117        let now = unix_time_ms();
8118        Ok(ManagedSessionRecord {
8119            record_id: self.allocate_record_id()?,
8120            display_name: self.m4a_nick_of(address.session.instance_id).unwrap_or_else(|| self.default_record_name(&provider)),
8121            provider,
8122            mode,
8123            state,
8124            workspace_id: address.workspace_id.clone(),
8125            canonical_root: opaque_windows_path(canonical_root),
8126            provider_session,
8127            active_session: Some(address.clone()),
8128            environment_profile,
8129            bundle,
8130            context_id: context.as_ref().map(|receipt| receipt.id.clone()),
8131            context,
8132            exported_context: None,
8133            task_binding: None,
8134            created_at_unix_ms: now,
8135            updated_at_unix_ms: now,
8136            last_error: None,
8137        })
8138    }
8139
8140    fn insert_record(&self, record: ManagedSessionRecord) -> Result<(), NodeFailure> {
8141        let mut records = self
8142            .session_records
8143            .lock()
8144            .unwrap_or_else(|poisoned| poisoned.into_inner());
8145        if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
8146            return Err(failure(
8147                NodeFailureCode::BackendBusy,
8148                "managed session record capacity is full; forget an inactive record first",
8149            ));
8150        }
8151        if let Some(identity) = record.provider_session.as_ref() {
8152            if records.records.values().any(|candidate| {
8153                candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8154                    session_registry::same_provider_session(
8155                        &candidate.provider,
8156                        candidate_identity,
8157                        &record.provider,
8158                        identity,
8159                    )
8160                })
8161            }) {
8162                return Err(failure(
8163                    NodeFailureCode::SessionRecordConflict,
8164                    "provider session is already represented by another managed record",
8165                ));
8166            }
8167        }
8168        match records.records.entry(record.record_id.clone()) {
8169            std::collections::btree_map::Entry::Vacant(entry) => {
8170                entry.insert(record);
8171            }
8172            std::collections::btree_map::Entry::Occupied(_) => {
8173                return Err(failure(
8174                    NodeFailureCode::SessionRecordConflict,
8175                    "session record ID collision",
8176                ));
8177            }
8178        }
8179        Ok(())
8180    }
8181
8182    fn record(&self, record_id: &SessionRecordId) -> Result<ManagedSessionRecord, NodeFailure> {
8183        self.session_records
8184            .lock()
8185            .unwrap_or_else(|poisoned| poisoned.into_inner())
8186            .records
8187            .get(record_id)
8188            .cloned()
8189            .ok_or_else(|| {
8190                failure(
8191                    NodeFailureCode::UnknownSessionRecord,
8192                    "managed session record does not exist",
8193                )
8194            })
8195    }
8196
8197    fn revalidate_session_record_preview(
8198        &self,
8199        expected: &ManagedSessionRecord,
8200        identity: &ProviderSessionIdentity,
8201    ) -> Result<(), NodeFailure> {
8202        let current = self.record(&expected.record_id)?;
8203        let current_root = self.workspace_root(&current.workspace_id)?;
8204        if current.provider != expected.provider
8205            || current.workspace_id != expected.workspace_id
8206            || current.canonical_root != expected.canonical_root
8207            || current.provider_session.as_ref() != Some(identity)
8208            || !platform::roots_equal(
8209                &current_root,
8210                windows_path_text(&current.canonical_root),
8211            )
8212        {
8213            return Err(failure(
8214                NodeFailureCode::SessionRecordConflict,
8215                "managed session changed while its preview was loading",
8216            ));
8217        }
8218        Ok(())
8219    }
8220
8221    fn revalidate_session_record_context_export(
8222        &self,
8223        expected: &ManagedSessionRecord,
8224        identity: Option<&ProviderSessionIdentity>,
8225        session: &SessionAddress,
8226        allow_clean_detachment: bool,
8227    ) -> Result<(), NodeFailure> {
8228        let current = self.record(&expected.record_id)?;
8229        let current_root = self.workspace_root(&current.workspace_id)?;
8230        let snapshot = self.internal_session_snapshot(session)?;
8231        let binding = self
8232            .session_bindings
8233            .lock()
8234            .unwrap_or_else(|poisoned| poisoned.into_inner())
8235            .get(&session.session.instance_id)
8236            .cloned()
8237            .ok_or_else(|| {
8238                failure(
8239                    NodeFailureCode::SessionRecordConflict,
8240                    "managed session runtime binding disappeared during context export",
8241                )
8242            })?;
8243        if !session_record_context_export_binding_is_exact(
8244            expected,
8245            &current,
8246            identity,
8247            session,
8248            &binding,
8249            &snapshot.agent_id,
8250            &current_root,
8251            allow_clean_detachment,
8252        ) || !session_record_context_export_source_is_usable(
8253            &current,
8254            &snapshot.status,
8255            allow_clean_detachment,
8256        ) {
8257            return Err(failure(
8258                NodeFailureCode::SessionRecordConflict,
8259                "managed session binding changed during context export",
8260            ));
8261        }
8262        Ok(())
8263    }
8264
8265    fn revalidate_loaded_history(
8266        &self,
8267        session: &SessionAddress,
8268        candidate_id: &str,
8269        loaded: &HistorySessionRecord,
8270    ) -> Result<(), NodeFailure> {
8271        let snapshot = self.internal_session_snapshot(session)?;
8272        if snapshot.history.pending.is_some()
8273            || snapshot.history.loaded_candidate_id.as_deref() != Some(candidate_id)
8274            || snapshot.history.loaded.as_ref() != Some(loaded)
8275        {
8276            return Err(failure(
8277                NodeFailureCode::SessionRecordConflict,
8278                "managed session history changed during context export",
8279            ));
8280        }
8281        Ok(())
8282    }
8283
8284    fn remove_record_memory(&self, record_id: &SessionRecordId) -> Option<ManagedSessionRecord> {
8285        self.session_records
8286            .lock()
8287            .unwrap_or_else(|poisoned| poisoned.into_inner())
8288            .records
8289            .remove(record_id)
8290    }
8291
8292    fn discard_record(&self, record_id: &SessionRecordId) -> Result<(), NodeFailure> {
8293        if self.destroy_record_materialization(record_id)? {
8294            self.publish(NodeEvent::SessionRecordRemoved {
8295                record_id: record_id.clone(),
8296            });
8297            return Ok(());
8298        }
8299        let transaction = self
8300            .state_transaction
8301            .lock()
8302            .unwrap_or_else(|poisoned| poisoned.into_inner());
8303        let Some(removed) = self.remove_record_memory(record_id) else {
8304            return Ok(());
8305        };
8306        if let Err(error) = self.persist_state_locked() {
8307            let _ = self.insert_record(removed);
8308            return Err(persistence_failure(error));
8309        }
8310        drop(transaction);
8311        self.publish(NodeEvent::SessionRecordRemoved {
8312            record_id: record_id.clone(),
8313        });
8314        Ok(())
8315    }
8316
8317    fn publish_record(&self, record: ManagedSessionRecord) {
8318        self.publish(NodeEvent::SessionRecordUpserted { record });
8319    }
8320
8321    fn mark_record_error(
8322        &self,
8323        record_id: &SessionRecordId,
8324        message: &str,
8325    ) -> Result<(), NodeFailure> {
8326        let transaction = self
8327            .state_transaction
8328            .lock()
8329            .unwrap_or_else(|poisoned| poisoned.into_inner());
8330        let previous = {
8331            let mut records = self
8332                .session_records
8333                .lock()
8334                .unwrap_or_else(|poisoned| poisoned.into_inner());
8335            let Some(record) = records.records.get_mut(record_id) else {
8336                return Ok(());
8337            };
8338            let previous = record.clone();
8339            record.state = ManagedSessionState::Unavailable;
8340            record.last_error = Some(session_registry::sanitized_record_error_summary(message));
8341            record.updated_at_unix_ms = unix_time_ms();
8342            previous
8343        };
8344        if let Err(error) = self.persist_state_locked() {
8345            self.session_records
8346                .lock()
8347                .unwrap_or_else(|poisoned| poisoned.into_inner())
8348                .records
8349                .insert(record_id.clone(), previous);
8350            return Err(persistence_failure(error));
8351        }
8352        let record = self.record(record_id)?;
8353        drop(transaction);
8354        self.publish_record(record);
8355        Ok(())
8356    }
8357
8358    fn rename_session_record(
8359        &self,
8360        record_id: &SessionRecordId,
8361        display_name: String,
8362    ) -> Result<ManagedSessionRecord, NodeFailure> {
8363        validate_display_name(&display_name)
8364            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8365        let _transaction = self
8366            .state_transaction
8367            .lock()
8368            .unwrap_or_else(|poisoned| poisoned.into_inner());
8369        let previous = {
8370            let mut records = self
8371                .session_records
8372                .lock()
8373                .unwrap_or_else(|poisoned| poisoned.into_inner());
8374            let record = records.records.get_mut(record_id).ok_or_else(|| {
8375                failure(
8376                    NodeFailureCode::UnknownSessionRecord,
8377                    "managed session record does not exist",
8378                )
8379            })?;
8380            let previous = record.clone();
8381            record.display_name = display_name;
8382            record.updated_at_unix_ms = unix_time_ms();
8383            previous
8384        };
8385        if let Err(error) = self.persist_state_locked() {
8386            self.session_records
8387                .lock()
8388                .unwrap_or_else(|poisoned| poisoned.into_inner())
8389                .records
8390                .insert(record_id.clone(), previous);
8391            return Err(persistence_failure(error));
8392        }
8393        let updated = self.record(record_id)?;
8394        self.publish_record(updated.clone());
8395        Ok(updated)
8396    }
8397
8398    fn set_session_task(
8399        &self,
8400        record_id: &SessionRecordId,
8401        expected_revision: u64,
8402        target: SessionTaskTargetV1,
8403    ) -> Result<ManagedSessionRecord, NodeFailure> {
8404        let transaction = self
8405            .state_transaction
8406            .lock()
8407            .unwrap_or_else(|poisoned| poisoned.into_inner());
8408        let current = self.record(record_id)?;
8409        let current_task_id = current
8410            .task_binding
8411            .as_ref()
8412            .and_then(|binding| binding.task_id.as_ref());
8413        let current_revision = current
8414            .task_binding
8415            .as_ref()
8416            .map_or(0, |binding| binding.revision);
8417        if expected_revision != current_revision {
8418            return Err(failure(
8419                NodeFailureCode::SessionRecordConflict,
8420                "managed session task binding revision changed",
8421            ));
8422        }
8423        let exact = match &target {
8424            SessionTaskTargetV1::Existing { task_id } => current_task_id == Some(task_id),
8425            SessionTaskTargetV1::Clear => current_task_id.is_none(),
8426            SessionTaskTargetV1::New => false,
8427        };
8428        if exact {
8429            return Ok(current);
8430        }
8431        let revision = current_revision.checked_add(1).ok_or_else(|| {
8432            failure(
8433                NodeFailureCode::SessionRecordConflict,
8434                "managed session task binding revision is exhausted",
8435            )
8436        })?;
8437        let task_id = match target {
8438            SessionTaskTargetV1::New => Some(self.allocate_task_id()?),
8439            SessionTaskTargetV1::Existing { task_id } => Some(task_id),
8440            SessionTaskTargetV1::Clear => None,
8441        };
8442        let changed_at_unix_ms = unix_time_ms()
8443            .max(current.updated_at_unix_ms.checked_add(1).ok_or_else(|| {
8444                failure(
8445                    NodeFailureCode::SessionRecordConflict,
8446                    "managed session record timestamp is exhausted",
8447                )
8448            })?);
8449        let updated = {
8450            let mut records = self
8451                .session_records
8452                .lock()
8453                .unwrap_or_else(|poisoned| poisoned.into_inner());
8454            let record = records.records.get_mut(record_id).ok_or_else(|| {
8455                failure(
8456                    NodeFailureCode::UnknownSessionRecord,
8457                    "managed session record does not exist",
8458                )
8459            })?;
8460            record.task_binding = Some(SessionTaskBindingV1 {
8461                revision,
8462                task_id,
8463                changed_at_unix_ms,
8464            });
8465            record.updated_at_unix_ms = changed_at_unix_ms;
8466            record.clone()
8467        };
8468        if let Err(error) = self.persist_state_locked() {
8469            self.session_records
8470                .lock()
8471                .unwrap_or_else(|poisoned| poisoned.into_inner())
8472                .records
8473                .insert(record_id.clone(), current);
8474            return Err(persistence_failure(error));
8475        }
8476        drop(transaction);
8477        self.publish_record(updated.clone());
8478        Ok(updated)
8479    }
8480
8481    fn index_provider_session(
8482        &self,
8483        workspace_id: WorkspaceId,
8484        provider: AgentId,
8485        identity: ProviderSessionIdentity,
8486        display_name: String,
8487    ) -> Result<ManagedSessionRecord, NodeFailure> {
8488        self.index_provider_session_with_policy(
8489            workspace_id,
8490            provider,
8491            identity,
8492            display_name,
8493            false,
8494        )
8495    }
8496
8497    fn index_provider_session_with_policy(
8498        &self,
8499        workspace_id: WorkspaceId,
8500        provider: AgentId,
8501        identity: ProviderSessionIdentity,
8502        display_name: String,
8503        allow_validated_transcript_path: bool,
8504    ) -> Result<ManagedSessionRecord, NodeFailure> {
8505        validate_display_name(&display_name)
8506            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8507        identity
8508            .validate()
8509            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8510        if identity.transcript_path.is_some() && !allow_validated_transcript_path {
8511            return Err(failure(
8512                NodeFailureCode::InvalidRequest,
8513                "provider session reference index v1 does not accept transcript paths",
8514            ));
8515        }
8516        let canonical_root = self.workspace_root(&workspace_id)?;
8517        if !self.enabled_providers.contains(&provider) {
8518            return Err(failure(
8519                NodeFailureCode::InvalidRequest,
8520                "provider is not enabled on this node",
8521            ));
8522        }
8523
8524        let transaction = self
8525            .state_transaction
8526            .lock()
8527            .unwrap_or_else(|poisoned| poisoned.into_inner());
8528        {
8529            let records = self
8530                .session_records
8531                .lock()
8532                .unwrap_or_else(|poisoned| poisoned.into_inner());
8533            if let Some(existing) = records.records.values().find(|candidate| {
8534                candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8535                    candidate_identity.key == identity.key && candidate_identity.id == identity.id
8536                })
8537            }) {
8538                if existing.provider == provider && existing.workspace_id == workspace_id {
8539                    return Ok(existing.clone());
8540                }
8541                return Err(failure(
8542                    NodeFailureCode::SessionRecordConflict,
8543                    "provider session reference conflicts with another provider or workspace",
8544                ));
8545            }
8546            if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
8547                return Err(failure(
8548                    NodeFailureCode::BackendBusy,
8549                    "managed session record capacity is full; forget an inactive record first",
8550                ));
8551            }
8552        }
8553
8554        let now = unix_time_ms();
8555        let record = ManagedSessionRecord {
8556            record_id: self.allocate_record_id()?,
8557            display_name,
8558            provider,
8559            mode: SessionMode::Pty,
8560            state: ManagedSessionState::Dormant,
8561            workspace_id,
8562            canonical_root: opaque_windows_path(canonical_root),
8563            provider_session: Some(identity),
8564            active_session: None,
8565            environment_profile: None,
8566            bundle: None,
8567            context_id: None,
8568            context: None,
8569            exported_context: None,
8570            task_binding: None,
8571            created_at_unix_ms: now,
8572            updated_at_unix_ms: now,
8573            last_error: None,
8574        };
8575        self.insert_record(record.clone())?;
8576        if let Err(error) = self.persist_state_locked() {
8577            self.remove_record_memory(&record.record_id);
8578            return Err(persistence_failure(error));
8579        }
8580        drop(transaction);
8581        self.publish_record(record.clone());
8582        Ok(record)
8583    }
8584
8585    fn bound_address_for_record(&self, record_id: &SessionRecordId) -> Option<SessionAddress> {
8586        self.session_bindings
8587            .lock()
8588            .unwrap_or_else(|poisoned| poisoned.into_inner())
8589            .iter()
8590            .find_map(|(instance_id, binding)| {
8591                (binding.record_id.as_ref() == Some(record_id)).then(|| SessionAddress {
8592                    workspace_id: binding.workspace_id.clone(),
8593                    session: SessionKey {
8594                        instance_id: *instance_id,
8595                        generation: binding.generation,
8596                    },
8597                })
8598            })
8599    }
8600
8601    async fn forget_session_record(
8602        &self,
8603        record_id: &SessionRecordId,
8604    ) -> Result<(), NodeFailure> {
8605        let record = self.record(record_id)?;
8606        if record.active_session.is_some()
8607            || matches!(
8608                record.state,
8609                ManagedSessionState::Live | ManagedSessionState::IdentityPending
8610            )
8611        {
8612            return Err(failure(
8613                NodeFailureCode::SessionRecordBusy,
8614                "stop the managed session before forgetting it",
8615            ));
8616        }
8617        if let Some(address) = self.bound_address_for_record(record_id) {
8618            self.remove_session(&address).await?;
8619        }
8620        self.remove_managed_session_record(record_id, &record.workspace_id).await
8621    }
8622
8623    /// Removes one managed session record from durable state and notifies
8624    /// every listener -- the shared transaction body behind both
8625    /// `forget_session_record`'s operator-driven path (busy-checked above
8626    /// it, and clears any still-live session binding before calling in
8627    /// here) and the retention sweep (`retire_unavailable_session_records`,
8628    /// which only ever selects `ManagedSessionState::Unavailable` records
8629    /// that are already known never-busy and never bound, so it calls
8630    /// straight into this with neither check). Cleans a managed-worktree
8631    /// lease if the record held one, removes the record's environment
8632    /// materialization (or, absent one, removes it from memory directly),
8633    /// persists under `state_transaction` with rollback on persist failure,
8634    /// then publishes `NodeEvent::SessionRecordRemoved` so c2's own
8635    /// legacy-provider bookkeeping (`record_is_legacy`) stays in sync
8636    /// exactly as it does for an operator-driven forget.
8637    async fn remove_managed_session_record(
8638        &self,
8639        record_id: &SessionRecordId,
8640        workspace_id: &WorkspaceId,
8641    ) -> Result<(), NodeFailure> {
8642        let managed_lease_id = self.managed_worktrees.lock()
8643            .unwrap_or_else(|poisoned| poisoned.into_inner())
8644            .lease_for_workspace(workspace_id);
8645        if !self.destroy_record_materialization(record_id)? {
8646            let transaction = self
8647                .state_transaction
8648                .lock()
8649                .unwrap_or_else(|poisoned| poisoned.into_inner());
8650            let removed = self.remove_record_memory(record_id).ok_or_else(|| {
8651                failure(
8652                    NodeFailureCode::UnknownSessionRecord,
8653                    "managed session record does not exist",
8654                )
8655            })?;
8656            if let Err(error) = self.persist_state_locked() {
8657                let _ = self.insert_record(removed);
8658                return Err(persistence_failure(error));
8659            }
8660            drop(transaction);
8661        }
8662        self.publish(NodeEvent::SessionRecordRemoved {
8663            record_id: record_id.clone(),
8664        });
8665        if let Some(lease_id) = managed_lease_id {
8666            if let Some(snapshot) = self.managed_worktrees.lock()
8667                .unwrap_or_else(|poisoned| poisoned.into_inner())
8668                .get(&lease_id)
8669                .map(ManagedWorktreeLeaseRecord::snapshot)
8670            {
8671                self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
8672            }
8673            let _ = self.cleanup_managed_worktree(&lease_id, false).await;
8674        }
8675        Ok(())
8676    }
8677
8678    /// Retires `ManagedSessionState::Unavailable` records against the
8679    /// node's configured `session_record_retention` age/keep-N policy.
8680    /// Never touches `Live`, `IdentityPending`, or `Dormant` records --
8681    /// `Dormant` is resumable and folding it into this sweep would delete
8682    /// records a caller can still act on. Both policy fields default to
8683    /// `0` (disabled); when both are `0` this is a no-op. When both are
8684    /// set, a record is retired only if it fails BOTH tests (older than
8685    /// `age_ms` AND beyond the newest `keep_per_workspace` in its
8686    /// workspace); when only one is set, that one alone decides. Called
8687    /// once at startup (alongside `reconcile_managed_worktrees`) and then
8688    /// on a `SESSION_RECORD_RETENTION_SWEEP_PERIOD` cadence from the drive
8689    /// loop -- see both call sites.
8690    async fn retire_unavailable_session_records(&self) {
8691        let policy = self.session_record_retention;
8692        let age_enabled = policy.age_ms > 0;
8693        let keep_enabled = policy.keep_per_workspace > 0;
8694        if !age_enabled && !keep_enabled {
8695            return;
8696        }
8697        let now = unix_time_ms();
8698        let candidates: Vec<(SessionRecordId, WorkspaceId, AgentId, u64)> = {
8699            self.session_records
8700                .lock()
8701                .unwrap_or_else(|poisoned| poisoned.into_inner())
8702                .records
8703                .values()
8704                .filter(|record| record.state == ManagedSessionState::Unavailable)
8705                .map(|record| {
8706                    (
8707                        record.record_id.clone(),
8708                        record.workspace_id.clone(),
8709                        record.provider.clone(),
8710                        record.updated_at_unix_ms,
8711                    )
8712                })
8713                .collect()
8714        };
8715        if candidates.is_empty() {
8716            return;
8717        }
8718        let mut by_workspace: BTreeMap<WorkspaceId, Vec<(SessionRecordId, AgentId, u64)>> =
8719            BTreeMap::new();
8720        for (record_id, workspace_id, provider, updated_at_unix_ms) in candidates {
8721            by_workspace
8722                .entry(workspace_id)
8723                .or_default()
8724                .push((record_id, provider, updated_at_unix_ms));
8725        }
8726        let reason = if age_enabled && keep_enabled {
8727            "age+keep-n"
8728        } else if age_enabled {
8729            "age"
8730        } else {
8731            "keep-n"
8732        };
8733        let mut retired: u64 = 0;
8734        for (workspace_id, mut records) in by_workspace {
8735            // Newest `updated_at_unix_ms` first, so index `>=
8736            // keep_per_workspace` names exactly "beyond the newest N".
8737            records.sort_by(|left, right| right.2.cmp(&left.2));
8738            for (index, (record_id, provider, updated_at_unix_ms)) in
8739                records.into_iter().enumerate()
8740            {
8741                let record_age_ms = now.saturating_sub(updated_at_unix_ms);
8742                let age_hit = age_enabled && record_age_ms >= policy.age_ms;
8743                let keep_hit = keep_enabled && index >= policy.keep_per_workspace as usize;
8744                let should_retire = if age_enabled && keep_enabled {
8745                    age_hit && keep_hit
8746                } else {
8747                    age_hit || keep_hit
8748                };
8749                if !should_retire {
8750                    continue;
8751                }
8752                match self.remove_managed_session_record(&record_id, &workspace_id).await {
8753                    Ok(()) => {
8754                        self.retired_records_total.fetch_add(1, Ordering::Relaxed);
8755                        retired += 1;
8756                        tracing::info!(
8757                            node_id = %self.node_id,
8758                            record_id = %record_id,
8759                            workspace_id = %workspace_id,
8760                            provider = %provider,
8761                            age_ms = record_age_ms,
8762                            reason,
8763                            "managed session record retired"
8764                        );
8765                    }
8766                    Err(error) => tracing::warn!(
8767                        node_id = %self.node_id,
8768                        record_id = %record_id,
8769                        workspace_id = %workspace_id,
8770                        code = ?error.code,
8771                        cause = %error.message,
8772                        "session record retention sweep failed to retire a record"
8773                    ),
8774                }
8775            }
8776        }
8777        if retired > 0 {
8778            let remaining_unavailable = self
8779                .session_records
8780                .lock()
8781                .unwrap_or_else(|poisoned| poisoned.into_inner())
8782                .records
8783                .values()
8784                .filter(|record| record.state == ManagedSessionState::Unavailable)
8785                .count();
8786            tracing::info!(
8787                node_id = %self.node_id,
8788                retired,
8789                remaining_unavailable,
8790                "session record retention sweep completed"
8791            );
8792        }
8793    }
8794
8795    async fn resume_session_record(
8796        &self,
8797        record_id: &SessionRecordId,
8798        terminal_size: gate4agent_types::TerminalSize,
8799        initial_prompt: Option<String>,
8800    ) -> Result<(ManagedSessionRecord, SessionAddress), NodeFailure> {
8801        let mut record = self.record(record_id)?;
8802        // claude's ACP agent has no session/load: an ACP claude conversation whose transcript
8803        // is on disk is resumed as a PTY `claude --resume <id>` of that same transcript.
8804        if record.mode == SessionMode::Acp
8805            && record.provider.as_str() == "claude"
8806            && record.provider_session.as_ref().is_some_and(|i| provider_session_exists("claude", &i.id))
8807        {
8808            record.mode = SessionMode::Pty;
8809            if let Some(r) = self.session_records.lock().unwrap_or_else(|p| p.into_inner()).records.get_mut(record_id) {
8810                r.mode = SessionMode::Pty;
8811            }
8812            tracing::info!(record = %record_id, "acp claude record resumed over pty (--resume of its transcript)");
8813        }
8814        if record.active_session.is_some()
8815            || matches!(
8816                record.state,
8817                ManagedSessionState::Live | ManagedSessionState::IdentityPending
8818            )
8819        {
8820            return Err(failure(
8821                NodeFailureCode::SessionRecordBusy,
8822                "managed session already has a live runtime binding",
8823            ));
8824        }
8825        let identity = record.provider_session.clone().ok_or_else(|| {
8826            failure(
8827                NodeFailureCode::SessionRecordNotResumable,
8828                "managed session has no verified provider session identity",
8829            )
8830        })?;
8831        let working_directory = self.workspace_root(&record.workspace_id)?;
8832        if !platform::roots_equal(
8833            &working_directory,
8834            windows_path_text(&record.canonical_root),
8835        ) {
8836            return Err(failure(
8837                NodeFailureCode::SessionRecordConflict,
8838                "managed session workspace root changed; refusing to resume in another directory",
8839            ));
8840        }
8841        {
8842            let records = self
8843                .session_records
8844                .lock()
8845                .unwrap_or_else(|poisoned| poisoned.into_inner());
8846            if records.records.values().any(|candidate| {
8847                candidate.record_id != *record_id
8848                    && candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8849                        session_registry::same_provider_session(
8850                            &candidate.provider,
8851                            candidate_identity,
8852                            &record.provider,
8853                            &identity,
8854                        )
8855                    })
8856                    && (candidate.active_session.is_some()
8857                        || matches!(
8858                            candidate.state,
8859                            ManagedSessionState::Live | ManagedSessionState::IdentityPending
8860                        ))
8861            }) {
8862                return Err(failure(
8863                    NodeFailureCode::SessionRecordConflict,
8864                    "provider session is already active through another managed record",
8865                ));
8866            }
8867        }
8868        let runtime_requirement = if initial_prompt.is_some() {
8869            ProviderRuntimeRequirement::ResumeWithPrompt
8870        } else {
8871            ProviderRuntimeRequirement::Resume
8872        };
8873        let runtime_policy = self.admit_provider_runtime(
8874            &record.provider,
8875            runtime_requirement,
8876        )
8877        .await?;
8878        // The resumed runtime runs in the session folder its record was named after.
8879        let working_directory = match self.m4a_accounts.as_ref() {
8880            Some(_) => {
8881                let dir = std::path::Path::new(&working_directory).join(".hatchery-sessions").join(&record.display_name);
8882                if !record.display_name.contains(' ') && dir.is_dir() { dir.to_string_lossy().into_owned() } else { working_directory }
8883            }
8884            None => working_directory,
8885        };
8886        let request = ResumeLaunchRequest {
8887            working_directory,
8888            terminal_size,
8889            initial_prompt,
8890        };
8891        request
8892            .validate()
8893            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8894        self.ensure_binding_capacity()?;
8895        let resolved_materialization = match self.resolve_record_materialization(
8896            record_id,
8897            &record.provider,
8898            record.mode,
8899            record.environment_profile.as_ref(),
8900            record.bundle.as_ref(),
8901            record.context.as_ref(),
8902        ) {
8903            Ok(materialization) => materialization,
8904            Err(error) => {
8905                let label = match error.code {
8906                    NodeFailureCode::UnknownBundle => "bundle-unavailable",
8907                    NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
8908                    NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
8909                    _ => "environment-profile-unavailable",
8910                };
8911                self.mark_record_error(record_id, label)?;
8912                return Err(error);
8913            }
8914        };
8915        let (materialization_id, environment_overlay) = match resolved_materialization {
8916            Some((id, overlay)) => (Some(id), overlay),
8917            None => (None, None),
8918        };
8919        let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
8920        // Resumed runtime: same auto-permit flags and launch options (model / effort) as a spawn.
8921        let environment_overlay = {
8922            let mut args: Vec<OsString> = Vec::new();
8923            if let Ok(cwd) = self.workspace_root(&record.workspace_id) {
8924                let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
8925                if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") { homes.extend(std::env::split_paths(&extra)); }
8926                crate::auto_permit::prepare(record.provider.as_str(), &homes, Some(std::path::Path::new(&cwd)));
8927            }
8928            args.extend(crate::auto_permit::launch_args(record.provider.as_str(), record.mode));
8929            let options = SPAWN_SESSION_OPTIONS.try_with(|o| o.clone()).ok().flatten();
8930            if record.mode == SessionMode::Pty && record.provider.as_str() == "claude" {
8931                if let Some(sel) = options.as_ref() {
8932                    args.push("--model".into()); args.push(sel.model.clone().into());
8933                    if let Some(effort) = sel.values.get("effort").and_then(|v| v.as_str()) {
8934                        args.push("--effort".into()); args.push(effort.to_owned().into());
8935                    }
8936                }
8937            }
8938            if matches!(record.mode, SessionMode::Pty | SessionMode::Acp) && crate::pty_resume::journalled(record.provider.as_str(), record.mode == SessionMode::Acp) {
8939                if let (Some(file), Some(identity)) = (self.session_resume_journal(), record.provider_session.as_ref()) {
8940                    crate::pty_resume::upsert(&file, crate::pty_resume::Entry {
8941                        instance_id: instance_id.0,
8942                        provider: record.provider.as_str().to_owned(),
8943                        workspace_id: record.workspace_id.as_str().to_owned(),
8944                        provider_session_id: identity.id.clone(),
8945                        session_key: self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).get(&instance_id).cloned().unwrap_or_default(),
8946                        cwd: String::new(),
8947                        acp: record.mode == SessionMode::Acp,
8948                        options,
8949                        cols: 0,
8950                        rows: 0,
8951                        origin: SPAWN_ORIGIN.try_with(|o| o.clone()).ok().flatten(),
8952                        parent_session_id: SPAWN_PARENT.try_with(|o| o.clone()).ok().flatten(),
8953                    });
8954                }
8955            }
8956            overlay_with_extra_args(&record.provider, record.mode, environment_overlay, args)?
8957        };
8958        // The resumed runtime keeps the m4a account of the runtime it replaces.
8959        let environment_overlay = match (self.m4a_accounts.as_ref(), crate::m4a_accounts::m4a_provider(record.provider.as_str())) {
8960            (Some(accounts), Some(m4a_provider)) if matches!(record.mode, SessionMode::Pty | SessionMode::Acp) => {
8961                if let Some(old) = self.bound_address_for_record(record_id) {
8962                    if let Some(key) = self.m4a_keys.lock().unwrap_or_else(|p| p.into_inner()).get(&old.session.instance_id).cloned() {
8963                        self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).insert(instance_id, key);
8964                    }
8965                }
8966                let session_key = self.m4a_session_key(instance_id);
8967                self.m4a_session_overlay(accounts, m4a_provider, &record.provider, instance_id, environment_overlay, &session_key, record.mode)?
8968            }
8969            _ => environment_overlay,
8970        };
8971        let mut environment_selection = match SpawnPreparer::select_environment(self, 
8972            instance_id,
8973            &record.provider,
8974            record.mode,
8975            record.environment_profile.as_ref(),
8976        ) {
8977            Ok(selection) => selection,
8978            Err(error) => {
8979                self.mark_record_error(record_id, "environment-profile-unavailable")?;
8980                return Err(error);
8981            }
8982        };
8983        let mut instance_overlay = environment_overlay
8984            .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
8985            .transpose()?
8986            .flatten();
8987        if let Some(stale_address) = self.bound_address_for_record(record_id) {
8988            self.remove_session(&stale_address).await?;
8989        }
8990        let address = SessionAddress {
8991            workspace_id: record.workspace_id.clone(),
8992            session: SessionKey {
8993                instance_id,
8994                generation: SessionGeneration(1),
8995            },
8996        };
8997        let transaction = self
8998            .state_transaction
8999            .lock()
9000            .unwrap_or_else(|poisoned| poisoned.into_inner());
9001        let current = self.record(record_id)?;
9002        if current.active_session.is_some()
9003            || matches!(
9004                current.state,
9005                ManagedSessionState::Live | ManagedSessionState::IdentityPending
9006            )
9007        {
9008            return Err(failure(
9009                NodeFailureCode::SessionRecordBusy,
9010                "managed session became active while resume was being prepared",
9011            ));
9012        }
9013        self.bind_managed_session_with_materialization(
9014            &address,
9015            record_id.clone(),
9016            runtime_policy,
9017            record.environment_profile.clone(),
9018            record.bundle.clone(),
9019            record.context.clone(),
9020            materialization_id,
9021        );
9022        if let Some(selection) = environment_selection.take() {
9023            selection.retain();
9024        }
9025        if let Some(overlay) = instance_overlay.take() {
9026            overlay.retain();
9027        }
9028        drop(transaction);
9029
9030        let transport = match record.mode {
9031            SessionMode::Pty => TransportKind::Pty,
9032            SessionMode::Inline => TransportKind::Pipe,
9033            SessionMode::Acp => TransportKind::Acp,
9034        };
9035        let agent_id = record.provider.clone();
9036        let dispatch_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
9037        if let Err(error) = self
9038            .dispatch_bounded(
9039                ControlCommand::Register {
9040                    instance_id,
9041                    agent_id,
9042                    transport,
9043                },
9044                dispatch_timeout,
9045            )
9046            .await
9047        {
9048            if let Some(binding) = self.remove_binding(&address) {
9049                SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
9050            }
9051            return Err(error);
9052        }
9053        let command = self.prepare_command(ControlCommand::Resume {
9054            instance_id,
9055            target: ResumeTarget::ProviderSession { identity },
9056            runtime_policy,
9057            request,
9058        });
9059        let started_after = self.current_sequence();
9060        if let Err(error) = self.arm_resume(&address, command.id, runtime_policy) {
9061            self.rollback_spawn(&address, dispatch_timeout).await?;
9062            return Err(error);
9063        }
9064        if let Err(error) = self.dispatch_envelope(command) {
9065            self.clear_armed_resume(&address);
9066            self.rollback_spawn(&address, dispatch_timeout).await?;
9067            return Err(error);
9068        }
9069        match self
9070            .wait_for_record_resume(&address, started_after)
9071            .await
9072        {
9073            Ok(settled_address) => {
9074                let managed_lease_id = {
9075                    self.managed_worktrees.lock()
9076                        .unwrap_or_else(|poisoned| poisoned.into_inner())
9077                        .lease_for_workspace(&record.workspace_id)
9078                };
9079                if let Some(lease_id) = managed_lease_id {
9080                    {
9081                        let mut bindings = self.session_bindings.lock()
9082                            .unwrap_or_else(|poisoned| poisoned.into_inner());
9083                        let binding = bindings.get_mut(&settled_address.session.instance_id)
9084                            .ok_or_else(|| failure(
9085                                NodeFailureCode::ManagedWorktreeRecoveryRequired,
9086                                "resumed managed session lost its exact binding",
9087                            ))?;
9088                        if binding.generation != settled_address.session.generation {
9089                            return Err(failure(
9090                                NodeFailureCode::ManagedWorktreeRecoveryRequired,
9091                                "resumed managed session generation diverged",
9092                            ));
9093                        }
9094                        binding.managed_worktree_lease_id = Some(lease_id.clone());
9095                    }
9096                    let snapshot = self.managed_worktrees.lock()
9097                        .unwrap_or_else(|poisoned| poisoned.into_inner())
9098                        .bind_session(
9099                            &lease_id,
9100                            ManagedWorktreeSessionHolder {
9101                                incarnation_id: self.incarnation_id,
9102                                instance_id: settled_address.session.instance_id,
9103                                generation: settled_address.session.generation,
9104                            },
9105                            Some(record_id.clone()),
9106                            unix_time_ms(),
9107                        ).map_err(|message| failure(
9108                            NodeFailureCode::ManagedWorktreeRecoveryRequired,
9109                            &message,
9110                        ))?;
9111                    if let Err(error) = self.persist_state() {
9112                        let _ = self.remove_session(&settled_address).await;
9113                        return Err(persistence_failure(error));
9114                    }
9115                    self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
9116                }
9117                Ok((self.record(record_id)?, settled_address))
9118            }
9119            Err(error) => {
9120                if matches!(
9121                    error.code,
9122                    NodeFailureCode::SessionRecordNotResumable
9123                        | NodeFailureCode::BackendOperationFailed
9124                ) {
9125                    if let Some(bound) = self.bound_address_for_record(record_id) {
9126                        let _ = self.remove_session(&bound).await;
9127                    }
9128                }
9129                Err(error)
9130            }
9131        }
9132    }
9133
9134    async fn wait_for_record_resume(
9135        &self,
9136        initial_address: &SessionAddress,
9137        after_sequence: u64,
9138    ) -> Result<SessionAddress, NodeFailure> {
9139        let deadline = Instant::now()
9140            + Duration::from_millis(MANAGED_RESUME_SETTLE_TIMEOUT_MS);
9141        let mut scan_after = after_sequence;
9142        loop {
9143            let events = self
9144                .history
9145                .lock()
9146                .unwrap_or_else(|poisoned| poisoned.into_inner())
9147                .events
9148                .iter()
9149                .filter(|event| event.sequence > scan_after)
9150                .cloned()
9151                .collect::<Vec<_>>();
9152            for envelope in events {
9153                scan_after = scan_after.max(envelope.sequence);
9154                let NodeEvent::Control { address, event } = envelope.event else {
9155                    continue;
9156                };
9157                if address.session.instance_id != initial_address.session.instance_id {
9158                    continue;
9159                }
9160                match event.event {
9161                    ControlEventKind::Resumed { .. } => return Ok(address),
9162                    ControlEventKind::ResumeDenied { reason } => {
9163                        return Err(failure(
9164                            NodeFailureCode::SessionRecordNotResumable,
9165                            &reason,
9166                        ));
9167                    }
9168                    ControlEventKind::ResumeFailed { message }
9169                    | ControlEventKind::CommandRejected { message } => {
9170                        return Err(failure(
9171                            NodeFailureCode::BackendOperationFailed,
9172                            &message,
9173                        ));
9174                    }
9175                    _ => {}
9176                }
9177            }
9178            if Instant::now() >= deadline {
9179                return Err(failure(
9180                    NodeFailureCode::BackendBusy,
9181                    "managed resume did not settle before the bounded deadline",
9182                ));
9183            }
9184            sleep(Duration::from_millis(5)).await;
9185        }
9186    }
9187
9188    fn workspace_root(&self, workspace_id: &WorkspaceId) -> Result<String, NodeFailure> {
9189        self.workspaces
9190            .read()
9191            .unwrap_or_else(|poisoned| poisoned.into_inner())
9192            .get(workspace_id)
9193            .cloned()
9194            .ok_or_else(|| failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"))
9195    }
9196
9197    fn workspace_roots(&self) -> Vec<PathBuf> {
9198        self.workspaces
9199            .read()
9200            .unwrap_or_else(|poisoned| poisoned.into_inner())
9201            .values()
9202            .map(PathBuf::from)
9203            .collect()
9204    }
9205
9206    async fn inspect_workspace(
9207        &self,
9208        workspace_id: WorkspaceId,
9209    ) -> Result<WorkspaceInspection, NodeFailure> {
9210        let _permit = self
9211            .inspection_slots
9212            .clone()
9213            .try_acquire_owned()
9214            .map_err(|_| {
9215                tracing::warn!(
9216                    workspace_id = %workspace_id,
9217                    "workspace inspection rejected: inspection capacity is busy",
9218                );
9219                failure(
9220                    NodeFailureCode::BackendBusy,
9221                    "workspace inspection capacity is busy",
9222                )
9223            })?;
9224        let canonical_root = self.workspace_root(&workspace_id).map_err(|error| {
9225            tracing::warn!(
9226                workspace_id = %workspace_id,
9227                code = ?error.code,
9228                "workspace inspection rejected: workspace is not registered",
9229            );
9230            error
9231        })?;
9232        let tree_root = canonical_root.clone();
9233        let started_at = Instant::now();
9234        let time_budget_ms = workspace_inspection_time_budget_ms();
9235        let entry_cap = workspace_inspection_entry_cap();
9236        let deadline = started_at + Duration::from_millis(time_budget_ms);
9237        let (entries, tree_truncated, walk_budget) = tokio::task::spawn_blocking(move || {
9238            collect_workspace_entries(Path::new(&tree_root), deadline, entry_cap)
9239        })
9240        .await
9241        .map_err(|error| {
9242            tracing::warn!(
9243                workspace_id = %workspace_id,
9244                error = %error,
9245                "workspace inspection rejected: walk task failed",
9246            );
9247            failure(
9248                NodeFailureCode::BackendOperationFailed,
9249                &format!("workspace inspection task failed: {error}"),
9250            )
9251        })?;
9252        let (mut git, git_time_budget_exceeded) =
9253            inspect_git_workspace(&canonical_root, deadline).await;
9254        if !git.worktrees.is_empty() {
9255            let registered = self
9256                .workspaces
9257                .read()
9258                .unwrap_or_else(|poisoned| poisoned.into_inner())
9259                .iter()
9260                .map(|(workspace_id, root)| (workspace_id.clone(), root.clone()))
9261                .collect::<Vec<_>>();
9262            for worktree in &mut git.worktrees {
9263                worktree.workspace_id = registered
9264                    .iter()
9265                    .find(|(_, root)| {
9266                        worktree_paths_equal(root, windows_path_text(&worktree.path))
9267                    })
9268                    .map(|(workspace_id, _)| workspace_id.clone());
9269            }
9270        }
9271        if git.is_repository {
9272            git.managed_worktree = self
9273                .managed_worktrees
9274                .lock()
9275                .unwrap_or_else(|poisoned| poisoned.into_inner())
9276                .git_scope(&workspace_id, git.branch.as_deref());
9277        }
9278        let truncation = workspace_inspection_truncation(
9279            &walk_budget,
9280            git_time_budget_exceeded,
9281            started_at.elapsed(),
9282        );
9283        if let Some(truncation) = &truncation {
9284            tracing::info!(
9285                workspace_id = %workspace_id,
9286                walk_time_budget_exceeded = truncation.walk_time_budget_exceeded,
9287                walk_entry_cap_exceeded = truncation.walk_entry_cap_exceeded,
9288                git_time_budget_exceeded = truncation.git_time_budget_exceeded,
9289                entries_visited = truncation.entries_visited,
9290                elapsed_ms = truncation.elapsed_ms,
9291                "workspace inspection truncated by its inner budget",
9292            );
9293        }
9294        Ok(WorkspaceInspection {
9295            workspace_id,
9296            entries,
9297            tree_truncated,
9298            git,
9299            truncation,
9300        })
9301    }
9302
9303    async fn browse_host_directories(
9304        &self,
9305        directory: Option<OpaqueHostPath>,
9306        after: Option<OpaqueHostPath>,
9307    ) -> Result<HostDirectoryListing, NodeFailure> {
9308        let permit = self
9309            .inspection_slots
9310            .clone()
9311            .try_acquire_owned()
9312            .map_err(|_| {
9313                failure(
9314                    NodeFailureCode::BackendBusy,
9315                    "host directory browse capacity is busy",
9316                )
9317            })?;
9318        let task = tokio::task::spawn_blocking(move || {
9319            let _permit = permit;
9320            browse_host_directories(directory, after)
9321        });
9322        timeout(Duration::from_millis(HOST_DIRECTORY_BROWSE_TIMEOUT_MS), task)
9323            .await
9324            .map_err(|_| {
9325                failure(
9326                    NodeFailureCode::HostDirectoryReadTimedOut,
9327                    "host directory browse exceeded its bounded deadline",
9328                )
9329            })?
9330            .map_err(|_| {
9331                failure(
9332                    NodeFailureCode::HostDirectoryReadFailed,
9333                    "host directory browse task failed",
9334                )
9335            })?
9336            .map_err(host_directory_failure)
9337    }
9338
9339    async fn read_workspace_file(
9340        &self,
9341        workspace_id: WorkspaceId,
9342        path: RepositoryPath,
9343    ) -> Result<WorkspaceFileRead, NodeFailure> {
9344        let canonical_root = self.workspace_root(&workspace_id)?;
9345        let permit = self
9346            .inspection_slots
9347            .clone()
9348            .try_acquire_owned()
9349            .map_err(|_| {
9350                failure(
9351                    NodeFailureCode::BackendBusy,
9352                    "workspace file read capacity is busy",
9353                )
9354            })?;
9355        let path_to_read = path.clone();
9356        let task = tokio::task::spawn_blocking(move || {
9357            let _permit = permit;
9358            read_workspace_file_from_disk(Path::new(&canonical_root), &path_to_read)
9359        });
9360        let content = timeout(
9361            Duration::from_millis(WORKSPACE_FILE_READ_TIMEOUT_MS),
9362            task,
9363        )
9364        .await
9365        .map_err(|_| {
9366            failure(
9367                NodeFailureCode::RepositoryFileReadTimedOut,
9368                "repository file read exceeded its bounded deadline",
9369            )
9370        })?
9371        .map_err(|_| {
9372            failure(
9373                NodeFailureCode::RepositoryFileReadFailed,
9374                "repository file read task failed",
9375            )
9376        })?
9377        .map_err(workspace_file_failure)?;
9378        let content = match content {
9379            WorkspaceFileBytes::Utf8(text) => {
9380                let revision = Some(workspace_file_revision(text.as_bytes()));
9381                return Ok(WorkspaceFileRead {
9382                    workspace_id,
9383                    path,
9384                    content: WorkspaceFileContent::Utf8 {
9385                        byte_len: u32::try_from(text.len())
9386                            .expect("bounded workspace text length must fit u32"),
9387                        text,
9388                    },
9389                    revision,
9390                });
9391            }
9392            WorkspaceFileBytes::NonUtf8 { byte_length } => {
9393                WorkspaceFileContent::NonUtf8 {
9394                    byte_len: u32::try_from(byte_length)
9395                        .expect("bounded workspace file length must fit u32"),
9396                }
9397            }
9398            WorkspaceFileBytes::TooLarge => WorkspaceFileContent::TooLarge {
9399                limit_bytes: MAX_WORKSPACE_FILE_BYTES as u32,
9400            },
9401        };
9402        Ok(WorkspaceFileRead {
9403            workspace_id,
9404            path,
9405            content,
9406            revision: None,
9407        })
9408    }
9409
9410    async fn write_workspace_file(
9411        &self,
9412        workspace_id: WorkspaceId,
9413        path: RepositoryPath,
9414        expected_revision: WorkspaceFileRevision,
9415        text: String,
9416    ) -> Result<WorkspaceFileRead, NodeFailure> {
9417        let canonical_root = self.workspace_root(&workspace_id)?;
9418        let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9419            failure(NodeFailureCode::BackendBusy, "workspace file write capacity is busy")
9420        })?;
9421        let path_to_write = path.clone();
9422        let expected = expected_revision.as_str().to_owned();
9423        let text_to_write = text.clone();
9424        let task = tokio::task::spawn_blocking(move || {
9425            let _permit = permit;
9426            write_workspace_file_to_disk(
9427                Path::new(&canonical_root),
9428                &path_to_write,
9429                &expected,
9430                &text_to_write,
9431            )
9432        });
9433        let revision = timeout(Duration::from_millis(WORKSPACE_FILE_WRITE_TIMEOUT_MS), task)
9434            .await
9435            .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteTimedOut, "repository file write exceeded its bounded deadline"))?
9436            .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteFailed, "repository file write task failed"))?
9437            .map_err(workspace_file_write_failure)?;
9438        Ok(WorkspaceFileRead {
9439            workspace_id,
9440            path,
9441            content: WorkspaceFileContent::Utf8 {
9442                byte_len: u32::try_from(text.len()).expect("bounded workspace text length must fit u32"),
9443                text,
9444            },
9445            revision: Some(WorkspaceFileRevision::new(revision)
9446                .expect("workspace writer returns a valid SHA-256 revision")),
9447        })
9448    }
9449
9450    async fn create_workspace_file(
9451        &self,
9452        workspace_id: WorkspaceId,
9453        path: RepositoryPath,
9454    ) -> Result<WorkspaceFileRead, NodeFailure> {
9455        let canonical_root = self.workspace_root(&workspace_id)?;
9456        let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9457            failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
9458        })?;
9459        let path_to_create = path.clone();
9460        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
9461        let task_commit_state = Arc::clone(&commit_state);
9462        let task = tokio::task::spawn_blocking(move || {
9463            let _permit = permit;
9464            create_workspace_file_on_disk(
9465                Path::new(&canonical_root),
9466                &path_to_create,
9467                &task_commit_state,
9468            )
9469        });
9470        let revision = settle_workspace_entry_create(
9471            task,
9472            commit_state,
9473            Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
9474            "repository file create exceeded its bounded deadline",
9475            "repository file create task failed",
9476        )
9477        .await?;
9478        Ok(WorkspaceFileRead {
9479            workspace_id,
9480            path,
9481            content: WorkspaceFileContent::Utf8 {
9482                text: String::new(),
9483                byte_len: 0,
9484            },
9485            revision: Some(WorkspaceFileRevision::new(revision)
9486                .expect("workspace creator returns a valid SHA-256 revision")),
9487        })
9488    }
9489
9490    async fn create_workspace_directory(
9491        &self,
9492        workspace_id: WorkspaceId,
9493        path: RepositoryPath,
9494    ) -> Result<WorkspaceEntry, NodeFailure> {
9495        let canonical_root = self.workspace_root(&workspace_id)?;
9496        let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9497            failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
9498        })?;
9499        let path_to_create = path.clone();
9500        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
9501        let task_commit_state = Arc::clone(&commit_state);
9502        let task = tokio::task::spawn_blocking(move || {
9503            let _permit = permit;
9504            create_workspace_directory_on_disk(
9505                Path::new(&canonical_root),
9506                &path_to_create,
9507                &task_commit_state,
9508            )
9509        });
9510        settle_workspace_entry_create(
9511            task,
9512            commit_state,
9513            Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
9514            "repository directory create exceeded its bounded deadline",
9515            "repository directory create task failed",
9516        )
9517        .await?;
9518        Ok(WorkspaceEntry {
9519            relative_path: path,
9520            kind: WorkspaceEntryKind::Directory,
9521        })
9522    }
9523
9524    async fn read_git_history(
9525        &self,
9526        workspace_id: WorkspaceId,
9527        path: Option<RepositoryPath>,
9528        before: Option<GitObjectId>,
9529        limit: u16,
9530    ) -> Result<GitHistoryPage, NodeFailure> {
9531        let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9532            failure(NodeFailureCode::BackendBusy, "git history capacity is busy")
9533        })?;
9534        let root = self.workspace_root(&workspace_id)?;
9535        read_git_history_bounded(&root, path.as_ref(), before.as_ref(), limit).await
9536    }
9537
9538    async fn read_git_diff(
9539        &self,
9540        workspace_id: WorkspaceId,
9541        request: GitDiffRequest,
9542    ) -> Result<GitDiff, NodeFailure> {
9543        let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9544            failure(NodeFailureCode::BackendBusy, "git diff capacity is busy")
9545        })?;
9546        let root = self.workspace_root(&workspace_id)?;
9547        read_git_diff_bounded(&root, request).await
9548    }
9549
9550    fn native_session_route_root(
9551        &self,
9552        route: &NativeSessionCatalogRoute,
9553    ) -> Result<Option<String>, NodeFailure> {
9554        route
9555            .validate()
9556            .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9557        match route.scope {
9558            gate4agent_types::NativeSessionCatalogScope::Workspace => route
9559                .workspace_id
9560                .as_ref()
9561                .map(|workspace_id| self.workspace_root(workspace_id))
9562                .transpose(),
9563            gate4agent_types::NativeSessionCatalogScope::Unregistered => Ok(None),
9564        }
9565    }
9566
9567    fn project_native_session_entries(
9568        &self,
9569        route: &NativeSessionCatalogRoute,
9570        entries: Vec<ScopedNativeSessionCatalogEntry>,
9571    ) -> Vec<crate::protocol::NativeSessionCatalogEntry> {
9572        let current_root = route
9573            .workspace_id
9574            .as_ref()
9575            .and_then(|workspace_id| self.workspace_root(workspace_id).ok());
9576        let records = self
9577            .session_records
9578            .lock()
9579            .unwrap_or_else(|poisoned| poisoned.into_inner());
9580        let mut seen_records = std::collections::HashSet::new();
9581        entries
9582            .into_iter()
9583            .map(|entry| {
9584                let record_id = route.workspace_id.as_ref().and_then(|workspace_id| {
9585                    let matches = records
9586                        .records
9587                        .values()
9588                        .filter(|record| {
9589                            record.provider == route.provider
9590                                && record.workspace_id == *workspace_id
9591                                && current_root.as_deref().is_some_and(|root| {
9592                                    platform::roots_equal(
9593                                        root,
9594                                        windows_path_text(&record.canonical_root),
9595                                    )
9596                                })
9597                                && record.provider_session.as_ref().is_some_and(|identity| {
9598                                    session_registry::same_provider_session(
9599                                        &record.provider,
9600                                        identity,
9601                                        &route.provider,
9602                                        &entry.provider_session,
9603                                    )
9604                                })
9605                        })
9606                        .map(|record| record.record_id.clone())
9607                        .collect::<Vec<_>>();
9608                    (matches.len() == 1
9609                        && seen_records.insert(matches[0].clone()))
9610                        .then(|| matches[0].clone())
9611                });
9612                let metadata = entry.metadata;
9613                crate::protocol::NativeSessionCatalogEntry {
9614                    selection_id: metadata.selection_id,
9615                    title: metadata.title,
9616                    modified_at_unix_ms: metadata.modified_at_unix_ms,
9617                    model: metadata.model,
9618                    message_count: metadata.message_count,
9619                    completed_turn_count: metadata.completed_turn_count,
9620                    external_group: entry.external_group,
9621                    record_id,
9622                }
9623            })
9624            .collect()
9625    }
9626
9627    async fn catalog_native_sessions(
9628        &self,
9629        route: NativeSessionCatalogRoute,
9630        limit: u16,
9631    ) -> Result<(
9632        Vec<crate::protocol::NativeSessionCatalogEntry>,
9633        crate::protocol::NativeSessionCatalogSummary,
9634    ), NodeFailure> {
9635        let canonical_root = self.native_session_route_root(&route)?;
9636        let registered_workspace_roots = self.workspace_roots();
9637        let provider = route.provider.clone();
9638        let scope = route.scope;
9639        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9640            failure(
9641                NodeFailureCode::InvalidRequest,
9642                "native session catalog is not configured",
9643            )
9644        })?;
9645        let _permit = Arc::clone(&self.inspection_slots)
9646            .try_acquire_owned()
9647            .map_err(|_| {
9648                failure(
9649                    NodeFailureCode::BackendBusy,
9650                    "native session catalog capacity is busy",
9651                )
9652            })?;
9653        let page = run_native_session_catalog_operation(
9654            catalog,
9655            "native session catalog",
9656            move |catalog| {
9657                catalog.catalog_initial_for_scope(
9658                    &provider,
9659                    scope,
9660                    canonical_root.as_deref().map(Path::new),
9661                    &registered_workspace_roots,
9662                    limit,
9663                )
9664            },
9665        )
9666        .await?;
9667        let summary = crate::protocol::NativeSessionCatalogSummary {
9668            catalog_revision: page.revision,
9669            recent_cutoff_unix_ms: page.cutoff_unix_ms,
9670            recent_total_count: page.recent_total_count,
9671            older_total_count: page.older_total_count,
9672            recent_next_after_selection_id: page.next_after_selection_id,
9673            recent_has_more: page.remaining_count > 0,
9674        };
9675        Ok((self.project_native_session_entries(&route, page.entries), summary))
9676    }
9677
9678    async fn page_native_sessions(
9679        &self,
9680        route: NativeSessionCatalogRoute,
9681        window: crate::protocol::NativeSessionCatalogWindow,
9682        catalog_revision: u64,
9683        recent_cutoff_unix_ms: u64,
9684        after_selection_id: Option<String>,
9685        limit: u16,
9686    ) -> Result<crate::protocol::NativeSessionCatalogPage, NodeFailure> {
9687        let canonical_root = self.native_session_route_root(&route)?;
9688        let registered_workspace_roots = self.workspace_roots();
9689        let provider = route.provider.clone();
9690        let scope = route.scope;
9691        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9692            failure(
9693                NodeFailureCode::InvalidRequest,
9694                "native session catalog is not configured",
9695            )
9696        })?;
9697        let _permit = Arc::clone(&self.inspection_slots)
9698            .try_acquire_owned()
9699            .map_err(|_| {
9700                failure(
9701                    NodeFailureCode::BackendBusy,
9702                    "native session catalog capacity is busy",
9703                )
9704            })?;
9705        let page = run_native_session_catalog_operation(
9706            catalog,
9707            "native session catalog page",
9708            move |catalog| {
9709                Ok::<_, std::convert::Infallible>(catalog.catalog_page_for_scope(
9710                    &provider,
9711                    scope,
9712                    canonical_root.as_deref().map(Path::new),
9713                    &registered_workspace_roots,
9714                    window,
9715                    catalog_revision,
9716                    recent_cutoff_unix_ms,
9717                    after_selection_id.as_deref(),
9718                    limit,
9719                ))
9720            },
9721        )
9722        .await?;
9723        let page = page.map_err(|error| match error {
9724            NativeSessionCatalogError::StaleCatalog => failure(
9725                NodeFailureCode::StaleNativeSessionCatalog,
9726                "native session catalog revision or cursor is stale",
9727            ),
9728            _ => failure(
9729                NodeFailureCode::BackendOperationFailed,
9730                "native session catalog page failed",
9731            ),
9732        })?;
9733        let entries = self.project_native_session_entries(&route, page.entries);
9734        Ok(crate::protocol::NativeSessionCatalogPage {
9735            window: page.window,
9736            revision: page.revision,
9737            entries,
9738            next_after_selection_id: page.next_after_selection_id,
9739            remaining_count: page.remaining_count,
9740            has_more: page.remaining_count > 0,
9741        })
9742    }
9743
9744    async fn preview_native_session(
9745        &self,
9746        selection: NativeSessionSelection,
9747        message_limit: u16,
9748    ) -> Result<crate::protocol::NativeSessionPreview, NodeFailure> {
9749        selection
9750            .validate()
9751            .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9752        let canonical_root = self.native_session_route_root(&selection.route)?;
9753        let registered_workspace_roots = self.workspace_roots();
9754        let provider = selection.route.provider.clone();
9755        let scope = selection.route.scope;
9756        let catalog_revision = selection.catalog_revision;
9757        let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9758        let selection_id = selection.selection_id.clone();
9759        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9760            failure(
9761                NodeFailureCode::InvalidRequest,
9762                "native session preview is not configured",
9763            )
9764        })?;
9765        let _permit = Arc::clone(&self.inspection_slots)
9766            .try_acquire_owned()
9767            .map_err(|_| {
9768                failure(
9769                    NodeFailureCode::BackendBusy,
9770                    "native session preview capacity is busy",
9771                )
9772            })?;
9773        let preview = run_native_session_catalog_operation(
9774            catalog,
9775            "native session preview",
9776            move |catalog| {
9777                Ok::<_, std::convert::Infallible>(catalog.preview_for_scope(
9778                    &provider,
9779                    scope,
9780                    canonical_root.as_deref().map(Path::new),
9781                    &registered_workspace_roots,
9782                    catalog_revision,
9783                    recent_cutoff_unix_ms,
9784                    &selection_id,
9785                    message_limit,
9786                ))
9787            },
9788        )
9789        .await?;
9790        preview
9791            .map(Into::into)
9792            .map_err(native_session_preview_failure)
9793    }
9794
9795    async fn index_native_session(
9796        &self,
9797        selection: NativeSessionSelection,
9798        display_name: String,
9799    ) -> Result<ManagedSessionRecord, NodeFailure> {
9800        selection
9801            .validate()
9802            .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9803        if selection.route.scope
9804            != gate4agent_types::NativeSessionCatalogScope::Workspace
9805        {
9806            return Err(failure(
9807                NodeFailureCode::WorkspaceRegistrationRequired,
9808                "register the external project as a workspace before indexing or resuming it",
9809            ));
9810        }
9811        let workspace_id = selection
9812            .route
9813            .workspace_id
9814            .clone()
9815            .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9816        let canonical_root = self
9817            .native_session_route_root(&selection.route)?
9818            .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9819        let registered_workspace_roots = self.workspace_roots();
9820        let provider = selection.route.provider.clone();
9821        let catalog_revision = selection.catalog_revision;
9822        let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9823        let selection_id = selection.selection_id.clone();
9824        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9825            failure(
9826                NodeFailureCode::InvalidRequest,
9827                "native session index is not configured",
9828            )
9829        })?;
9830        let _permit = Arc::clone(&self.inspection_slots)
9831            .try_acquire_owned()
9832            .map_err(|_| {
9833                failure(
9834                    NodeFailureCode::BackendBusy,
9835                    "native session index capacity is busy",
9836                )
9837            })?;
9838        let resolved = run_native_session_catalog_operation(
9839            catalog,
9840            "native session index resolution",
9841            move |catalog| {
9842                Ok::<_, std::convert::Infallible>(catalog.resolve_selection_for_scope(
9843                    &provider,
9844                    gate4agent_types::NativeSessionCatalogScope::Workspace,
9845                    Some(Path::new(&canonical_root)),
9846                    &registered_workspace_roots,
9847                    catalog_revision,
9848                    recent_cutoff_unix_ms,
9849                    &selection_id,
9850                ))
9851            },
9852        )
9853        .await?
9854        .map_err(native_session_preview_failure)?;
9855        self.index_provider_session_with_policy(
9856            workspace_id,
9857            selection.route.provider,
9858            resolved.identity,
9859            display_name,
9860            true,
9861        )
9862    }
9863
9864    async fn preview_native_session_by_session_id(
9865        &self,
9866        workspace_id: WorkspaceId,
9867        provider: AgentId,
9868        session_id: String,
9869        message_limit: u16,
9870    ) -> Result<gate4agent_types::NativeSessionPreview, NodeFailure> {
9871        let canonical_root = self.workspace_root(&workspace_id)?;
9872        let registered_workspace_roots = self.workspace_roots();
9873        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9874            failure(
9875                NodeFailureCode::InvalidRequest,
9876                "native session preview is not configured",
9877            )
9878        })?;
9879        let _permit = Arc::clone(&self.inspection_slots)
9880            .try_acquire_owned()
9881            .map_err(|_| {
9882                failure(
9883                    NodeFailureCode::BackendBusy,
9884                    "native session preview capacity is busy",
9885                )
9886            })?;
9887        run_native_session_catalog_operation(
9888            catalog,
9889            "native session preview",
9890            move |catalog| {
9891                catalog.preview_session_id_for_workspace(
9892                    &provider,
9893                    Path::new(&canonical_root),
9894                    &registered_workspace_roots,
9895                    &session_id,
9896                    message_limit,
9897                )
9898            },
9899        )
9900        .await
9901    }
9902
9903    async fn register_workspace(
9904        &self,
9905        workspace_id: WorkspaceId,
9906        root: String,
9907    ) -> Result<WorkspaceSnapshot, NodeFailure> {
9908        validate_workspace_request_root(&root)?;
9909        let canonicalize_workspace_id = workspace_id.clone();
9910        let workspace = tokio::task::spawn_blocking(move || {
9911            WorkspaceConfig::new(canonicalize_workspace_id, root)
9912        })
9913        .await
9914        .map_err(|error| {
9915            failure(
9916                NodeFailureCode::BackendOperationFailed,
9917                &format!("workspace canonicalization task failed: {error}"),
9918            )
9919        })?
9920        .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
9921        let transaction = self
9922            .state_transaction
9923            .lock()
9924            .unwrap_or_else(|poisoned| poisoned.into_inner());
9925        let mut workspaces = self
9926            .workspaces
9927            .write()
9928            .unwrap_or_else(|poisoned| poisoned.into_inner());
9929        if workspaces.contains_key(&workspace_id) {
9930            return Err(failure(
9931                NodeFailureCode::DuplicateWorkspaceId,
9932                "workspace ID is already registered",
9933            ));
9934        }
9935        if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
9936            platform::roots_equal(existing_root, workspace.canonical_root())
9937        }) {
9938            return Err(failure(
9939                NodeFailureCode::DuplicateWorkspaceRoot,
9940                &format!("workspace root is already registered as '{existing_id}'"),
9941            ));
9942        }
9943        {
9944            let records = self
9945                .session_records
9946                .lock()
9947                .unwrap_or_else(|poisoned| poisoned.into_inner());
9948            if let Some(record) = records.records.values().find(|record| {
9949                record.workspace_id == workspace_id
9950                    && !record
9951                        .canonical_root
9952                        .as_utf8()
9953                        .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9954            }) {
9955                return Err(failure(
9956                    NodeFailureCode::SessionRecordConflict,
9957                    &format!(
9958                        "workspace ID is retained by session record '{}' at another root",
9959                        record.record_id,
9960                    ),
9961                ));
9962            }
9963        }
9964        let snapshot = WorkspaceSnapshot {
9965            workspace_id: workspace_id.clone(),
9966            canonical_root: opaque_windows_path(workspace.canonical_root().to_owned()),
9967            sessions: Vec::new(),
9968            worktree_service_mode: Some(WorktreeServiceMode::Manual),
9969            managed_worktree_profiles: Some(WorktreeProfileInventory {
9970                profiles: Vec::new(),
9971            }),
9972        };
9973        workspaces.insert(workspace_id.clone(), workspace.canonical_root().to_owned());
9974        drop(workspaces);
9975        let previous_records = {
9976            let mut records = self
9977                .session_records
9978                .lock()
9979                .unwrap_or_else(|poisoned| poisoned.into_inner());
9980            let previous = records.records.clone();
9981            for record in records.records.values_mut().filter(|record| {
9982                record.workspace_id == workspace_id
9983                    && record
9984                        .canonical_root
9985                        .as_utf8()
9986                        .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9987            }) {
9988                if record.active_session.is_none() {
9989                    record.state = detached_record_state(record);
9990                }
9991                if record.last_error.as_deref() == Some(WORKSPACE_UNAVAILABLE_ERROR) {
9992                    record.last_error = None;
9993                }
9994                record.updated_at_unix_ms = unix_time_ms();
9995            }
9996            previous
9997        };
9998        if let Err(error) = self.persist_state_locked() {
9999            self.workspaces
10000                .write()
10001                .unwrap_or_else(|poisoned| poisoned.into_inner())
10002                .remove(&workspace_id);
10003            self.session_records
10004                .lock()
10005                .unwrap_or_else(|poisoned| poisoned.into_inner())
10006                .records = previous_records;
10007            return Err(persistence_failure(error));
10008        }
10009        let restored = self
10010            .session_records
10011            .lock()
10012            .unwrap_or_else(|poisoned| poisoned.into_inner())
10013            .records
10014            .values()
10015            .filter(|record| record.workspace_id == workspace_id)
10016            .cloned()
10017            .collect::<Vec<_>>();
10018        drop(transaction);
10019        self.publish(NodeEvent::WorkspaceAdded {
10020            workspace: snapshot.clone(),
10021        });
10022        for record in restored {
10023            self.publish_record(record);
10024        }
10025        Ok(snapshot)
10026    }
10027
10028    async fn create_standalone_workspace(
10029        &self,
10030        workspace_id: WorkspaceId,
10031        root: String,
10032        initial_branch: Option<String>,
10033    ) -> Result<WorkspaceSnapshot, NodeFailure> {
10034        validate_workspace_request_root(&root)?;
10035        validate_git_revision("initial branch", initial_branch.as_deref())?;
10036        let preflight_workspace_id = workspace_id.clone();
10037        let preflight_root = root.clone();
10038        let canonical_candidate = tokio::task::spawn_blocking(move || {
10039            canonical_standalone_workspace_candidate(&preflight_workspace_id, &preflight_root)
10040        })
10041        .await
10042        .map_err(|_| failure(
10043            NodeFailureCode::BackendOperationFailed,
10044            "standalone workspace root preflight task failed",
10045        ))?
10046        .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
10047        {
10048            let workspaces = self
10049                .workspaces
10050                .read()
10051                .unwrap_or_else(|poisoned| poisoned.into_inner());
10052            if workspaces.contains_key(&workspace_id) {
10053                return Err(failure(
10054                    NodeFailureCode::DuplicateWorkspaceId,
10055                    "workspace ID is already registered",
10056                ));
10057            }
10058            if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
10059                platform::roots_equal(existing_root, &canonical_candidate)
10060            }) {
10061                return Err(failure(
10062                    NodeFailureCode::DuplicateWorkspaceRoot,
10063                    &format!("workspace root is already registered as '{existing_id}'"),
10064                ));
10065            }
10066        }
10067        let prepared = prepare_standalone_workspace(root, initial_branch.as_deref())
10068            .await
10069            .map_err(standalone_workspace_failure)?;
10070        prepared
10071            .verify_for_registration()
10072            .await
10073            .map_err(standalone_workspace_failure)?;
10074        match self
10075            .register_workspace(workspace_id, prepared.root().to_owned())
10076            .await
10077        {
10078            Ok(workspace) => Ok(workspace),
10079            Err(registration_error) => {
10080                if prepared.compensate_registration_failure().await {
10081                    Err(registration_error)
10082                } else {
10083                    Err(failure(
10084                        NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
10085                        "standalone workspace initialization succeeded but registration failed; recovery is required",
10086                    ))
10087                }
10088            }
10089        }
10090    }
10091
10092    fn unregister_workspace(&self, workspace_id: &WorkspaceId) -> Result<(), NodeFailure> {
10093        let bindings = self
10094            .session_bindings
10095            .lock()
10096            .unwrap_or_else(|poisoned| poisoned.into_inner());
10097        if bindings.values().any(|binding| &binding.workspace_id == workspace_id) {
10098            return Err(failure(
10099                NodeFailureCode::WorkspaceBusy,
10100                "workspace retains a bound session; remove every session before unregistering it",
10101            ));
10102        }
10103        drop(bindings);
10104        let transaction = self
10105            .state_transaction
10106            .lock()
10107            .unwrap_or_else(|poisoned| poisoned.into_inner());
10108        let mut workspaces = self
10109            .workspaces
10110            .write()
10111            .unwrap_or_else(|poisoned| poisoned.into_inner());
10112        if !workspaces.contains_key(workspace_id) {
10113            return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
10114        }
10115        if workspaces.len() == 1 {
10116            return Err(failure(
10117                NodeFailureCode::LastWorkspace,
10118                "node must retain at least one workspace",
10119            ));
10120        }
10121        let removed_root = workspaces
10122            .remove(workspace_id)
10123            .expect("workspace presence was validated");
10124        drop(workspaces);
10125        let previous_records = {
10126            let mut records = self
10127                .session_records
10128                .lock()
10129                .unwrap_or_else(|poisoned| poisoned.into_inner());
10130            let previous = records.records.clone();
10131            for record in records
10132                .records
10133                .values_mut()
10134                .filter(|record| &record.workspace_id == workspace_id)
10135            {
10136                record.active_session = None;
10137                record.state = ManagedSessionState::Unavailable;
10138                record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
10139                record.updated_at_unix_ms = unix_time_ms();
10140            }
10141            previous
10142        };
10143        if let Err(error) = self.persist_state_locked() {
10144            self.workspaces
10145                .write()
10146                .unwrap_or_else(|poisoned| poisoned.into_inner())
10147                .insert(workspace_id.clone(), removed_root);
10148            self.session_records
10149                .lock()
10150                .unwrap_or_else(|poisoned| poisoned.into_inner())
10151                .records = previous_records;
10152            return Err(persistence_failure(error));
10153        }
10154        let unavailable = self
10155            .session_records
10156            .lock()
10157            .unwrap_or_else(|poisoned| poisoned.into_inner())
10158            .records
10159            .values()
10160            .filter(|record| &record.workspace_id == workspace_id)
10161            .cloned()
10162            .collect::<Vec<_>>();
10163        drop(transaction);
10164        self.publish(NodeEvent::WorkspaceRemoved {
10165            workspace_id: workspace_id.clone(),
10166        });
10167        for record in unavailable {
10168            self.publish_record(record);
10169        }
10170        Ok(())
10171    }
10172
10173    async fn create_worktree(
10174        &self,
10175        source_workspace_id: WorkspaceId,
10176        workspace_id: WorkspaceId,
10177        target_root: String,
10178        branch: String,
10179        base: Option<String>,
10180    ) -> Result<(GitWorktreeSnapshot, WorkspaceSnapshot), NodeFailure> {
10181        self.require_worktree_service(&source_workspace_id)?;
10182        validate_workspace_request_root(&target_root)?;
10183        validate_git_revision("worktree base", base.as_deref())?;
10184        let source_root = self.workspace_root(&source_workspace_id)?;
10185        {
10186            let workspaces = self
10187                .workspaces
10188                .read()
10189                .unwrap_or_else(|poisoned| poisoned.into_inner());
10190            if workspaces.contains_key(&workspace_id) {
10191                return Err(failure(
10192                    NodeFailureCode::DuplicateWorkspaceId,
10193                    "workspace ID is already registered",
10194                ));
10195            }
10196            if let Some((existing_id, _)) = workspaces
10197                .iter()
10198                .find(|(_, root)| worktree_paths_equal(root, &target_root))
10199            {
10200                return Err(failure(
10201                    NodeFailureCode::DuplicateWorkspaceRoot,
10202                    &format!("workspace root is already registered as '{existing_id}'"),
10203                ));
10204            }
10205        }
10206        let mut worktree = create_git_worktree(
10207            &source_root,
10208            &target_root,
10209            &branch,
10210            base.as_deref(),
10211        )
10212        .await
10213        .map_err(git_worktree_failure)?;
10214        let workspace = match self
10215            .register_workspace(workspace_id, worktree.path.clone())
10216            .await
10217        {
10218            Ok(workspace) => workspace,
10219            Err(registration_error) => {
10220                if remove_git_worktree(&source_root, &worktree.path).await.is_err() {
10221                    return Err(failure(
10222                        NodeFailureCode::ManagedWorktreeRecoveryRequired,
10223                        "worktree registration failed and compensation requires recovery",
10224                    ));
10225                }
10226                return Err(failure(
10227                    registration_error.code,
10228                    &registration_error.message,
10229                ));
10230            }
10231        };
10232        worktree.workspace_id = Some(workspace.workspace_id.clone());
10233        Ok((protocol_worktree(worktree), workspace))
10234    }
10235
10236    async fn remove_worktree(
10237        &self,
10238        source_workspace_id: WorkspaceId,
10239        target_root: String,
10240    ) -> Result<Option<WorkspaceId>, NodeFailure> {
10241        self.require_worktree_service(&source_workspace_id)?;
10242        validate_workspace_request_root(&target_root)?;
10243        let source_root = self.workspace_root(&source_workspace_id)?;
10244        let target_root = normalize_worktree_removal_target(&target_root)
10245            .map_err(git_worktree_failure)?;
10246        let target = list_git_worktrees(&source_root)
10247            .await
10248            .map_err(git_worktree_failure)?
10249            .into_iter()
10250            .find(|worktree| worktree_paths_equal(&worktree.path, &target_root))
10251            .ok_or_else(|| {
10252                failure(
10253                    NodeFailureCode::WorktreeProtected,
10254                    "refusing to remove a path that is not in Git's worktree listing",
10255                )
10256            })?;
10257        let registered_workspace_id = {
10258            let workspaces = self
10259                .workspaces
10260                .read()
10261                .unwrap_or_else(|poisoned| poisoned.into_inner());
10262            let matched = workspaces
10263                .iter()
10264                .find(|(_, root)| worktree_paths_equal(root, &target.path))
10265                .map(|(workspace_id, _)| workspace_id.clone());
10266            if matched.is_some() && workspaces.len() == 1 {
10267                return Err(failure(
10268                    NodeFailureCode::LastWorkspace,
10269                    "node must retain at least one workspace",
10270                ));
10271            }
10272            matched
10273        };
10274        if let Some(workspace_id) = registered_workspace_id.as_ref() {
10275            let bindings = self
10276                .session_bindings
10277                .lock()
10278                .unwrap_or_else(|poisoned| poisoned.into_inner());
10279            if bindings
10280                .values()
10281                .any(|binding| &binding.workspace_id == workspace_id)
10282            {
10283                return Err(failure(
10284                    NodeFailureCode::WorkspaceBusy,
10285                    "worktree workspace retains a bound session; remove every session first",
10286                ));
10287            }
10288        }
10289        remove_git_worktree(&source_root, &target.path)
10290            .await
10291            .map_err(git_worktree_failure)?;
10292        if let Some(workspace_id) = registered_workspace_id.as_ref() {
10293            self.unregister_workspace(workspace_id)
10294                .map_err(|error| failure(error.code, &error.message))?;
10295        }
10296        Ok(registered_workspace_id)
10297    }
10298
10299    async fn cleanup_managed_worktree(
10300        &self,
10301        lease_id: &ManagedWorktreeLeaseId,
10302        explicit: bool,
10303    ) -> Result<ManagedWorktreeLeaseSnapshot, NodeFailure> {
10304        let lease = self.managed_worktrees.lock()
10305            .unwrap_or_else(|poisoned| poisoned.into_inner())
10306            .get(lease_id)
10307            .cloned()
10308            .ok_or_else(|| failure(
10309                NodeFailureCode::UnknownManagedWorktreeLease,
10310                "managed worktree lease does not exist",
10311            ))?;
10312        if lease.state == ManagedWorktreeLeaseState::Removed {
10313            return Ok(lease.snapshot());
10314        }
10315        if lease.has_holders() {
10316            return Err(failure(
10317                NodeFailureCode::ManagedWorktreeBusy,
10318                "managed worktree lease still has a session or durable-record holder",
10319            ));
10320        }
10321        if self
10322            .materializations
10323            .lock()
10324            .unwrap_or_else(|poisoned| poisoned.into_inner())
10325            .values()
10326            .any(|record| record.managed_lease_id() == Some(lease_id))
10327        {
10328            return Err(failure(
10329                NodeFailureCode::ManagedWorktreeRecoveryRequired,
10330                "managed worktree cleanup is blocked by session environment ownership",
10331            ));
10332        }
10333        if !explicit && lease.retention == ManagedWorktreeRetention::Retain {
10334            let snapshot = {
10335                let mut registry = self.managed_worktrees.lock()
10336                    .unwrap_or_else(|poisoned| poisoned.into_inner());
10337                let current = registry.get_mut(lease_id)
10338                    .expect("managed worktree lease remains present");
10339                current.state = ManagedWorktreeLeaseState::Retained;
10340                current.cleanup_failure = None;
10341                current.updated_at_unix_ms = unix_time_ms();
10342                current.snapshot()
10343            };
10344            if self.persist_state().is_err() {
10345                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
10346            }
10347            self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
10348            return Ok(snapshot);
10349        }
10350        let profile = self.managed_profile(&lease.source_workspace_id, &lease.profile_id)?;
10351        if profile.revision() != &lease.profile_revision {
10352            self.mark_managed_recovery_required(
10353                lease_id,
10354                ManagedWorktreeCleanupFailure::OwnershipConflict,
10355            );
10356            return Err(failure(
10357                NodeFailureCode::ManagedWorktreeRecoveryRequired,
10358                "managed worktree profile revision changed",
10359            ));
10360        }
10361        profile.validate_target_authority(&lease.lease_id, &lease.target_root).map_err(|message| {
10362            self.mark_managed_recovery_required(
10363                lease_id,
10364                ManagedWorktreeCleanupFailure::OwnershipConflict,
10365            );
10366            failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message)
10367        })?;
10368        let source_root = self.workspace_root(&lease.source_workspace_id)?;
10369        let listed = list_git_worktrees(&source_root).await.map_err(|error| {
10370            self.mark_managed_cleanup_blocked(lease_id, ManagedWorktreeCleanupFailure::Backend);
10371            managed_git_worktree_failure(error)
10372        })?;
10373        let target = listed.iter()
10374            .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
10375        if let Some(target) = target {
10376            if target.is_main || target.is_bare || !exact_owned_worktree(
10377                &lease,
10378                &target.path,
10379                target.branch.as_deref(),
10380                &target.head,
10381            ) {
10382                self.mark_managed_recovery_required(
10383                    lease_id,
10384                    ManagedWorktreeCleanupFailure::OwnershipConflict,
10385                );
10386                return Err(failure(
10387                    NodeFailureCode::ManagedWorktreeOwnershipConflict,
10388                    "Git worktree identity no longer matches the managed lease",
10389                ));
10390            }
10391            if target.prunable {
10392                self.mark_managed_cleanup_blocked(
10393                    lease_id,
10394                    ManagedWorktreeCleanupFailure::Prunable,
10395                );
10396                return Err(failure(
10397                    NodeFailureCode::ManagedWorktreeRecoveryRequired,
10398                    "managed worktree is prunable and requires recovery",
10399                ));
10400            }
10401            if let Err(error) = remove_git_worktree(&source_root, &target.path).await {
10402                let failure_kind = match error.kind {
10403                    GitWorktreeErrorKind::Dirty => ManagedWorktreeCleanupFailure::Dirty,
10404                    GitWorktreeErrorKind::Locked => ManagedWorktreeCleanupFailure::Locked,
10405                    GitWorktreeErrorKind::Protected => ManagedWorktreeCleanupFailure::OwnershipConflict,
10406                    GitWorktreeErrorKind::Conflict => ManagedWorktreeCleanupFailure::Busy,
10407                    GitWorktreeErrorKind::Invalid | GitWorktreeErrorKind::NotRepository
10408                    | GitWorktreeErrorKind::Failed => ManagedWorktreeCleanupFailure::Backend,
10409                };
10410                if failure_kind == ManagedWorktreeCleanupFailure::OwnershipConflict {
10411                    self.mark_managed_recovery_required(lease_id, failure_kind);
10412                } else {
10413                    self.mark_managed_cleanup_blocked(lease_id, failure_kind);
10414                }
10415                return Err(managed_git_worktree_failure(error));
10416            }
10417        } else {
10418            match std::fs::symlink_metadata(&lease.target_root) {
10419                Err(error) if error.kind() == io::ErrorKind::NotFound => {}
10420                Ok(_) => {
10421                    self.mark_managed_recovery_required(
10422                        lease_id,
10423                        ManagedWorktreeCleanupFailure::OwnershipConflict,
10424                    );
10425                    return Err(failure(
10426                        NodeFailureCode::ManagedWorktreeOwnershipConflict,
10427                        "managed target exists but is not owned by Git",
10428                    ));
10429                }
10430                Err(_) => {
10431                    self.mark_managed_cleanup_blocked(
10432                        lease_id,
10433                        ManagedWorktreeCleanupFailure::Backend,
10434                    );
10435                    return Err(failure(
10436                        NodeFailureCode::BackendOperationFailed,
10437                        "managed target identity could not be inspected",
10438                    ));
10439                }
10440            }
10441        }
10442        let registered = self.workspaces.read()
10443            .unwrap_or_else(|poisoned| poisoned.into_inner())
10444            .contains_key(&lease.workspace_id);
10445        if registered {
10446            self.unregister_workspace(&lease.workspace_id)?;
10447        }
10448        let removed = {
10449            self.managed_worktrees.lock()
10450                .unwrap_or_else(|poisoned| poisoned.into_inner())
10451                .tombstone(lease_id, unix_time_ms())
10452                .expect("managed worktree lease remains present during cleanup")
10453        };
10454        self.persist_state().map_err(persistence_failure)?;
10455        self.publish(NodeEvent::ManagedWorktreeRemoved { lease_id: lease_id.clone() });
10456        Ok(removed.snapshot())
10457    }
10458
10459    fn mark_managed_cleanup_blocked(
10460        &self,
10461        lease_id: &ManagedWorktreeLeaseId,
10462        failure_kind: ManagedWorktreeCleanupFailure,
10463    ) {
10464        let snapshot = {
10465            let mut registry = self.managed_worktrees.lock()
10466                .unwrap_or_else(|poisoned| poisoned.into_inner());
10467            let Some(lease) = registry.get_mut(lease_id) else { return };
10468            lease.state = ManagedWorktreeLeaseState::CleanupBlocked;
10469            lease.cleanup_failure = Some(failure_kind);
10470            lease.updated_at_unix_ms = unix_time_ms();
10471            lease.snapshot()
10472        };
10473        let _ = self.persist_state();
10474        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
10475    }
10476
10477    fn require_worktree_service(
10478        &self,
10479        source_workspace_id: &WorkspaceId,
10480    ) -> Result<(), NodeFailure> {
10481        if self.managed_worktrees.lock()
10482            .unwrap_or_else(|poisoned| poisoned.into_inner())
10483            .lease_for_workspace(source_workspace_id)
10484            .is_some()
10485        {
10486            return Err(failure(
10487                NodeFailureCode::ManagedWorktreeOwnershipConflict,
10488                "managed worktree targets are internal-only workspaces",
10489            ));
10490        }
10491        match self
10492            .worktree_service_modes
10493            .get(source_workspace_id)
10494            .copied()
10495            .unwrap_or(WorktreeServiceMode::Manual)
10496        {
10497            WorktreeServiceMode::Manual => Ok(()),
10498            WorktreeServiceMode::Managed | WorktreeServiceMode::Off => Err(failure(
10499                NodeFailureCode::UnsupportedSpawnCapability,
10500                "Git worktree service mode is unavailable in this node build",
10501            )),
10502        }
10503    }
10504
10505    fn managed_reservation_conflict(
10506        &self,
10507        workspace_id: Option<&WorkspaceId>,
10508        root: Option<&str>,
10509    ) -> bool {
10510        self.managed_worktrees.lock()
10511            .unwrap_or_else(|poisoned| poisoned.into_inner())
10512            .active_records()
10513            .any(|lease| {
10514                workspace_id.is_some_and(|workspace_id| {
10515                    workspace_id == &lease.source_workspace_id
10516                        || workspace_id == &lease.workspace_id
10517                }) || root.is_some_and(|root| worktree_paths_equal(root, &lease.target_root))
10518            })
10519    }
10520
10521    fn reject_managed_reservation(
10522        &self,
10523        workspace_id: Option<&WorkspaceId>,
10524        root: Option<&str>,
10525    ) -> Result<(), NodeFailure> {
10526        if self.managed_reservation_conflict(workspace_id, root) {
10527            Err(failure(
10528                NodeFailureCode::ManagedWorktreeOwnershipConflict,
10529                "managed worktree ownership may only be mutated through its lease cleanup",
10530            ))
10531        } else {
10532            Ok(())
10533        }
10534    }
10535
10536    fn bound_workspace_root(&self, address: &SessionAddress) -> Result<String, NodeFailure> {
10537        self.validate_address(address)?;
10538        self.workspace_root(&address.workspace_id)
10539    }
10540
10541    /// `Resume`/`ResumeWithPrompt` are always a raw-PTY relaunch (see
10542    /// `require_policy`'s own doc on those two requirements), so they keep
10543    /// gating on `binding.runtime_policy`'s PTY-terminal-text-inference
10544    /// flags exactly as before; `pipe_transport`/`acp_transport` stay
10545    /// `false` on the `require_policy` call below for them because a PTY
10546    /// resume never needs those facts, not because this method cannot look
10547    /// them up.
10548    ///
10549    /// `SemanticPrompt` -- the requirement `Prompt`/`Paste` share -- reads
10550    /// the SAME `binding.runtime_policy` every other requirement reads here
10551    /// (one computed policy, read by every gate). That policy is no longer
10552    /// PTY-terminal-verification-shaped for an ACP session --
10553    /// `spawn_session_with_deadline` computes it BY TRANSPORT now, through
10554    /// `provider_runtime::policy_for_transport` -- so consulting it for
10555    /// `Prompt`/`Paste` on ACP is sound where it used to gate the wrong fact
10556    /// (this is what made an ACP `Prompt` come back `UnsupportedCapability`
10557    /// even though a prompt is an ACP session's only interface). Pipe keeps
10558    /// reading the same PTY-probe-derived value it always has --
10559    /// `policy_for_transport` passes Pipe through unchanged this pass (see
10560    /// its own doc for why).
10561    ///
10562    /// What this method still adds on top, for ACP/Pipe only, is a
10563    /// turn-in-flight PRECONDITION ahead of that policy check: a session
10564    /// takes one turn at a time, so a `Prompt`/`Paste` is refused by name
10565    /// (`NodeFailureCode::TurnInFlight`) while a turn is already running,
10566    /// read off the same live `SessionSnapshot` state `require_acp_session`
10567    /// already reads for the ACP control verbs -- `TurnStarted` observed
10568    /// with no matching `TurnCompleted` yet is exactly `ProviderSnapshot::
10569    /// lead_activity != Idle`. Sending a second prompt mid-turn would put
10570    /// its fate in vendor-specific behaviour this node cannot observe (one
10571    /// agent may queue it, another drop it, another interleave it), so this
10572    /// refuses it outright rather than gambling on that -- a rule with no
10573    /// PTY analogue, which is why PTY does not take this extra precondition.
10574    fn require_session_runtime_policy(
10575        &self,
10576        address: &SessionAddress,
10577        requirement: ProviderRuntimeRequirement,
10578    ) -> Result<ProviderRuntimePolicy, NodeFailure> {
10579        let runtime_policy = {
10580            let bindings = self
10581                .session_bindings
10582                .lock()
10583                .unwrap_or_else(|poisoned| poisoned.into_inner());
10584            let binding = bindings
10585                .get(&address.session.instance_id)
10586                .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10587            if binding.workspace_id != address.workspace_id {
10588                return Err(failure(
10589                    NodeFailureCode::SessionWorkspaceMismatch,
10590                    "session belongs to another workspace",
10591                ));
10592            }
10593            if binding.generation != address.session.generation {
10594                return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10595            }
10596            binding.runtime_policy
10597        };
10598        if requirement == ProviderRuntimeRequirement::SemanticPrompt {
10599            let snapshot = self.internal_session_snapshot(address)?;
10600            if matches!(snapshot.transport, TransportKind::Acp | TransportKind::Pipe)
10601                && snapshot.provider.lead_activity != ProviderActivity::Idle
10602            {
10603                return Err(failure(
10604                    NodeFailureCode::TurnInFlight,
10605                    "session already has a turn in flight; wait for it to complete before sending another prompt",
10606                ));
10607            }
10608        }
10609        require_policy(runtime_policy, false, false, requirement)
10610            .map(|()| runtime_policy)
10611            .map_err(|_| failure(
10612                NodeFailureCode::UnsupportedCapability,
10613                "session runtime policy does not admit this semantic operation",
10614            ))
10615    }
10616
10617    fn remove_binding(&self, address: &SessionAddress) -> Option<SessionBinding> {
10618        let mut bindings = self
10619            .session_bindings
10620            .lock()
10621            .unwrap_or_else(|poisoned| poisoned.into_inner());
10622        if bindings.get(&address.session.instance_id).is_some_and(|binding| {
10623            binding.workspace_id == address.workspace_id
10624                && binding.generation == address.session.generation
10625        }) {
10626            let removed = bindings.remove(&address.session.instance_id);
10627            self.clear_terminal_frame_watermark(address);
10628            SessionLifecycleHook::on_binding_removed(self, address, removed.as_ref());
10629            return removed;
10630        }
10631        None
10632    }
10633
10634    fn cleanup_session_owned_materialization(
10635        &self,
10636        address: &SessionAddress,
10637        binding: &SessionBinding,
10638    ) -> Result<(), NodeFailure> {
10639        let Some(id) = binding.materialization_id.as_ref() else {
10640            return Ok(());
10641        };
10642        let owner = self
10643            .materializations
10644            .lock()
10645            .unwrap_or_else(|poisoned| poisoned.into_inner())
10646            .get(id)
10647            .map(|record| record.owner().clone());
10648        match owner {
10649            None | Some(MaterializationOwner::Record { .. }) => Ok(()),
10650            Some(MaterializationOwner::Session {
10651                incarnation_id,
10652                instance_id,
10653                generation,
10654            }) if incarnation_id == self.incarnation_id
10655                && instance_id == address.session.instance_id
10656                && generation == address.session.generation =>
10657            {
10658                self.cleanup_materialization(id)
10659            }
10660            Some(MaterializationOwner::Session { .. }) => Err(failure(
10661                NodeFailureCode::BackendOperationFailed,
10662                "session environment ownership requires recovery",
10663            )),
10664        }
10665    }
10666
10667    fn clear_terminal_frame_watermark(&self, address: &SessionAddress) {
10668        let mut watermarks = self
10669            .terminal_frame_watermarks
10670            .lock()
10671            .unwrap_or_else(|poisoned| poisoned.into_inner());
10672        if watermarks
10673            .get(&address.session.instance_id)
10674            .is_some_and(|(current, _)| current == address)
10675        {
10676            watermarks.remove(&address.session.instance_id);
10677        }
10678    }
10679
10680    #[cfg(test)]
10681    fn address_for(
10682        &self,
10683        instance_id: AgentInstanceId,
10684        generation: SessionGeneration,
10685    ) -> Option<SessionAddress> {
10686        let binding = self.session_bindings
10687            .lock()
10688            .unwrap_or_else(|poisoned| poisoned.into_inner())
10689            .get(&instance_id)
10690            .cloned()?;
10691        (binding.generation == generation).then_some(SessionAddress {
10692            workspace_id: binding.workspace_id,
10693            session: SessionKey { instance_id, generation },
10694        })
10695    }
10696
10697    fn arm_resume(
10698        &self,
10699        address: &SessionAddress,
10700        command_id: CommandId,
10701        runtime_policy: ProviderRuntimePolicy,
10702    ) -> Result<(), NodeFailure> {
10703        let mut bindings = self
10704            .session_bindings
10705            .lock()
10706            .unwrap_or_else(|poisoned| poisoned.into_inner());
10707        let binding = bindings
10708            .get_mut(&address.session.instance_id)
10709            .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10710        if binding.workspace_id != address.workspace_id {
10711            return Err(failure(
10712                NodeFailureCode::SessionWorkspaceMismatch,
10713                "session belongs to another workspace",
10714            ));
10715        }
10716        if binding.generation != address.session.generation {
10717            return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10718        }
10719        binding.pending_resume = Some((binding.generation, command_id, runtime_policy));
10720        Ok(())
10721    }
10722
10723    fn clear_armed_resume(&self, address: &SessionAddress) {
10724        let mut bindings = self
10725            .session_bindings
10726            .lock()
10727            .unwrap_or_else(|poisoned| poisoned.into_inner());
10728        if let Some(binding) = bindings.get_mut(&address.session.instance_id) {
10729            if binding.workspace_id == address.workspace_id
10730                && binding.generation == address.session.generation
10731            {
10732                binding.pending_resume = None;
10733            }
10734        }
10735    }
10736
10737    fn publish_control(&self, event: ControlEvent) {
10738        let revokes_harness_mcp = matches!(
10739            &event.event,
10740            ControlEventKind::Exited { .. }
10741                | ControlEventKind::Failed { .. }
10742                | ControlEventKind::Removed
10743        );
10744        let (address, previous_address, record_id, identity_admitted, resume_command_rejected) = {
10745            let mut bindings = self
10746                .session_bindings
10747                .lock()
10748                .unwrap_or_else(|poisoned| poisoned.into_inner());
10749            let Some(binding) = bindings.get_mut(&event.instance_id) else {
10750                return;
10751            };
10752            let previous_address = SessionAddress {
10753                workspace_id: binding.workspace_id.clone(),
10754                session: SessionKey {
10755                    instance_id: event.instance_id,
10756                    generation: binding.generation,
10757                },
10758            };
10759            let mut resume_command_rejected = false;
10760            if binding.generation == event.generation {
10761                if let Some((pending_generation, pending_command_id, _)) = binding.pending_resume {
10762                    let rejected_resume = matches!(
10763                        &event.event,
10764                        ControlEventKind::CommandRejected { .. }
10765                    ) && event.command_id == Some(pending_command_id);
10766                    let resume_failed = matches!(
10767                        &event.event,
10768                        ControlEventKind::ResumeDenied { .. }
10769                            | ControlEventKind::ResumeFailed { .. }
10770                    );
10771                    if pending_generation == binding.generation
10772                        && (rejected_resume || resume_failed)
10773                    {
10774                        resume_command_rejected = rejected_resume;
10775                        binding.pending_resume = None;
10776                    }
10777                }
10778            } else {
10779                let expected = binding.generation.0.checked_add(1);
10780                let authorized = matches!(&event.event, ControlEventKind::ResumeAuthorized { .. });
10781                if !binding.pending_resume.is_some_and(|(generation, _, _)| {
10782                    generation == binding.generation
10783                })
10784                    || expected != Some(event.generation.0)
10785                    || !authorized
10786                {
10787                    return;
10788                }
10789                let (_, _, runtime_policy) = binding
10790                    .pending_resume
10791                    .expect("authorized resume retains its pending runtime policy");
10792                binding.generation = event.generation;
10793                binding.runtime_policy = runtime_policy;
10794                binding.pending_resume = None;
10795            }
10796            (
10797                SessionAddress {
10798                    workspace_id: binding.workspace_id.clone(),
10799                    session: SessionKey {
10800                        instance_id: event.instance_id,
10801                        generation: event.generation,
10802                    },
10803                },
10804                (previous_address.session.generation != event.generation)
10805                    .then_some(previous_address),
10806                binding.record_id.clone(),
10807                binding.runtime_policy.provider_session_identity,
10808                resume_command_rejected,
10809            )
10810        };
10811        if let Some(previous_address) = previous_address.as_ref() {
10812            self.clear_terminal_frame_watermark(previous_address);
10813        }
10814        if let Some(record_id) = record_id {
10815            self.reconcile_managed_record(
10816                &record_id,
10817                &address,
10818                &event,
10819                identity_admitted,
10820                resume_command_rejected,
10821            );
10822        }
10823        // Every state change a session's process lifecycle can reach gets a
10824        // line here: this is the single chokepoint every `ControlEventKind`
10825        // passes through regardless of transport (PTY/pipe/ACP) or whether it
10826        // came from a fresh spawn or a resume. Per-message provider chatter
10827        // (`ProviderEvent`/`ProviderGap`/terminal frames) is deliberately not
10828        // matched here -- that is exactly the per-frame noise this crate
10829        // family's level discipline forbids at INFO.
10830        match &event.event {
10831            ControlEventKind::Running { process_id } => {
10832                if let Some(pid) = process_id {
10833                    crate::git_watch::running(address.workspace_id.as_str(), address.session.instance_id.0, "", *pid);
10834                }
10835                #[cfg(unix)]
10836                if let (Some(proxy), Some(pid)) = (self.signer_proxy.get(), process_id) {
10837                    proxy.attach_pid(&format!("{}-{}", self.incarnation_id, address.session.instance_id.0), *pid);
10838                }
10839                tracing::info!(
10840                    node_id = %self.node_id,
10841                    workspace_id = %address.workspace_id,
10842                    session = ?address.session,
10843                    pid = ?process_id,
10844                    "session process is running",
10845                );
10846            }
10847            ControlEventKind::Exited { exit_code, forced } => {
10848                crate::git_watch::exited(address.workspace_id.as_str(), address.session.instance_id.0);
10849                #[cfg(unix)]
10850                if let Some(proxy) = self.signer_proxy.get().cloned() {
10851                    let key = format!("{}-{}", self.incarnation_id, address.session.instance_id.0);
10852                    if proxy.has_session(&key) {
10853                        // Revoke at core off the event path (blocking uplink call).
10854                        std::thread::spawn(move || proxy.release(&key));
10855                    }
10856                }
10857                tracing::info!(
10858                    node_id = %self.node_id,
10859                    workspace_id = %address.workspace_id,
10860                    session = ?address.session,
10861                    exit_code = ?exit_code,
10862                    forced = forced,
10863                    "session process exited",
10864                );
10865            }
10866            ControlEventKind::Failed { message } => {
10867                tracing::warn!(
10868                    node_id = %self.node_id,
10869                    workspace_id = %address.workspace_id,
10870                    session = ?address.session,
10871                    cause = %message,
10872                    "session failed",
10873                );
10874            }
10875            ControlEventKind::CommandRejected { message } => {
10876                tracing::warn!(
10877                    node_id = %self.node_id,
10878                    workspace_id = %address.workspace_id,
10879                    session = ?address.session,
10880                    cause = %message,
10881                    "session command rejected",
10882                );
10883            }
10884            ControlEventKind::Removed => {
10885                tracing::info!(
10886                    node_id = %self.node_id,
10887                    workspace_id = %address.workspace_id,
10888                    session = ?address.session,
10889                    "session removed",
10890                );
10891            }
10892            ControlEventKind::ResumeAuthorized { session: provider_session } => {
10893                tracing::info!(
10894                    node_id = %self.node_id,
10895                    workspace_id = %address.workspace_id,
10896                    session = ?address.session,
10897                    provider_session = ?provider_session,
10898                    "session resume authorized",
10899                );
10900            }
10901            ControlEventKind::Resumed { session: provider_session, process_id } => {
10902                tracing::info!(
10903                    node_id = %self.node_id,
10904                    workspace_id = %address.workspace_id,
10905                    session = ?address.session,
10906                    provider_session = ?provider_session,
10907                    pid = ?process_id,
10908                    "session resumed",
10909                );
10910            }
10911            ControlEventKind::ResumeDenied { reason } => {
10912                tracing::warn!(
10913                    node_id = %self.node_id,
10914                    workspace_id = %address.workspace_id,
10915                    session = ?address.session,
10916                    cause = %reason,
10917                    "session resume denied",
10918                );
10919            }
10920            ControlEventKind::ResumeFailed { message } => {
10921                tracing::warn!(
10922                    node_id = %self.node_id,
10923                    workspace_id = %address.workspace_id,
10924                    session = ?address.session,
10925                    cause = %message,
10926                    "session resume failed",
10927                );
10928            }
10929            _ => {}
10930        }
10931        self.apply_pending_acp_options(&address, &event);
10932        self.apply_acp_auto_permit(&address, &event);
10933        trace_acp_turn(&address, &event);
10934        self.observe_acp_provider_error(&address, &event);
10935        let stream_chunk = agent_stream_chunk(&event);
10936        self.publish(NodeEvent::Control {
10937            address: address.clone(),
10938            event,
10939        });
10940        if let Some(chunk) = stream_chunk {
10941            self.publish_agent_stream_chunk(address.clone(), chunk);
10942        }
10943        if revokes_harness_mcp {
10944            SessionLifecycleHook::on_session_ended(self, &address);
10945        }
10946    }
10947
10948    fn reconcile_managed_record(
10949        &self,
10950        record_id: &SessionRecordId,
10951        address: &SessionAddress,
10952        event: &ControlEvent,
10953        identity_admitted: bool,
10954        resume_command_rejected: bool,
10955    ) {
10956        let _transaction = self
10957            .state_transaction
10958            .lock()
10959            .unwrap_or_else(|poisoned| poisoned.into_inner());
10960        let observed_identity = match &event.event {
10961            ControlEventKind::ProviderEvent {
10962                event: gate4agent_types::ProviderEvent::SessionIdentityObserved { identity },
10963                ..
10964            } if identity_admitted => Some(identity.clone()),
10965            _ => None,
10966        };
10967        let identity_was_observed = observed_identity.is_some();
10968        let replacement_id = observed_identity
10969            .as_ref()
10970            .and_then(|_| self.allocate_record_id().ok());
10971        let mut upserts = Vec::new();
10972        let mut removals = Vec::new();
10973        let mut rebind = None;
10974        let original_records;
10975        let original_materializations = self
10976            .materializations
10977            .lock()
10978            .unwrap_or_else(|poisoned| poisoned.into_inner())
10979            .clone();
10980        {
10981            let mut records = self
10982                .session_records
10983                .lock()
10984                .unwrap_or_else(|poisoned| poisoned.into_inner());
10985            original_records = records.records.clone();
10986            let Some(current_snapshot) = records.records.get(record_id).cloned() else {
10987                return;
10988            };
10989            if let Some(identity) = observed_identity {
10990                let existing_id = records
10991                    .records
10992                    .values()
10993                    .find(|record| {
10994                        record.record_id != *record_id
10995                            && record.provider_session.as_ref().is_some_and(|record_identity| {
10996                                session_registry::same_provider_session(
10997                                    &record.provider,
10998                                    record_identity,
10999                                    &current_snapshot.provider,
11000                                    &identity,
11001                                )
11002                            })
11003                    })
11004                    .map(|record| record.record_id.clone());
11005                if let Some(existing_id) = existing_id {
11006                    let scope_matches = records.records.get(&existing_id).is_some_and(|record| {
11007                        record.mode == current_snapshot.mode
11008                            && record.workspace_id == current_snapshot.workspace_id
11009                            && record.environment_profile == current_snapshot.environment_profile
11010                            && record.bundle == current_snapshot.bundle
11011                            && record.context_id == current_snapshot.context_id
11012                            && record.context == current_snapshot.context
11013                            && record
11014                                .canonical_root
11015                                .as_utf8()
11016                                .zip(current_snapshot.canonical_root.as_utf8())
11017                                .is_some_and(|(left, right)| platform::roots_equal(left, right))
11018                    });
11019                    if !scope_matches {
11020                        if let Some(current) = records.records.get_mut(record_id) {
11021                            current.state = ManagedSessionState::Unavailable;
11022                            current.last_error =
11023                                Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR.to_owned());
11024                            current.updated_at_unix_ms = unix_time_ms();
11025                            upserts.push(current.clone());
11026                        }
11027                    } else {
11028                        let existing_is_active = records
11029                            .records
11030                            .get(&existing_id)
11031                            .is_some_and(|record| record.active_session.is_some());
11032                        if existing_is_active {
11033                            if let Some(current) = records.records.get_mut(record_id) {
11034                                current.state = ManagedSessionState::Unavailable;
11035                                current.last_error =
11036                                    Some(PROVIDER_SESSION_LIVE_CONFLICT_ERROR.to_owned());
11037                                current.updated_at_unix_ms = unix_time_ms();
11038                                upserts.push(current.clone());
11039                            }
11040                        } else {
11041                            if current_snapshot.provider_session.is_some() {
11042                                if let Some(current) = records.records.get_mut(record_id) {
11043                                    current.active_session = None;
11044                                    current.state = ManagedSessionState::Dormant;
11045                                    current.updated_at_unix_ms = unix_time_ms();
11046                                    upserts.push(current.clone());
11047                                }
11048                            } else {
11049                                records.records.remove(record_id);
11050                                removals.push(record_id.clone());
11051                            }
11052                            if let Some(existing) = records.records.get_mut(&existing_id) {
11053                                existing.provider_session = Some(identity.clone());
11054                                existing.active_session = Some(address.clone());
11055                                existing.state = ManagedSessionState::Live;
11056                                existing.updated_at_unix_ms = unix_time_ms();
11057                                existing.last_error = None;
11058                                upserts.push(existing.clone());
11059                            }
11060                            rebind = Some(existing_id);
11061                        }
11062                    }
11063                } else if current_snapshot
11064                    .provider_session
11065                    .as_ref()
11066                    .is_some_and(|current| {
11067                        !session_registry::same_provider_session(
11068                            &current_snapshot.provider,
11069                            current,
11070                            &current_snapshot.provider,
11071                            &identity,
11072                        )
11073                    })
11074                {
11075                    if let Some(current) = records.records.get_mut(record_id) {
11076                        current.active_session = None;
11077                        if current.bundle.is_some() || current.context.is_some() {
11078                            let had_bundle = current.bundle.is_some();
11079                            current.provider_session = None;
11080                            current.bundle = None;
11081                            current.context_id = None;
11082                            current.context = None;
11083                            current.state = ManagedSessionState::Unavailable;
11084                            current.last_error = Some(if had_bundle {
11085                                "bundle-unavailable".to_owned()
11086                            } else {
11087                                "context-unavailable".to_owned()
11088                            });
11089                        } else {
11090                            current.state = ManagedSessionState::Dormant;
11091                        }
11092                        current.updated_at_unix_ms = unix_time_ms();
11093                        upserts.push(current.clone());
11094                    }
11095                    if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
11096                        if let Some(current) = records.records.get_mut(record_id) {
11097                            current.state = ManagedSessionState::Unavailable;
11098                            current.last_error =
11099                                Some(MANAGED_SESSION_CAPACITY_ERROR.to_owned());
11100                            current.updated_at_unix_ms = unix_time_ms();
11101                            upserts.push(current.clone());
11102                        }
11103                    } else if let Some(new_record_id) = replacement_id {
11104                        let now = unix_time_ms();
11105                        let replacement = ManagedSessionRecord {
11106                            record_id: new_record_id.clone(),
11107                            display_name: format!(
11108                                "{} #{}",
11109                                current_snapshot.provider.as_str(),
11110                                records
11111                                    .records
11112                                    .values()
11113                                    .filter(|record| {
11114                                        record.provider == current_snapshot.provider
11115                                    })
11116                                    .count()
11117                                    .saturating_add(1),
11118                            ),
11119                            provider: current_snapshot.provider.clone(),
11120                            mode: current_snapshot.mode,
11121                            state: ManagedSessionState::Live,
11122                            workspace_id: current_snapshot.workspace_id,
11123                            canonical_root: current_snapshot.canonical_root,
11124                            provider_session: Some(identity),
11125                            active_session: Some(address.clone()),
11126                            environment_profile: current_snapshot.environment_profile,
11127                            bundle: current_snapshot.bundle,
11128                            context_id: current_snapshot.context_id,
11129                            context: current_snapshot.context,
11130                            // This is a fresh record_id representing the session under its
11131                            // newly-observed identity; it has not yet produced any clean
11132                            // exit of its own, so it starts with no exported pack (the old
11133                            // record, still addressable under its own record_id, keeps
11134                            // whatever exported_context it already had).
11135                            exported_context: None,
11136                            task_binding: current_snapshot.task_binding,
11137                            created_at_unix_ms: now,
11138                            updated_at_unix_ms: now,
11139                            last_error: None,
11140                        };
11141                        records
11142                            .records
11143                            .insert(new_record_id.clone(), replacement.clone());
11144                        upserts.push(replacement);
11145                        rebind = Some(new_record_id);
11146                    } else if let Some(current) = records.records.get_mut(record_id) {
11147                        current.state = ManagedSessionState::Unavailable;
11148                        current.last_error =
11149                            Some(PROVIDER_IDENTITY_ALLOCATION_ERROR.to_owned());
11150                        upserts.push(current.clone());
11151                    }
11152                } else if let Some(current) = records.records.get_mut(record_id) {
11153                    current.provider_session = Some(identity);
11154                    current.active_session = Some(address.clone());
11155                    current.state = ManagedSessionState::Live;
11156                    current.updated_at_unix_ms = unix_time_ms();
11157                    current.last_error = None;
11158                    upserts.push(current.clone());
11159                }
11160            } else if let Some(current) = records.records.get_mut(record_id) {
11161                if resume_command_rejected {
11162                    current.active_session = None;
11163                    current.state = detached_record_state(current);
11164                    current.updated_at_unix_ms = unix_time_ms();
11165                    current.last_error =
11166                        Some(PROVIDER_RESUME_REJECTED_ERROR.to_owned());
11167                    upserts.push(current.clone());
11168                } else {
11169                    match &event.event {
11170                    ControlEventKind::Running { .. }
11171                    | ControlEventKind::ResumeAuthorized { .. }
11172                    | ControlEventKind::Resumed { .. } => {
11173                        current.active_session = Some(address.clone());
11174                        current.state = active_record_state(
11175                            identity_admitted,
11176                            current.provider_session.is_some(),
11177                        );
11178                        current.updated_at_unix_ms = unix_time_ms();
11179                        current.last_error = None;
11180                        upserts.push(current.clone());
11181                    }
11182                    ControlEventKind::Exited { .. } | ControlEventKind::Removed => {
11183                        current.active_session = None;
11184                        current.state = detached_record_state(current);
11185                        current.updated_at_unix_ms = unix_time_ms();
11186                        upserts.push(current.clone());
11187                    }
11188                    ControlEventKind::Failed { message }
11189                    | ControlEventKind::ResumeFailed { message } => {
11190                        current.active_session = None;
11191                        current.state = detached_record_state(current);
11192                        current.updated_at_unix_ms = unix_time_ms();
11193                        current.last_error = Some(
11194                            session_registry::sanitized_record_error_summary(message),
11195                        );
11196                        upserts.push(current.clone());
11197                    }
11198                    ControlEventKind::ResumeDenied { reason } => {
11199                        current.active_session = None;
11200                        current.state = detached_record_state(current);
11201                        current.updated_at_unix_ms = unix_time_ms();
11202                        current.last_error = Some(
11203                            session_registry::sanitized_record_error_summary(reason),
11204                        );
11205                        upserts.push(current.clone());
11206                    }
11207                        _ => {}
11208                    }
11209                }
11210            }
11211        }
11212        let materialization_owner_record = identity_was_observed.then(|| {
11213            rebind.clone().unwrap_or_else(|| record_id.clone())
11214        }).and_then(|candidate| {
11215            self.session_records
11216                .lock()
11217                .unwrap_or_else(|poisoned| poisoned.into_inner())
11218                .records
11219                .get(&candidate)
11220                .filter(|record| {
11221                    record.provider_session.is_some()
11222                        && record.active_session.as_ref() == Some(address)
11223                })
11224                .map(|_| candidate)
11225        });
11226        if let Some(owner_record_id) = materialization_owner_record {
11227            let materialization_id = self
11228                .session_bindings
11229                .lock()
11230                .unwrap_or_else(|poisoned| poisoned.into_inner())
11231                .get(&address.session.instance_id)
11232                .and_then(|binding| binding.materialization_id.clone());
11233            if let Some(materialization_id) = materialization_id {
11234                let mut transfer_failed = false;
11235                {
11236                    let mut materializations = self
11237                        .materializations
11238                        .lock()
11239                        .unwrap_or_else(|poisoned| poisoned.into_inner());
11240                    if let Some(ownership) = materializations.get_mut(&materialization_id) {
11241                        match ownership.owner() {
11242                            MaterializationOwner::Session {
11243                                incarnation_id,
11244                                instance_id,
11245                                generation,
11246                            } if *incarnation_id == self.incarnation_id
11247                                && *instance_id == address.session.instance_id
11248                                && *generation == address.session.generation =>
11249                            {
11250                                transfer_failed = ownership
11251                                    .transfer_to_record(owner_record_id.clone(), unix_time_ms())
11252                                    .is_err();
11253                            }
11254                            MaterializationOwner::Record { record_id }
11255                                if record_id == &owner_record_id => {}
11256                            MaterializationOwner::Record { record_id } => {
11257                                let expected = record_id.clone();
11258                                transfer_failed = ownership
11259                                    .transfer_record_owner(
11260                                        &expected,
11261                                        owner_record_id.clone(),
11262                                        unix_time_ms(),
11263                                    )
11264                                    .is_err();
11265                            }
11266                            _ => transfer_failed = true,
11267                        }
11268                        if transfer_failed {
11269                            let _ = ownership.mark_recovery_required(unix_time_ms());
11270                        }
11271                    } else {
11272                        transfer_failed = true;
11273                    }
11274                }
11275                if transfer_failed {
11276                    if let Some(record) = self
11277                        .session_records
11278                        .lock()
11279                        .unwrap_or_else(|poisoned| poisoned.into_inner())
11280                        .records
11281                        .get_mut(&owner_record_id)
11282                    {
11283                        record.state = ManagedSessionState::Unavailable;
11284                        record.last_error = Some("environment-profile-unavailable".to_owned());
11285                        record.updated_at_unix_ms = unix_time_ms();
11286                        upserts.push(record.clone());
11287                    }
11288                }
11289            }
11290        }
11291        if let Some(new_record_id) = rebind.clone() {
11292            if let Some(binding) = self
11293                .session_bindings
11294                .lock()
11295                .unwrap_or_else(|poisoned| poisoned.into_inner())
11296                .get_mut(&address.session.instance_id)
11297            {
11298                binding.record_id = Some(new_record_id);
11299            }
11300        }
11301        if upserts.is_empty() && removals.is_empty() {
11302            return;
11303        }
11304        if self.persist_state_locked().is_err() {
11305            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
11306            let mut records = self
11307                .session_records
11308                .lock()
11309                .unwrap_or_else(|poisoned| poisoned.into_inner());
11310            records.records = original_records;
11311            *self
11312                .materializations
11313                .lock()
11314                .unwrap_or_else(|poisoned| poisoned.into_inner()) = original_materializations;
11315            if let Some(current) = records.records.get_mut(record_id) {
11316                current.state = ManagedSessionState::Unavailable;
11317                current.last_error = Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned());
11318                current.updated_at_unix_ms = unix_time_ms();
11319                upserts = vec![current.clone()];
11320            } else {
11321                upserts.clear();
11322            }
11323            removals.clear();
11324            drop(records);
11325            if let Some(binding) = self
11326                .session_bindings
11327                .lock()
11328                .unwrap_or_else(|poisoned| poisoned.into_inner())
11329                .get_mut(&address.session.instance_id)
11330            {
11331                binding.record_id = Some(record_id.clone());
11332            }
11333            eprintln!("gate4agent-node: {DURABLE_STATE_COMMIT_FAILED_ERROR}");
11334        }
11335        for record in upserts {
11336            self.publish_record(record);
11337        }
11338        for record_id in removals {
11339            self.publish(NodeEvent::SessionRecordRemoved { record_id });
11340        }
11341    }
11342
11343    fn snapshot(&self) -> NodeSnapshot {
11344        let control = self.handle.snapshot();
11345        let bindings = self
11346            .session_bindings
11347            .lock()
11348            .unwrap_or_else(|poisoned| poisoned.into_inner())
11349            .clone();
11350        let workspace_roots = self
11351            .workspaces
11352            .read()
11353            .unwrap_or_else(|poisoned| poisoned.into_inner())
11354            .clone();
11355        let environment_profiles = self
11356            .environment_profiles
11357            .read()
11358            .unwrap_or_else(|poisoned| poisoned.into_inner());
11359        let mut workspaces = workspace_roots
11360            .iter()
11361            .map(|(workspace_id, canonical_root)| {
11362                (
11363                    workspace_id.clone(),
11364                    WorkspaceSnapshot {
11365                        workspace_id: workspace_id.clone(),
11366                        canonical_root: opaque_windows_path(canonical_root.clone()),
11367                        sessions: Vec::new(),
11368                        worktree_service_mode: Some(
11369                            self.worktree_service_modes
11370                                .get(workspace_id)
11371                                .copied()
11372                                .unwrap_or(WorktreeServiceMode::Manual),
11373                        ),
11374                        managed_worktree_profiles: Some(WorktreeProfileInventory {
11375                            profiles: self
11376                                .managed_worktree_profiles
11377                                .get(workspace_id)
11378                                .into_iter()
11379                                .flat_map(BTreeMap::values)
11380                                .map(|profile| ManagedWorktreeProfileSummary {
11381                                    id: profile.profile_id().clone(),
11382                                    revision: profile.revision().clone(),
11383                                    retention: profile.retention(),
11384                                })
11385                                .collect(),
11386                        }),
11387                    },
11388                )
11389            })
11390            .collect::<BTreeMap<_, _>>();
11391        let mut agent_progress = Vec::new();
11392        for session in &control.sessions {
11393            let Some(binding) = bindings.get(&session.instance_id) else {
11394                continue;
11395            };
11396            if binding.generation != session.generation {
11397                continue;
11398            }
11399            if let Some(workspace) = workspaces.get_mut(&binding.workspace_id) {
11400                let address = SessionAddress {
11401                    workspace_id: binding.workspace_id.clone(),
11402                    session: SessionKey {
11403                        instance_id: session.instance_id,
11404                        generation: session.generation,
11405                    },
11406                };
11407                if let Some(progress) =
11408                    agent_progress_from_provider_snapshot(address, &session.provider)
11409                {
11410                    agent_progress.push(progress);
11411                }
11412                workspace.sessions.push(session.clone());
11413            }
11414        }
11415        NodeSnapshot {
11416            node_id: self.node_id.clone(),
11417            enabled_providers: self.enabled_providers.clone(),
11418            provider_runtime_statuses: {
11419                let updates = self
11420                    .provider_runtime_status_updates
11421                    .read()
11422                    .unwrap_or_else(|poisoned| poisoned.into_inner());
11423                ProviderRuntimeStatuses::new(
11424                    self.provider_runtime_statuses
11425                        .iter()
11426                        .filter(|status| !updates.contains_key(status.provider()))
11427                        .cloned()
11428                        .chain(updates.values().cloned()),
11429                )
11430                .expect("refreshed provider runtime inventory remains bounded and unique")
11431            },
11432            workspaces: workspaces.into_values().collect(),
11433            session_records: self
11434                .session_records
11435                .lock()
11436                .unwrap_or_else(|poisoned| poisoned.into_inner())
11437                .records
11438                .values()
11439                .cloned()
11440                .collect(),
11441            managed_worktrees: self.managed_worktrees
11442                .lock()
11443                .unwrap_or_else(|poisoned| poisoned.into_inner())
11444                .snapshots(),
11445            launch_inventory: Some(LaunchInventory {
11446                spawn_profiles: Some(
11447                    self.spawn_profiles
11448                        .iter()
11449                        .filter_map(|profile| {
11450                            resolved_spawn_profile_summary(profile, &environment_profiles)
11451                        })
11452                        .collect(),
11453                ),
11454                bundles: Some(
11455                    self.bundle_catalog
11456                        .read()
11457                        .unwrap_or_else(|poisoned| poisoned.into_inner())
11458                        .iter()
11459                        .map(NodeBundle::receipt)
11460                        .collect(),
11461                ),
11462                // Opaque station allowlist ids from empty-default catalog /
11463                // optional startup loader. Never secrets. Dig2 probe stays stub.
11464                network_allowlists: {
11465                    let catalog = self
11466                        .network_allowlist_catalog
11467                        .read()
11468                        .unwrap_or_else(|poisoned| poisoned.into_inner());
11469                    if catalog.is_empty() {
11470                        None
11471                    } else {
11472                        Some(catalog.ids().cloned().collect())
11473                    }
11474                },
11475            }),
11476            agent_progress,
11477        }
11478    }
11479
11480    fn publish(&self, event: NodeEvent) -> NodeEventEnvelope {
11481        assert!(
11482            !matches!(
11483                &event,
11484                NodeEvent::TerminalFrame { .. } | NodeEvent::AgentStream { .. }
11485            ),
11486            "terminal frame and agent stream events must use their own dedicated live channel",
11487        );
11488        let mut history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11489        let sequence = history
11490            .last_sequence
11491            .checked_add(1)
11492            .expect("node event sequence exhausted");
11493        if history.events.len() == NODE_EVENT_HISTORY_MAX {
11494            if let Some(evicted) = history.events.pop_front() {
11495                history.replay_floor_sequence = evicted
11496                    .sequence
11497                    .checked_add(1)
11498                    .expect("node replay floor sequence exhausted");
11499                history.removed_record_providers.remove(&evicted.sequence);
11500            }
11501        }
11502        match &event {
11503            NodeEvent::HarnessMcpReadCall { .. } => {
11504                panic!("transient harness MCP calls must not enter durable event history")
11505            }
11506            NodeEvent::SessionRecordUpserted { record } => {
11507                history
11508                    .record_providers
11509                    .insert(record.record_id.clone(), record.provider.clone());
11510            }
11511            NodeEvent::SessionRecordRemoved { record_id } => {
11512                if let Some(provider) = history.record_providers.remove(record_id) {
11513                    history.removed_record_providers.insert(sequence, provider);
11514                }
11515            }
11516            NodeEvent::ManagedWorktreeUpserted { .. }
11517            | NodeEvent::ManagedWorktreeRemoved { .. }
11518            | NodeEvent::Control { .. }
11519            | NodeEvent::ControllerChanged { .. }
11520            | NodeEvent::SessionRecordHistorySummarized { .. }
11521            | NodeEvent::WorkspaceAdded { .. }
11522            | NodeEvent::WorkspaceRemoved { .. }
11523            | NodeEvent::ResyncRequired { .. }
11524            | NodeEvent::AgentStream { .. }
11525            | NodeEvent::TerminalFrame { .. } => {}
11526        }
11527        let envelope = NodeEventEnvelope { sequence, event };
11528        history.events.push_back(envelope.clone());
11529        history.last_sequence = sequence;
11530        let _ = self.event_tx.send(envelope.clone());
11531        drop(history);
11532        envelope
11533    }
11534
11535    /// Publishes a transient (never durable) `NodeEvent` -- today only
11536    /// `NodeEvent::HarnessMcpReadCall` -- onto the dedicated
11537    /// `harness_mcp_proxy_event_tx` broadcast (see that field's own doc for
11538    /// why this must never ride the shared `event_tx` bus). Returns whether
11539    /// the send reached at least one subscriber: `broadcast::Sender::send`
11540    /// fails in exactly one way, no active receiver at that instant, and
11541    /// the caller (`HarnessMcpProxyRegistry::begin_call`) uses a `false`
11542    /// return to fail the pending call immediately rather than leave it to
11543    /// expire silently at `LOCAL_READ_TIMEOUT`/
11544    /// `MAX_HARNESS_MCP_CALL_DEADLINE_MS`.
11545    fn publish_transient(&self, event: NodeEvent) -> bool {
11546        assert!(event.requires_harness_mcp_proxy_capability());
11547        assert!(event.harness_mcp_contract_is_valid_at(unix_time_ms()));
11548        let envelope = NodeEventEnvelope { sequence: 0, event };
11549        match self.harness_mcp_proxy_event_tx.send(Arc::new(vec![envelope])) {
11550            Ok(_) => true,
11551            Err(broadcast::error::SendError(undelivered)) => {
11552                if let Some(NodeEventEnvelope {
11553                    event: NodeEvent::HarnessMcpReadCall { call_id, request, .. },
11554                    ..
11555                }) = undelivered.first() {
11556                    // `request.content_type` only, never a per-tool label --
11557                    // this crate does not decode `request.body` and does not
11558                    // hold `hatchery-harness-api` (Nested Control Plane
11559                    // doctrine, Law 3; see this crate's dependency on
11560                    // `gate4agent-node-protocol`'s own `Forbidden:` line).
11561                    tracing::warn!(
11562                        call_id = call_id.as_str(),
11563                        content_type = ?request.content_type,
11564                        cause = "no-subscribers",
11565                        "harness MCP proxy event could not be published",
11566                    );
11567                }
11568                false
11569            }
11570        }
11571    }
11572
11573    fn publish_terminal_frames(&self) {
11574        let snapshot = self.handle.snapshot();
11575        let bindings = self
11576            .session_bindings
11577            .lock()
11578            .unwrap_or_else(|poisoned| poisoned.into_inner());
11579        let candidates = snapshot
11580            .sessions
11581            .iter()
11582            .filter_map(|session| {
11583                let binding = bindings.get(&session.instance_id)?;
11584                if binding.generation != session.generation {
11585                    return None;
11586                }
11587                let frame = session.terminal_frame.clone()?;
11588                crate::session_signals::feed(
11589                    binding.workspace_id.as_str(),
11590                    session.instance_id.0,
11591                    session.agent_id.as_str(),
11592                    &frame.contents,
11593                );
11594                crate::provider_limits::observe_screen(session.agent_id.as_str(), &frame.contents);
11595                let act = self.provider_errors.lock().unwrap_or_else(|p| p.into_inner()).observe_screen(session.instance_id.0, &frame.contents, Instant::now());
11596                if let Some(act) = act {
11597                    provider_error_act(binding.workspace_id.as_str(), session.instance_id.0, session.agent_id.as_str(), "pty", act);
11598                }
11599                crate::git_watch::provider_hint(binding.workspace_id.as_str(), session.instance_id.0, session.agent_id.as_str());
11600                Some((
11601                    SessionAddress {
11602                        workspace_id: binding.workspace_id.clone(),
11603                        session: SessionKey {
11604                            instance_id: session.instance_id,
11605                            generation: session.generation,
11606                        },
11607                    },
11608                    frame,
11609                ))
11610            })
11611            .collect();
11612        drop(bindings);
11613        self.publish_terminal_frame_candidates(candidates);
11614    }
11615
11616    fn publish_terminal_frame_candidates(
11617        &self,
11618        candidates: Vec<(SessionAddress, TerminalFrame)>,
11619    ) {
11620        let mut watermarks = self
11621            .terminal_frame_watermarks
11622            .lock()
11623            .unwrap_or_else(|poisoned| poisoned.into_inner());
11624        let mut events = Vec::with_capacity(candidates.len());
11625        for (address, frame) in candidates {
11626            let is_first_frame = !watermarks.contains_key(&address.session.instance_id);
11627            let advanced = watermarks
11628                .get(&address.session.instance_id)
11629                .map_or(true, |(current_address, sequence)| {
11630                    current_address != &address || frame.sequence > *sequence
11631                });
11632            if !advanced {
11633                continue;
11634            }
11635            if is_first_frame {
11636                // Do NOT sessionize on first PTY output: a session is a
11637                // provider conversation id, not bytes on the wire. Drafts
11638                // leave the provisional map only when HQ attaches
11639                // (Resize / TerminalBytes). The attach-never-arrived timer
11640                // (PROVISIONAL_SPAWN_GRACE_MS) covers deploy lag; HQ's
11641                // disconnect grace starts only on a real client WS close.
11642                // This is the first PTY output this node has observed for
11643                // this session -- a one-time event per spawn, not per-frame
11644                // chatter (the watermark makes sure of that). A session that
11645                // reaches this line with no controller attached anywhere on
11646                // the node is running unobserved: exactly how processes from
11647                // repeated spawn attempts went orphaned for hours without a
11648                // single line about it.
11649                if self.controller_state().is_none() {
11650                    tracing::warn!(
11651                        node_id = %self.node_id,
11652                        workspace_id = %address.workspace_id,
11653                        session = ?address.session,
11654                        terminal_size = ?frame.size,
11655                        "session producing output with no controller attached",
11656                    );
11657                } else {
11658                    tracing::info!(
11659                        node_id = %self.node_id,
11660                        workspace_id = %address.workspace_id,
11661                        session = ?address.session,
11662                        terminal_size = ?frame.size,
11663                        "session produced its first output",
11664                    );
11665                }
11666            }
11667            watermarks.insert(
11668                address.session.instance_id,
11669                (address.clone(), frame.sequence),
11670            );
11671            if let Some((at, reported)) = self
11672                .pty_input_marks
11673                .lock()
11674                .unwrap_or_else(|poisoned| poisoned.into_inner())
11675                .get_mut(&address.session.instance_id)
11676            {
11677                // First frame after an input: PTY write -> output published.
11678                if !*reported && at.elapsed() < Duration::from_secs(5) {
11679                    *reported = true;
11680                    tracing::debug!(target: "pty_lat", instance_id = address.session.instance_id.0, node_us = at.elapsed().as_micros() as u64, "pty input to frame published");
11681                }
11682            }
11683            events.push(NodeEvent::TerminalFrame { address, frame });
11684        }
11685        drop(watermarks);
11686        if events.is_empty() {
11687            return;
11688        }
11689        let mut history = self
11690            .history
11691            .lock()
11692            .unwrap_or_else(|poisoned| poisoned.into_inner());
11693        let mut envelopes = Vec::with_capacity(events.len());
11694        for event in events {
11695            let sequence = history
11696                .last_sequence
11697                .checked_add(1)
11698                .expect("node event sequence exhausted");
11699            history.last_sequence = sequence;
11700            envelopes.push(NodeEventEnvelope { sequence, event });
11701        }
11702        let _ = self.terminal_event_tx.send(Arc::new(envelopes));
11703        drop(history);
11704    }
11705
11706    /// Publishes one `AgentStreamChunkV1` onto the dedicated agent-stream
11707    /// channel, mirroring `publish_terminal_frame_candidates` above: the
11708    /// envelope's `sequence` is drawn from the same `history.last_sequence`
11709    /// counter every other event kind shares, never a counter of its own --
11710    /// `queue_connection_event`/`queue_connection_event_batch` compare
11711    /// `envelope.sequence` against one `discard_events_through` watermark
11712    /// regardless of which broadcast channel it arrived on, so a
11713    /// per-channel counter would desynchronize that comparison.
11714    fn publish_agent_stream_chunk(&self, address: SessionAddress, chunk: AgentStreamChunkV1) {
11715        let mut history = self
11716            .history
11717            .lock()
11718            .unwrap_or_else(|poisoned| poisoned.into_inner());
11719        let sequence = history
11720            .last_sequence
11721            .checked_add(1)
11722            .expect("node event sequence exhausted");
11723        history.last_sequence = sequence;
11724        drop(history);
11725        // Debug, not info/warn -- this fires on every single chunk, but it
11726        // is exactly the counter the next drop-measurement pass needs: what
11727        // the node actually emitted (`source_sequence`, `kind`) versus what
11728        // a harness subscriber's own forwarder later logs having received
11729        // (`hatchery-harness-service::runtime`'s "forwarding agent stream
11730        // event"), so a gap between the two logs names itself instead of
11731        // needing a new counter added ad hoc.
11732        tracing::debug!(
11733            source_sequence = chunk.source_sequence,
11734            kind = agent_stream_chunk_kind_label(&chunk.kind),
11735            envelope_sequence = sequence,
11736            "publishing agent stream chunk",
11737        );
11738        let envelope = NodeEventEnvelope {
11739            sequence,
11740            event: NodeEvent::AgentStream { address, chunk },
11741        };
11742        let _ = self.agent_stream_event_tx.send(Arc::new(vec![envelope]));
11743    }
11744
11745    fn current_sequence(&self) -> u64 {
11746        self.history
11747            .lock()
11748            .unwrap_or_else(|poisoned| poisoned.into_inner())
11749            .last_sequence
11750    }
11751
11752    fn controller_state(&self) -> Option<ControllerState> {
11753        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11754        let lease = controller.as_ref().copied()?;
11755        let remaining = lease.expires_at.saturating_duration_since(Instant::now());
11756        if remaining.is_zero() {
11757            *controller = None;
11758            return None;
11759        }
11760        Some(ControllerState {
11761            connection_id: lease.connection_id,
11762            lease_remaining_ms: remaining.as_millis().min(u64::MAX as u128) as u64,
11763        })
11764    }
11765
11766    fn acquire_controller(&self, connection_id: u64, role: ClientRole, lease_ms: u64) -> Result<ControllerState, NodeFailure> {
11767        if role != ClientRole::Operator {
11768            tracing::warn!(
11769                node_id = %self.node_id,
11770                connection_id,
11771                "controller lease rejected: observer connections cannot acquire control",
11772            );
11773            return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections cannot acquire control"));
11774        }
11775        let lease_ms = if lease_ms == 0 {
11776            DEFAULT_CONTROLLER_LEASE_MS
11777        } else {
11778            lease_ms.clamp(MIN_CONTROLLER_LEASE_MS, MAX_CONTROLLER_LEASE_MS)
11779        };
11780        let now = Instant::now();
11781        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11782        if let Some(active) = controller.as_ref() {
11783            if active.connection_id != connection_id && active.expires_at > now {
11784                tracing::warn!(
11785                    node_id = %self.node_id,
11786                    connection_id,
11787                    holder_connection_id = active.connection_id,
11788                    "controller lease rejected: another operator holds it",
11789                );
11790                return Err(failure(NodeFailureCode::ControllerBusy, "another operator holds the controller lease"));
11791            }
11792        }
11793        // A reconnect (this connection already holds the lease, renewing
11794        // before expiry) happens on a periodic cadence; logging it at INFO
11795        // every renewal would be exactly the heartbeat chatter this crate
11796        // family's level discipline forbids. A genuine attach -- the
11797        // controller identity actually changing -- is a state change and
11798        // gets INFO; a same-connection renewal gets DEBUG.
11799        let is_new_attach = match controller.as_ref() {
11800            Some(active) => active.connection_id != connection_id,
11801            None => true,
11802        };
11803        *controller = Some(ControllerLease {
11804            connection_id,
11805            expires_at: now + Duration::from_millis(lease_ms),
11806        });
11807        drop(controller);
11808        let state = ControllerState { connection_id, lease_remaining_ms: lease_ms };
11809        if is_new_attach {
11810            tracing::info!(
11811                node_id = %self.node_id,
11812                connection_id,
11813                lease_ms,
11814                "controller lease acquired",
11815            );
11816        } else {
11817            tracing::debug!(
11818                node_id = %self.node_id,
11819                connection_id,
11820                lease_ms,
11821                "controller lease renewed",
11822            );
11823        }
11824        self.publish(NodeEvent::ControllerChanged { controller: Some(state.clone()) });
11825        Ok(state)
11826    }
11827
11828    fn release_controller(&self, connection_id: u64) -> bool {
11829        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11830        let released = controller.as_ref().is_some_and(|active| active.connection_id == connection_id);
11831        if released {
11832            *controller = None;
11833        }
11834        drop(controller);
11835        if released {
11836            tracing::info!(
11837                node_id = %self.node_id,
11838                connection_id,
11839                "controller lease released",
11840            );
11841            self.publish(NodeEvent::ControllerChanged { controller: None });
11842        }
11843        released
11844    }
11845
11846    /// Mark a fresh Spawn as provisional and arm the attach-never-arrived
11847    /// reaper (PROVISIONAL_SPAWN_GRACE_MS from spawn). Removed from this map
11848    /// only when HQ attaches (Resize / TerminalBytes) — that is *not* a
11849    /// provider session; it only cancels the orphan timer. Disconnect/reload
11850    /// draft kills are owned by HQ and arm only on a real client WS close.
11851    /// ACP path of launch options: once the agent advertises its config options, write the
11852    /// pending model / effort through session/set_config_option (session/set_model when the
11853    /// agent has no model option). Gaps are logged, never forced.
11854    fn apply_pending_acp_options(&self, address: &SessionAddress, event: &ControlEvent) {
11855        let ControlEventKind::ProviderEvent { event: ProviderEvent::ConfigOptionsUpdated { options }, .. } = &event.event else {
11856            return;
11857        };
11858        let id = address.session.instance_id;
11859        let Some((model, effort)) = self.pending_acp_options.lock().unwrap_or_else(|p| p.into_inner()).remove(&id) else {
11860            return;
11861        };
11862        let advertised: Vec<(String, Option<String>, Vec<String>)> = options
11863            .iter()
11864            .map(|o| (o.id.clone(), o.category.clone(), o.choices.iter().map(|c| c.value_json.clone()).collect()))
11865            .collect();
11866        let (writes, set_model, effort_gap) =
11867            backend_paths::acp_option_writes(&advertised, model.as_deref(), effort.as_deref());
11868        if set_model {
11869            if let Some(model_id) = model.clone() {
11870                if let Err(e) = self.dispatch(ControlCommand::SetSessionModel { instance_id: id, model_id }) {
11871                    tracing::warn!(instance_id = id.0, message = %e.message, "acp set_model failed");
11872                }
11873            }
11874        }
11875        for (option_id, value_json) in writes {
11876            if let Err(e) = self.dispatch(ControlCommand::SetSessionConfigOption { instance_id: id, option_id, value_json }) {
11877                tracing::warn!(instance_id = id.0, message = %e.message, "acp set_config_option failed");
11878            }
11879        }
11880        if effort_gap {
11881            tracing::warn!(instance_id = id.0, effort = ?effort, "acp agent advertises no matching thought_level option; effort not applied");
11882        }
11883        tracing::info!(instance_id = id.0, model = ?model, effort = ?effort, "launch options applied over acp");
11884    }
11885
11886    /// ACP auto-permit: switch to the provider's no-ask mode once modes are advertised, and
11887    /// answer every session/request_permission with approve.
11888    fn apply_acp_auto_permit(&self, address: &SessionAddress, event: &ControlEvent) {
11889        let id = address.session.instance_id;
11890        let Some(provider) = crate::auto_permit::acp_provider(id.0) else { return };
11891        if matches!(&event.event, ControlEventKind::Exited { .. } | ControlEventKind::Failed { .. } | ControlEventKind::Removed) {
11892            crate::auto_permit::forget_acp(id.0);
11893            return;
11894        }
11895        let ControlEventKind::ProviderEvent { sequence, event: provider_event, .. } = &event.event else { return };
11896        if let ProviderEvent::InteractionRequested { request_id, interaction_kind, options, .. } = provider_event {
11897            tracing::debug!(instance_id = id.0, sequence = *sequence, request_id = ?request_id, kind = ?interaction_kind, options = options.len(), "acp interaction requested");
11898        }
11899        match provider_event {
11900            ProviderEvent::ModeChanged { mode_id, available } => {
11901                if let Some(want) = crate::auto_permit::pick_acp_mode(provider, mode_id, available.iter().map(|m| m.id.as_str())) {
11902                    match self.dispatch(ControlCommand::SetSessionMode { instance_id: id, mode_id: want.clone() }) {
11903                        Ok(_) => tracing::info!(instance_id = id.0, provider, mode = %want, "acp auto-permit mode set"),
11904                        Err(e) => tracing::warn!(instance_id = id.0, provider, message = %e.message, "acp auto-permit set_mode failed"),
11905                    }
11906                }
11907            }
11908            ProviderEvent::InteractionRequested { interaction_kind: gate4agent_types::ProviderInteractionKind::Approval, tool_name, options, .. } if !options.is_empty() => {
11909                match self.dispatch(ControlCommand::ResolveInteraction {
11910                    instance_id: id,
11911                    generation: address.session.generation,
11912                    interaction_id: gate4agent_types::ProviderInteractionId(*sequence),
11913                    response: gate4agent_types::ProviderInteractionResponse::ApproveOnce,
11914                }) {
11915                    Ok(_) => tracing::info!(instance_id = id.0, provider, tool = %tool_name, "acp permission auto-approved"),
11916                    Err(e) => tracing::warn!(instance_id = id.0, provider, message = %e.message, "acp auto-approve failed"),
11917                }
11918            }
11919            // NB: ACP emits SessionEnded at the end of every *turn* (prompt stopReason), so it
11920            // must not drop the auto-permit registration; that happens on process exit below.
11921            _ => {}
11922        }
11923    }
11924
11925    /// ACP side of the provider error classifier: remember the turn's prompt, reset on a clean
11926    /// end, classify a turn that ended with a provider error (or a provider `Error` event).
11927    fn observe_acp_provider_error(&self, address: &SessionAddress, event: &ControlEvent) {
11928        let id = address.session.instance_id.0;
11929        let found = match &event.event {
11930            ControlEventKind::Exited { .. } | ControlEventKind::Removed => {
11931                self.provider_errors.lock().unwrap_or_else(|p| p.into_inner()).forget(id);
11932                return;
11933            }
11934            ControlEventKind::ProviderEvent { event: e, .. } => match e {
11935                ProviderEvent::TurnStarted { prompt } => {
11936                    self.provider_errors.lock().unwrap_or_else(|p| p.into_inner()).turn_started(id, prompt.as_deref());
11937                    return;
11938                }
11939                ProviderEvent::Text { text, is_delta } => {
11940                    self.provider_errors.lock().unwrap_or_else(|p| p.into_inner()).turn_text(id, text, *is_delta);
11941                    return;
11942                }
11943                ProviderEvent::ToolStarted { .. } => {
11944                    self.provider_errors.lock().unwrap_or_else(|p| p.into_inner()).turn_tool(id);
11945                    return;
11946                }
11947                ProviderEvent::SessionEnded { is_error: false, result, .. } => {
11948                    let act = self.provider_errors.lock().unwrap_or_else(|p| p.into_inner()).turn_ended(id, result, Instant::now());
11949                    if let Some(act) = act {
11950                        let provider = crate::auto_permit::acp_provider(id).map(|p| p.to_string()).unwrap_or_else(|| "acp".into());
11951                        provider_error_act(address.workspace_id.as_str(), id, &provider, "acp", act);
11952                    }
11953                    return;
11954                }
11955                ProviderEvent::SessionEnded { stop_reason: Some(ProviderStopReason::ProviderError { message, vendor_code, .. }), .. } => {
11956                    let found = crate::provider_error::classify_message(message, vendor_code.as_deref());
11957                    let provider = crate::auto_permit::acp_provider(id).unwrap_or("acp");
11958                    let window = crate::provider_limits::fresh_5h_used(provider, 10 * 60 * 1000);
11959                    crate::provider_error::limit_from_window(found, vendor_code.as_deref(), window)
11960                }
11961                ProviderEvent::Error { message } => crate::provider_error::classify_message(message, None),
11962                _ => return,
11963            },
11964            _ => return,
11965        };
11966        let provider = crate::auto_permit::acp_provider(id).map(|p| p.to_string()).unwrap_or_else(|| "acp".into());
11967        let act = self.provider_errors.lock().unwrap_or_else(|p| p.into_inner()).observe_acp_error(id, found, Instant::now());
11968        if let Some(act) = act {
11969            provider_error_act(address.workspace_id.as_str(), id, &provider, "acp", act);
11970        }
11971    }
11972
11973    /// Run provider-error retries whose backoff is over (called from the drive loop).
11974    fn run_due_provider_retries(&self) {
11975        let due = self.provider_errors.lock().unwrap_or_else(|p| p.into_inner()).take_due(Instant::now());
11976        for (id, how, attempt) in due {
11977            let instance_id = AgentInstanceId(id);
11978            let r = match how {
11979                crate::provider_error::Retry::Pty => self
11980                    .dispatch(ControlCommand::SendInput { instance_id, action: InputAction::TerminalText(TerminalText { text: "continue".to_owned() }) })
11981                    .and_then(|_| self.dispatch(ControlCommand::SendInput { instance_id, action: InputAction::TerminalControl(TerminalControl::Enter) })),
11982                crate::provider_error::Retry::Acp { prompt } => self.dispatch(ControlCommand::SendInput {
11983                    instance_id,
11984                    action: InputAction::SubmitPrompt(PromptPayload { text: prompt, framing: PromptFraming::BracketedPaste }),
11985                }),
11986            };
11987            match r {
11988                Ok(_) => tracing::info!(target: "provider_retry", instance_id = id, attempt, "provider error: turn retried"),
11989                Err(e) => tracing::warn!(target: "provider_retry", instance_id = id, attempt, message = %e.message, "provider error: retry not sent"),
11990            }
11991        }
11992    }
11993
11994    fn mark_spawn_provisional(&self, address: SessionAddress) {
11995        let id = address.session.instance_id;
11996        self.provisional_spawns
11997            .lock()
11998            .unwrap_or_else(|poisoned| poisoned.into_inner())
11999            .insert(id, (address, Instant::now()));
12000        tracing::info!(
12001            node_id = %self.node_id,
12002            instance_id = id.0,
12003            grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
12004            "draft spawn; waiting for HQ view",
12005        );
12006    }
12007
12008    fn sessionize_spawn(&self, instance_id: AgentInstanceId) {
12009        let removed = self
12010            .provisional_spawns
12011            .lock()
12012            .unwrap_or_else(|poisoned| poisoned.into_inner())
12013            .remove(&instance_id);
12014        if removed.is_some() {
12015            tracing::info!(
12016                node_id = %self.node_id,
12017                instance_id = instance_id.0,
12018                "draft spawn claimed by HQ view",
12019            );
12020        }
12021    }
12022
12023    fn take_due_provisional_spawns(&self) -> Vec<SessionAddress> {
12024        let grace = Duration::from_millis(PROVISIONAL_SPAWN_GRACE_MS);
12025        let mut map = self
12026            .provisional_spawns
12027            .lock()
12028            .unwrap_or_else(|poisoned| poisoned.into_inner());
12029        let due: Vec<AgentInstanceId> = map
12030            .iter()
12031            .filter(|(_, (_, at))| at.elapsed() >= grace)
12032            .map(|(id, _)| *id)
12033            .collect();
12034        due.into_iter()
12035            .filter_map(|id| map.remove(&id).map(|(addr, _)| addr))
12036            .collect()
12037    }
12038
12039    async fn reap_provisional_spawn(&self, address: SessionAddress) {
12040        let instance_id = address.session.instance_id;
12041        tracing::warn!(
12042            node_id = %self.node_id,
12043            instance_id = instance_id.0,
12044            workspace = %address.workspace_id,
12045            reason = "attach_never_arrived",
12046            grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
12047            "draft spawn never viewed; stopping PTY",
12048        );
12049        let _ = self.dispatch(ControlCommand::Stop {
12050            instance_id,
12051            force: true,
12052        });
12053        let _ = self.remove_session(&address).await;
12054    }
12055
12056    fn require_controller(&self, connection_id: u64, role: ClientRole) -> Result<(), NodeFailure> {
12057        if role != ClientRole::Operator {
12058            return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections are read-only"));
12059        }
12060        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
12061        let now = Instant::now();
12062        match controller.as_ref().copied() {
12063            Some(active) if active.connection_id == connection_id && active.expires_at > now => Ok(()),
12064            Some(active) if active.expires_at <= now => {
12065                *controller = None;
12066                Err(failure(NodeFailureCode::ControllerRequired, "controller lease expired"))
12067            }
12068            _ => Err(failure(NodeFailureCode::ControllerRequired, "controller lease is required")),
12069        }
12070    }
12071
12072    fn validate_address(&self, address: &SessionAddress) -> Result<AgentId, NodeFailure> {
12073        if !self
12074            .workspaces
12075            .read()
12076            .unwrap_or_else(|poisoned| poisoned.into_inner())
12077            .contains_key(&address.workspace_id)
12078        {
12079            return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
12080        }
12081        let binding = self
12082            .session_bindings
12083            .lock()
12084            .unwrap_or_else(|poisoned| poisoned.into_inner())
12085            .get(&address.session.instance_id)
12086            .cloned()
12087            .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
12088        if binding.workspace_id != address.workspace_id {
12089            return Err(failure(
12090                NodeFailureCode::SessionWorkspaceMismatch,
12091                "session belongs to another workspace",
12092            ));
12093        }
12094        if binding.generation != address.session.generation {
12095            return Err(failure(
12096                NodeFailureCode::StaleGeneration,
12097                &format!(
12098                    "stale session generation: expected {}, received {}",
12099                    binding.generation.0,
12100                    address.session.generation.0,
12101                ),
12102            ));
12103        }
12104        let snapshot = self.handle.snapshot();
12105        let Some(current) = snapshot
12106            .sessions
12107            .iter()
12108            .find(|item| item.instance_id == address.session.instance_id)
12109        else {
12110            return Err(failure(NodeFailureCode::UnknownSession, "session instance does not exist"));
12111        };
12112        if current.generation != address.session.generation {
12113            return Err(failure(
12114                NodeFailureCode::StaleGeneration,
12115                &format!(
12116                    "stale session generation: expected {}, received {}",
12117                    current.generation.0,
12118                    address.session.generation.0,
12119                ),
12120            ));
12121        }
12122        Ok(current.agent_id.clone())
12123    }
12124
12125    fn dispatch(&self, command: ControlCommand) -> Result<CommandId, NodeFailure> {
12126        self.dispatch_envelope(self.prepare_command(command))
12127    }
12128
12129    fn prepare_command(&self, command: ControlCommand) -> CommandEnvelope {
12130        let id = self.next_command_id.fetch_add(1, Ordering::AcqRel);
12131        CommandEnvelope {
12132            id: CommandId(id),
12133            command,
12134        }
12135    }
12136
12137    fn dispatch_envelope(&self, envelope: CommandEnvelope) -> Result<CommandId, NodeFailure> {
12138        let command_id = envelope.id;
12139        self.handle
12140            .dispatch(envelope)
12141            .map_err(|error| match error {
12142                PortDispatchError::Full => failure(NodeFailureCode::BackendBusy, "backend command ingress is full"),
12143                PortDispatchError::Disconnected => failure(NodeFailureCode::BackendDisconnected, "backend command ingress is disconnected"),
12144            })?;
12145        Ok(command_id)
12146    }
12147
12148    /// Records the kernel's own reason for asynchronously refusing
12149    /// `command_id` -- see `rejected_command_reasons`'s doc for why a
12150    /// spawn-dispatch waiter otherwise has no way to learn it. Bounded by
12151    /// `REJECTED_COMMAND_REASON_MAX_ENTRIES`: expired entries are swept
12152    /// first, and if the map is still at capacity after that, the single
12153    /// oldest surviving entry is evicted -- `CommandId` is monotonically
12154    /// assigned (`next_command_id.fetch_add`), so the `BTreeMap`'s first key
12155    /// is always the oldest.
12156    fn record_rejected_command(
12157        &self,
12158        command_id: CommandId,
12159        message: String,
12160        unsupported_transport: bool,
12161    ) {
12162        let now = Instant::now();
12163        let mut reasons = self.rejected_command_reasons.lock()
12164            .unwrap_or_else(|poisoned| poisoned.into_inner());
12165        reasons.retain(|_, reason| reason.expires_at > now);
12166        if reasons.len() >= REJECTED_COMMAND_REASON_MAX_ENTRIES {
12167            if let Some(&oldest) = reasons.keys().next() {
12168                reasons.remove(&oldest);
12169            }
12170        }
12171        reasons.insert(
12172            command_id,
12173            RejectedCommandReason {
12174                message,
12175                unsupported_transport,
12176                expires_at: now + Duration::from_millis(REJECTED_COMMAND_REASON_TTL_MS),
12177            },
12178        );
12179    }
12180
12181    /// Removes and returns the kernel's reason for refusing `command_id`,
12182    /// if the drive loop has recorded one and it has not expired -- see
12183    /// `rejected_command_reasons`'s doc. Consumed at most once: a spawn
12184    /// wait loop calls this instead of blindly polling out its whole
12185    /// deadline when the command it is waiting on turns out to have been
12186    /// refused rather than merely slow.
12187    fn take_rejected_command_reason(&self, command_id: CommandId) -> Option<RejectedCommandReason> {
12188        let now = Instant::now();
12189        let mut reasons = self.rejected_command_reasons.lock()
12190            .unwrap_or_else(|poisoned| poisoned.into_inner());
12191        match reasons.remove(&command_id) {
12192            Some(reason) if reason.expires_at > now => Some(reason),
12193            _ => None,
12194        }
12195    }
12196
12197    async fn dispatch_bounded(
12198        &self,
12199        command: ControlCommand,
12200        timeout_duration: Duration,
12201    ) -> Result<CommandId, NodeFailure> {
12202        let deadline = Instant::now() + timeout_duration;
12203        loop {
12204            match self.dispatch(command.clone()) {
12205                Ok(command_id) => return Ok(command_id),
12206                Err(error) if error.code == NodeFailureCode::BackendBusy && Instant::now() < deadline => {
12207                    sleep(Duration::from_millis(2)).await;
12208                }
12209                Err(error) => return Err(error),
12210            }
12211        }
12212    }
12213
12214    fn internal_session_snapshot(
12215        &self,
12216        address: &SessionAddress,
12217    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
12218        self.validate_address(address)?;
12219        self.handle
12220            .snapshot()
12221            .sessions
12222            .clone()
12223            .into_iter()
12224            .find(|session| {
12225                session.instance_id == address.session.instance_id
12226                    && session.generation == address.session.generation
12227            })
12228            .ok_or_else(|| {
12229                failure(
12230                    NodeFailureCode::UnknownSession,
12231                    "session snapshot is unavailable",
12232                )
12233            })
12234    }
12235
12236    /// Confirms `address` names a live session spawned over the ACP
12237    /// transport and returns its current snapshot -- the gate every
12238    /// `acp-control-v1` verb shares. `require_session_runtime_policy`
12239    /// cannot answer this by itself: `ProviderRuntimePolicy` carries no
12240    /// transport bit for `Acp`/`Inline` (see that method's own doc), only
12241    /// the session's own recorded `TransportKind` does, and that lives on
12242    /// the live snapshot, not the binding.
12243    fn require_acp_session(
12244        &self,
12245        address: &SessionAddress,
12246    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
12247        let snapshot = self.internal_session_snapshot(address)?;
12248        if snapshot.transport != TransportKind::Acp {
12249            return Err(failure(
12250                NodeFailureCode::UnsupportedTransport,
12251                "this verb requires a session spawned over the ACP transport",
12252            ));
12253        }
12254        Ok(snapshot)
12255    }
12256
12257    async fn dispatch_history_bounded(
12258        &self,
12259        address: &SessionAddress,
12260        command: ControlCommand,
12261        operation: HistoryOperation,
12262    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
12263        self.validate_address(address)?;
12264        let started_after = self.current_sequence();
12265        let command_id = self
12266            .dispatch_bounded(
12267                command,
12268                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12269            )
12270            .await?;
12271        let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
12272        let mut scan_after = started_after;
12273        let mut accepted_after = None;
12274        loop {
12275            let events = self
12276                .history
12277                .lock()
12278                .unwrap_or_else(|poisoned| poisoned.into_inner())
12279                .events
12280                .iter()
12281                .filter(|envelope| envelope.sequence > scan_after)
12282                .cloned()
12283                .collect::<Vec<_>>();
12284            for envelope in events {
12285                scan_after = scan_after.max(envelope.sequence);
12286                let NodeEvent::Control {
12287                    address: event_address,
12288                    event,
12289                } = envelope.event
12290                else {
12291                    continue;
12292                };
12293                if event_address != *address {
12294                    continue;
12295                }
12296                if event.command_id == Some(command_id) {
12297                    match &event.event {
12298                        ControlEventKind::CommandRejected { .. } => {
12299                            return Err(failure(
12300                                NodeFailureCode::InvalidRequest,
12301                                "history request was rejected",
12302                            ));
12303                        }
12304                        ControlEventKind::HistoryRequested {
12305                            operation: requested,
12306                            ..
12307                        } if requested == &operation => {
12308                            accepted_after = Some(envelope.sequence);
12309                            continue;
12310                        }
12311                        _ => {}
12312                    }
12313                }
12314                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12315                    match (&operation, &event.event) {
12316                        (
12317                            HistoryOperation::Discover { .. },
12318                            ControlEventKind::HistoryDiscovered { .. },
12319                        )
12320                        | (
12321                            HistoryOperation::Load { .. },
12322                            ControlEventKind::HistoryLoaded { .. },
12323                        ) => return self.internal_session_snapshot(address),
12324                        (_, ControlEventKind::HistoryFailed { .. }) => {
12325                            return Err(failure(
12326                                NodeFailureCode::BackendOperationFailed,
12327                                "history operation failed",
12328                            ));
12329                        }
12330                        _ => {}
12331                    }
12332                }
12333            }
12334            if Instant::now() >= deadline {
12335                return Err(failure(
12336                    NodeFailureCode::BackendBusy,
12337                    if accepted_after.is_some() {
12338                        "history operation did not settle before the bounded deadline"
12339                    } else {
12340                        "history operation was not accepted before the bounded deadline"
12341                    },
12342                ));
12343            }
12344            sleep(Duration::from_millis(2)).await;
12345        }
12346    }
12347
12348    async fn discover_history(
12349        &self,
12350        address: &SessionAddress,
12351        limit: u16,
12352    ) -> Result<Vec<HistoryCandidateSummary>, NodeFailure> {
12353        let query = HistoryQuery {
12354            working_directory: Some(self.workspace_root(&address.workspace_id)?),
12355            limit,
12356        };
12357        query
12358            .validate()
12359            .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history query"))?;
12360        let snapshot = self
12361            .dispatch_history_bounded(
12362                address,
12363                ControlCommand::DiscoverHistory {
12364                    instance_id: address.session.instance_id,
12365                    query: query.clone(),
12366                },
12367                HistoryOperation::Discover { query },
12368            )
12369            .await?;
12370        if snapshot.history.pending.is_some() || snapshot.history.loaded.is_some() {
12371            return Err(failure(
12372                NodeFailureCode::BackendOperationFailed,
12373                "history discovery settled into an invalid state",
12374            ));
12375        }
12376        Ok(snapshot.history.candidates)
12377    }
12378
12379    async fn load_history(
12380        &self,
12381        address: &SessionAddress,
12382        candidate_id: String,
12383    ) -> Result<HistorySessionRecord, NodeFailure> {
12384        validate_candidate_id(&candidate_id)
12385            .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history candidate"))?;
12386        let snapshot = self
12387            .dispatch_history_bounded(
12388                address,
12389                ControlCommand::LoadHistory {
12390                    instance_id: address.session.instance_id,
12391                    candidate_id: candidate_id.clone(),
12392                },
12393                HistoryOperation::Load {
12394                    candidate_id: candidate_id.clone(),
12395                },
12396            )
12397            .await?;
12398        if snapshot.history.pending.is_some()
12399            || snapshot.history.loaded_candidate_id.as_ref() != Some(&candidate_id)
12400        {
12401            return Err(failure(
12402                NodeFailureCode::BackendOperationFailed,
12403                "history load settled into an invalid state",
12404            ));
12405        }
12406        snapshot.history.loaded.ok_or_else(|| {
12407            failure(
12408                NodeFailureCode::BackendOperationFailed,
12409                "history load produced no bounded session",
12410            )
12411        })
12412    }
12413
12414    /// `allow_clean_detachment`: `false` for the on-demand
12415    /// `NodeRequest::ExportContextPackForSessionRecord` API path (the
12416    /// managed record must stay untouched, still live, for the whole call);
12417    /// `true` only for the reactive auto-export-at-exit path (`§2.1`), which
12418    /// reacts to the very clean-exit event that detaches the record out from
12419    /// under this same call — see `session_record_export_target_matches`.
12420    async fn export_context_pack_for_session_record(
12421        &self,
12422        record_id: &SessionRecordId,
12423        session: &SessionAddress,
12424        allow_clean_detachment: bool,
12425    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12426        // C2 replaces a relayed failure's prose with a canned per-code
12427        // string (`C2NodeFailure`'s own `From`), so this path's dozen
12428        // distinct refusals are indistinguishable to anyone downstream --
12429        // and a refusal here expires a continuation, which keeps no field
12430        // for why. The prose stays on the node, where it was written.
12431        let outcome = self
12432            .export_context_pack_for_session_record_inner(record_id, session, allow_clean_detachment)
12433            .await;
12434        if let Err(failure) = &outcome {
12435            tracing::warn!(
12436                record = %record_id,
12437                code = ?failure.code,
12438                reason = %failure.message,
12439                "session-record context-pack export refused",
12440            );
12441        }
12442        outcome
12443    }
12444
12445    async fn export_context_pack_for_session_record_inner(
12446        &self,
12447        record_id: &SessionRecordId,
12448        session: &SessionAddress,
12449        allow_clean_detachment: bool,
12450    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12451        // Several steps here refuse with the same BackendOperationFailed
12452        // code and no prose of their own, so which step failed is
12453        // otherwise unrecoverable -- and a refusal here expires a
12454        // continuation.
12455        let step = |name: &'static str| move |failure: NodeFailure| {
12456            tracing::warn!(step = name, code = ?failure.code, "context-pack export step refused");
12457            failure
12458        };
12459        let record = self.record(record_id).map_err(step("record-lookup"))?;
12460        // `provider_session` is `Some` only for a provider whose resolved
12461        // runtime policy admits live identity verification (raw PTY plus a
12462        // semantic PTY adapter -- `provider_runtime::policy_from_capability_flags`).
12463        // The PTY-sidecar-observation shape (no fleet provider today, but
12464        // capability-keyed rather than vendor-locked) hardcodes
12465        // `provider_session_identity: false` in `provider_runtime::evaluate`,
12466        // so its records go straight to `Live` with `provider_session: None`
12467        // and stay that way for the record's entire life -- that is this
12468        // shape's permanent resting state, not a race with observation, and
12469        // `discover_history`/`load_history` below do not need an identity to
12470        // do their job for it. `IdentityPending` is the actual race (an
12471        // identity-admitted provider whose observation has not landed yet):
12472        // it keeps refusing here rather than falling back, because that
12473        // provider's own history the fallback would otherwise trust is not
12474        // guaranteed to be exact without the identity it is still waiting on.
12475        let identity = match record.provider_session.clone() {
12476            Some(identity) => {
12477                if identity.key != ProviderSessionKey::SessionId {
12478                    return Err(step("provider-identity-key")(failure(
12479                        NodeFailureCode::BackendOperationFailed,
12480                        "managed session provider identity cannot bind history exactly",
12481                    )));
12482                }
12483                Some(identity)
12484            }
12485            None if record.state == ManagedSessionState::IdentityPending => {
12486                return Err(step("provider-session-identity")(failure(
12487                    NodeFailureCode::BackendOperationFailed,
12488                    "managed session has not yet observed its provider session identity",
12489                )));
12490            }
12491            None => None,
12492        };
12493        self.revalidate_session_record_context_export(
12494            &record,
12495            identity.as_ref(),
12496            session,
12497            allow_clean_detachment,
12498        ).map_err(step("revalidate-before-discovery"))?;
12499        let candidates = self
12500            .discover_history(session, HISTORY_DISCOVERY_LIMIT_MAX)
12501            .await
12502            .map_err(step("discover-history"))?;
12503        // With no verified identity to filter by, the discovered set must
12504        // still resolve to exactly one candidate: the same ambiguity refusal
12505        // an identity filter would otherwise produce is the only safety
12506        // property left available for this shape.
12507        let candidate_id = select_history_candidate(
12508            &candidates,
12509            identity.as_ref().map(|identity| identity.id.as_str()),
12510        ).map_err(step("unique-candidate"))?;
12511        let loaded = self.load_history(session, candidate_id.clone()).await
12512            .map_err(step("load-history"))?;
12513        if let Some(identity) = identity.as_ref() {
12514            if loaded.session_id != identity.id {
12515                return Err(failure(
12516                    NodeFailureCode::SessionRecordConflict,
12517                    "loaded history identity does not match the managed session record",
12518                ));
12519            }
12520        }
12521        self.revalidate_session_record_context_export(
12522            &record,
12523            identity.as_ref(),
12524            session,
12525            allow_clean_detachment,
12526        )?;
12527        self.revalidate_loaded_history(session, &candidate_id, &loaded)
12528            .map_err(step("revalidate-loaded-history"))?;
12529        let pack = self.materialize_context_pack(session).await
12530            .map_err(step("materialize-context-pack"))?;
12531        self.commit_context_pack_for_session_record(
12532            &record,
12533            identity.as_ref(),
12534            session,
12535            &candidate_id,
12536            &loaded,
12537            pack,
12538            allow_clean_detachment,
12539        )
12540    }
12541
12542    async fn export_context_pack(
12543        &self,
12544        address: &SessionAddress,
12545    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12546        let pack = self.materialize_context_pack(address).await?;
12547        self.commit_context_pack(pack)
12548    }
12549
12550    async fn materialize_context_pack(
12551        &self,
12552        address: &SessionAddress,
12553    ) -> Result<NodeContextPack, NodeFailure> {
12554        let snapshot = self.internal_session_snapshot(address)?;
12555        if !context_pack_source_status_is_usable(&snapshot.status) {
12556            return Err(failure(
12557                NodeFailureCode::InvalidRequest,
12558                "context export requires a running or successfully exited source session",
12559            ));
12560        }
12561        if snapshot.history.pending.is_some()
12562            || snapshot.history.loaded_candidate_id.is_none()
12563        {
12564            return Err(failure(
12565                NodeFailureCode::InvalidRequest,
12566                "context export requires settled loaded history",
12567            ));
12568        }
12569        let loaded = snapshot.history.loaded.clone().ok_or_else(|| {
12570            failure(
12571                NodeFailureCode::InvalidRequest,
12572                "context export requires settled loaded history",
12573            )
12574        })?;
12575        let source_provider = snapshot.agent_id.clone();
12576        let loaded_candidate_id = snapshot.history.loaded_candidate_id.clone();
12577        let repository = self
12578            .context_pack_repository(&address.workspace_id)
12579            .await?;
12580        let current = self.internal_session_snapshot(address)?;
12581        if !context_pack_source_status_is_usable(&current.status)
12582            || current.agent_id != source_provider
12583            || current.history.pending.is_some()
12584            || current.history.loaded_candidate_id != loaded_candidate_id
12585            || current.history.loaded.as_ref() != Some(&loaded)
12586        {
12587            return Err(failure(
12588                NodeFailureCode::ContextPackMaterializationFailed,
12589                "context source changed during bounded repository capture",
12590            ));
12591        }
12592        NodeContextPack::export_with_repository(
12593            ContextPackLineageReceipt {
12594                source_node_id: self.node_id.clone(),
12595                source_session: address.clone(),
12596                source_provider,
12597            },
12598            &loaded,
12599            Some(repository),
12600        )
12601        .map_err(|_| {
12602            failure(
12603                NodeFailureCode::ContextPackMaterializationFailed,
12604                "bounded context pack export failed",
12605            )
12606        })
12607    }
12608
12609    /// Write-through to the durable [`ContextPackStore`], if one is
12610    /// configured (`state_path` is `Some`). A node with no durable state
12611    /// path — fixtures, tests, ephemeral runs — has no `ContextPackStore`
12612    /// open at all and this is a no-op, matching `persist_state`'s own
12613    /// "durability optional, degrade gracefully when unconfigured" idiom.
12614    fn commit_context_pack_durably(&self, pack: &NodeContextPack) -> Result<(), NodeFailure> {
12615        let mut store = self
12616            .context_pack_store
12617            .lock()
12618            .unwrap_or_else(|poisoned| poisoned.into_inner());
12619        let Some(store) = store.as_mut() else {
12620            return Ok(());
12621        };
12622        store.commit(pack).map_err(|_| {
12623            failure(
12624                NodeFailureCode::ContextPackMaterializationFailed,
12625                "durable context pack commit failed",
12626            )
12627        })
12628    }
12629
12630    fn commit_context_pack(
12631        &self,
12632        pack: NodeContextPack,
12633    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12634        self.commit_context_pack_durably(&pack)?;
12635        self.context_catalog
12636            .write()
12637            .unwrap_or_else(|poisoned| poisoned.into_inner())
12638            .insert(pack)
12639            .map_err(|_| {
12640                failure(
12641                    NodeFailureCode::ContextPackMaterializationFailed,
12642                    "context pack catalog is full",
12643                )
12644            })
12645    }
12646
12647    fn commit_context_pack_for_session_record(
12648        &self,
12649        expected: &ManagedSessionRecord,
12650        identity: Option<&ProviderSessionIdentity>,
12651        session: &SessionAddress,
12652        candidate_id: &str,
12653        loaded: &HistorySessionRecord,
12654        pack: NodeContextPack,
12655        allow_clean_detachment: bool,
12656    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12657        let current_root = self.workspace_root(&expected.workspace_id)?;
12658        let _transaction = self
12659            .state_transaction
12660            .lock()
12661            .unwrap_or_else(|poisoned| poisoned.into_inner());
12662        let records = self
12663            .session_records
12664            .lock()
12665            .unwrap_or_else(|poisoned| poisoned.into_inner());
12666        let current = records.records.get(&expected.record_id).ok_or_else(|| {
12667            failure(
12668                NodeFailureCode::SessionRecordConflict,
12669                "managed session record disappeared during context export",
12670            )
12671        })?;
12672        if !session_record_export_target_matches(expected, current, session, allow_clean_detachment) {
12673            return Err(failure(
12674                NodeFailureCode::SessionRecordConflict,
12675                "managed session record changed during context export",
12676            ));
12677        }
12678        let bindings = self
12679            .session_bindings
12680            .lock()
12681            .unwrap_or_else(|poisoned| poisoned.into_inner());
12682        let binding = bindings.get(&session.session.instance_id).ok_or_else(|| {
12683            failure(
12684                NodeFailureCode::SessionRecordConflict,
12685                "managed session runtime binding disappeared during context export",
12686            )
12687        })?;
12688        let runtime = self
12689            .handle
12690            .snapshot()
12691            .sessions
12692            .iter()
12693            .find(|snapshot| {
12694                snapshot.instance_id == session.session.instance_id
12695                    && snapshot.generation == session.session.generation
12696            })
12697            .cloned()
12698            .ok_or_else(|| {
12699                failure(
12700                    NodeFailureCode::SessionRecordConflict,
12701                    "managed session runtime disappeared during context export",
12702                )
12703            })?;
12704        let receipt = pack.receipt();
12705        if !session_record_context_export_binding_is_exact(
12706            expected,
12707            current,
12708            identity,
12709            session,
12710            binding,
12711            &runtime.agent_id,
12712            &current_root,
12713            allow_clean_detachment,
12714        ) || !session_record_context_export_source_is_usable(
12715            current,
12716            &runtime.status,
12717            allow_clean_detachment,
12718        ) || runtime.history.pending.is_some()
12719            || runtime.history.loaded_candidate_id.as_deref() != Some(candidate_id)
12720            || runtime.history.loaded.as_ref() != Some(loaded)
12721            || receipt.lineage.source_node_id != self.node_id
12722            || receipt.lineage.source_session != *session
12723            || receipt.lineage.source_provider != current.provider
12724            || receipt.source_message_count != loaded.message_count
12725        {
12726            return Err(failure(
12727                NodeFailureCode::SessionRecordConflict,
12728                "managed session source changed before context catalog commit",
12729            ));
12730        }
12731        self.commit_context_pack_durably(&pack)?;
12732        self.context_catalog
12733            .write()
12734            .unwrap_or_else(|poisoned| poisoned.into_inner())
12735            .insert(pack)
12736            .map_err(|_| {
12737                failure(
12738                    NodeFailureCode::ContextPackMaterializationFailed,
12739                    "context pack catalog is full",
12740                )
12741            })
12742    }
12743
12744    async fn context_pack_repository(
12745        &self,
12746        workspace_id: &WorkspaceId,
12747    ) -> Result<ContextPackRepository, NodeFailure> {
12748        let first = self
12749            .context_pack_repository_observation(workspace_id)
12750            .await?;
12751        let second = self
12752            .context_pack_repository_observation(workspace_id)
12753            .await?;
12754        stable_context_pack_repository(first, second)
12755    }
12756
12757    async fn context_pack_repository_observation(
12758        &self,
12759        workspace_id: &WorkspaceId,
12760    ) -> Result<ContextPackRepositoryObservation, NodeFailure> {
12761        let git = self.inspect_workspace(workspace_id.clone()).await?.git;
12762        let head = if git.is_repository {
12763            let root = self.workspace_root(workspace_id)?;
12764            match resolve_base_commit_with_timeout(
12765                &root,
12766                "HEAD",
12767                GIT_COMMAND_TIMEOUT_MS,
12768            )
12769            .await
12770            {
12771                Ok(commit) => ContextPackRepositoryHead::Commit(commit),
12772                Err(error) if error.kind == GitWorktreeErrorKind::Conflict => {
12773                    ContextPackRepositoryHead::Unborn
12774                }
12775                Err(_) => {
12776                    return Err(failure(
12777                        NodeFailureCode::ContextPackMaterializationFailed,
12778                        "context pack HEAD observation failed",
12779                    ));
12780                }
12781            }
12782        } else {
12783            ContextPackRepositoryHead::NotRepository
12784        };
12785        let mut files = Vec::with_capacity(CONTEXT_PACK_SELECTED_FILES.len());
12786        for selected_path in CONTEXT_PACK_SELECTED_FILES {
12787            let path = RepositoryPath::utf8(selected_path.to_owned()).map_err(|_| {
12788                failure(
12789                    NodeFailureCode::InvalidRepositoryPath,
12790                    "context pack selected file path is invalid",
12791                )
12792            })?;
12793            match self.read_workspace_file(workspace_id.clone(), path).await {
12794                Ok(WorkspaceFileRead {
12795                    content: WorkspaceFileContent::Utf8 { text, byte_len },
12796                    ..
12797                }) => files.push(ContextPackRepositoryFileSource::utf8(
12798                    selected_path,
12799                    text,
12800                    byte_len,
12801                )),
12802                Ok(WorkspaceFileRead {
12803                    content: WorkspaceFileContent::NonUtf8 { byte_len },
12804                    ..
12805                }) => files.push(ContextPackRepositoryFileSource::skipped(
12806                    selected_path,
12807                    ContextPackSelectedFileSkipReason::NonUtf8,
12808                    Some(byte_len),
12809                )),
12810                Ok(WorkspaceFileRead {
12811                    content: WorkspaceFileContent::TooLarge { .. },
12812                    ..
12813                }) => files.push(ContextPackRepositoryFileSource::skipped(
12814                    selected_path,
12815                    ContextPackSelectedFileSkipReason::TooLarge,
12816                    None,
12817                )),
12818                Err(error) => {
12819                    let reason = if matches!(
12820                        error.code,
12821                        NodeFailureCode::InvalidRepositoryPath
12822                            | NodeFailureCode::RepositoryFileNotRegular
12823                            | NodeFailureCode::RepositoryPathUnsafe
12824                    ) {
12825                        ContextPackSelectedFileSkipReason::Unsafe
12826                    } else {
12827                        ContextPackSelectedFileSkipReason::Unavailable
12828                    };
12829                    files.push(ContextPackRepositoryFileSource::skipped(
12830                        selected_path,
12831                        reason,
12832                        None,
12833                    ));
12834                }
12835            }
12836        }
12837        Ok(ContextPackRepositoryObservation { head, git, files })
12838    }
12839
12840    fn forget_context_pack(&self, context_id: &SpawnContextId) -> Result<(), NodeFailure> {
12841        let referenced_by_binding = self
12842            .session_bindings
12843            .lock()
12844            .unwrap_or_else(|poisoned| poisoned.into_inner())
12845            .values()
12846            .any(|binding| {
12847                binding.context.as_ref().map(|receipt| &receipt.id) == Some(context_id)
12848            });
12849        let referenced_by_record = self
12850            .session_records
12851            .lock()
12852            .unwrap_or_else(|poisoned| poisoned.into_inner())
12853            .records
12854            .values()
12855            .any(|record| record.context_id.as_ref() == Some(context_id));
12856        let referenced_by_materialization = self
12857            .materializations
12858            .lock()
12859            .unwrap_or_else(|poisoned| poisoned.into_inner())
12860            .values()
12861            .any(|ownership| {
12862                ownership.context().map(|receipt| &receipt.id) == Some(context_id)
12863            });
12864        if referenced_by_binding
12865            || referenced_by_record
12866            || referenced_by_materialization
12867        {
12868            return Err(failure(
12869                NodeFailureCode::ContextPackBusy,
12870                "context pack is still referenced",
12871            ));
12872        }
12873        let mut catalog = self.context_catalog
12874            .write()
12875            .unwrap_or_else(|poisoned| poisoned.into_inner());
12876        if catalog.get(context_id).is_none() {
12877            return Err(failure(
12878                NodeFailureCode::UnknownContextPack,
12879                "context pack is unavailable",
12880            ));
12881        }
12882        self.spawn_idempotency
12883            .lock()
12884            .unwrap_or_else(|poisoned| poisoned.into_inner())
12885            .entries
12886            .retain(|_, entry| !entry.value.references_context(context_id));
12887        let removed = catalog.remove(context_id);
12888        debug_assert!(removed.is_some(), "validated context pack remains present");
12889        Ok(())
12890    }
12891
12892    async fn dispatch_input_bounded(
12893        &self,
12894        address: &SessionAddress,
12895        action: InputAction,
12896    ) -> Result<(), NodeFailure> {
12897        let started_after = self.current_sequence();
12898        self.pty_input_marks
12899            .lock()
12900            .unwrap_or_else(|poisoned| poisoned.into_inner())
12901            .insert(address.session.instance_id, (Instant::now(), false));
12902        self.pty_input_wake.notify_one();
12903        let command_id = self
12904            .dispatch_bounded(
12905                ControlCommand::SendInput {
12906                    instance_id: address.session.instance_id,
12907                    action,
12908                },
12909                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12910            )
12911            .await?;
12912        // A semantic prompt may spend the provider's full readiness budget before
12913        // the runtime emits InputCompleted. Keep the node request deadline bounded,
12914        // but strictly beyond every readiness policy in this node's catalog.
12915        let deadline = Instant::now() + Duration::from_millis(self.input_settle_timeout_ms);
12916        let mut scan_after = started_after;
12917        let mut accepted_after = None;
12918        loop {
12919            let events = self
12920                .history
12921                .lock()
12922                .unwrap_or_else(|poisoned| poisoned.into_inner())
12923                .events
12924                .iter()
12925                .filter(|envelope| envelope.sequence > scan_after)
12926                .cloned()
12927                .collect::<Vec<_>>();
12928            for envelope in events {
12929                scan_after = scan_after.max(envelope.sequence);
12930                let NodeEvent::Control {
12931                    address: event_address,
12932                    event,
12933                } = envelope.event
12934                else {
12935                    continue;
12936                };
12937                if event_address != *address {
12938                    continue;
12939                }
12940                if event.command_id == Some(command_id) {
12941                    match &event.event {
12942                        ControlEventKind::CommandRejected { message } => {
12943                            return Err(failure(NodeFailureCode::InvalidRequest, message));
12944                        }
12945                        ControlEventKind::InputRequested { .. } => {
12946                            accepted_after = Some(envelope.sequence);
12947                            continue;
12948                        }
12949                        _ => {}
12950                    }
12951                }
12952                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12953                    match &event.event {
12954                        ControlEventKind::InputCompleted { .. } => return Ok(()),
12955                        ControlEventKind::InputFailed { 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                        "input was accepted but did not settle before the bounded deadline"
12970                    } else {
12971                        "input was not accepted before the bounded deadline"
12972                    },
12973                ));
12974            }
12975            sleep(Duration::from_millis(2)).await;
12976        }
12977    }
12978
12979    async fn dispatch_resize_bounded(
12980        &self,
12981        address: &SessionAddress,
12982        size: gate4agent_types::TerminalSize,
12983    ) -> Result<(), NodeFailure> {
12984        let started_after = self.current_sequence();
12985        let command_id = self
12986            .dispatch_bounded(
12987                ControlCommand::Resize {
12988                    instance_id: address.session.instance_id,
12989                    size,
12990                },
12991                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12992            )
12993            .await?;
12994        let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
12995        let mut scan_after = started_after;
12996        let mut accepted_after = None;
12997        loop {
12998            let events = self
12999                .history
13000                .lock()
13001                .unwrap_or_else(|poisoned| poisoned.into_inner())
13002                .events
13003                .iter()
13004                .filter(|envelope| envelope.sequence > scan_after)
13005                .cloned()
13006                .collect::<Vec<_>>();
13007            for envelope in events {
13008                scan_after = scan_after.max(envelope.sequence);
13009                let NodeEvent::Control {
13010                    address: event_address,
13011                    event,
13012                } = envelope.event
13013                else {
13014                    continue;
13015                };
13016                if event_address != *address {
13017                    continue;
13018                }
13019                if event.command_id == Some(command_id) {
13020                    match &event.event {
13021                        ControlEventKind::CommandRejected { message } => {
13022                            return Err(failure(NodeFailureCode::InvalidRequest, message));
13023                        }
13024                        ControlEventKind::ResizeRequested {
13025                            size: requested_size,
13026                            ..
13027                        } if *requested_size == size => {
13028                            accepted_after = Some(envelope.sequence);
13029                            continue;
13030                        }
13031                        _ => {}
13032                    }
13033                }
13034                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
13035                    match &event.event {
13036                        ControlEventKind::Resized { size: settled_size }
13037                            if *settled_size == size =>
13038                        {
13039                            return Ok(());
13040                        }
13041                        ControlEventKind::ResizeFailed { message } => {
13042                            return Err(failure(
13043                                NodeFailureCode::BackendOperationFailed,
13044                                message,
13045                            ));
13046                        }
13047                        _ => {}
13048                    }
13049                }
13050            }
13051            if Instant::now() >= deadline {
13052                return Err(failure(
13053                    NodeFailureCode::BackendBusy,
13054                    if accepted_after.is_some() {
13055                        "resize was accepted but did not settle before the bounded deadline"
13056                    } else {
13057                        "resize was not accepted before the bounded deadline"
13058                    },
13059                ));
13060            }
13061            sleep(Duration::from_millis(2)).await;
13062        }
13063    }
13064
13065    async fn spawn_session(
13066        &self,
13067        workspace_id: WorkspaceId,
13068        provider: AgentId,
13069        mode: SessionMode,
13070        terminal_size: gate4agent_types::TerminalSize,
13071        initial_prompt: Option<String>,
13072    ) -> Result<SessionAddress, NodeFailure> {
13073        if self.managed_worktrees.lock()
13074            .unwrap_or_else(|poisoned| poisoned.into_inner())
13075            .lease_for_workspace(&workspace_id)
13076            .is_some()
13077        {
13078            return Err(failure(
13079                NodeFailureCode::ManagedWorktreeOwnershipConflict,
13080                "managed worktree targets may only be spawned through their lease request",
13081            ));
13082        }
13083        self
13084            .spawn_session_with_deadline(
13085                workspace_id,
13086                provider.clone(),
13087                mode,
13088                terminal_size,
13089                initial_prompt,
13090                None,
13091                None,
13092                None,
13093                None,
13094                None,
13095                SpawnRecordPolicy::ProviderIdentityOnly,
13096                None,
13097                &[],
13098                None,
13099                // `NodeRequest::Spawn` (this method's only caller) carries no
13100                // approval-level selection of its own -- see
13101                // `HarnessOperatorRequestV1::SpawnSession`'s own field for
13102                // the one operator-wire path that can choose something other
13103                // than the axis default. Auto-permit may lower it where the agent
13104                // cannot offer its full-auto mode (claude ACP under root).
13105                crate::auto_permit::spawn_approval_level(provider.as_str(), mode).unwrap_or_default(),
13106                None,
13107                None,
13108            )
13109            .await
13110            .map(|(session, _runtime_policy)| session)
13111    }
13112
13113    async fn spawn_session_with_deadline(
13114        &self,
13115        workspace_id: WorkspaceId,
13116        provider: AgentId,
13117        mode: SessionMode,
13118        terminal_size: gate4agent_types::TerminalSize,
13119        initial_prompt: Option<String>,
13120        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
13121        bundle: Option<ResolvedBundleReceipt>,
13122        context: Option<ResolvedContextPackReceipt>,
13123        managed_authority: Option<ManagedWorktreeLeaseId>,
13124        admitted_runtime_policy: Option<ProviderRuntimePolicy>,
13125        record_policy: SpawnRecordPolicy,
13126        deadline: Option<Instant>,
13127        required_capabilities: &[ProviderRuntimeCapability],
13128        harness_mcp: Option<&PreparedHarnessMcpSpawn>,
13129        approval_level: ApprovalLevel,
13130        network_allowlist: Option<crate::protocol::SpawnNetworkAllowlistId>,
13131        // Opaque dig2browser station profile id (ids only). Feature
13132        // `dig2-station-probe`: after probe at resolve, spawn takes an
13133        // exclusive node-local lease; cleared on session end. Never
13134        // ImportSession / cookies on C2. Feature off: ignored (stub echo
13135        // already happened on the receipt path).
13136        browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
13137    ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
13138        let reserved_lease = self.managed_worktrees.lock()
13139            .unwrap_or_else(|poisoned| poisoned.into_inner())
13140            .lease_for_workspace(&workspace_id);
13141        match (reserved_lease.as_ref(), managed_authority.as_ref()) {
13142            (Some(expected), Some(provided)) if expected == provided => {}
13143            (None, None) => {}
13144            _ => {
13145                return Err(failure(
13146                    NodeFailureCode::ManagedWorktreeOwnershipConflict,
13147                    "spawn lacks exact managed worktree lease authority",
13148                ));
13149            }
13150        }
13151        // Refuse concurrent distinct environment profiles (incl. ProviderHome
13152        // bindings) on the same workspace cwd. Parallel path: managed worktree
13153        // spawn so each profile gets its own workspace_id.
13154        self.ensure_workspace_environment_profile_compatible(
13155            &workspace_id,
13156            environment_profile.as_ref(),
13157        )?;
13158        let runtime_requirement = match (mode, initial_prompt.is_some()) {
13159            (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
13160            (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
13161            (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
13162            (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
13163        };
13164        // This session's own transport -- computed once, here, from `mode`,
13165        // and reused both to admit spawn (below) and, further down, to
13166        // compute the final `runtime_policy` this session actually operates
13167        // under (`provider_runtime::policy_for_transport`). Registration
13168        // reuses this exact value rather than recomputing it a second time.
13169        let transport = match mode {
13170            SessionMode::Pty => TransportKind::Pty,
13171            SessionMode::Inline => TransportKind::Pipe,
13172            SessionMode::Acp => TransportKind::Acp,
13173        };
13174        let pipe_transport = self.provider_supports_pipe_transport(&provider);
13175        let acp_transport = self.provider_supports_acp_transport(&provider);
13176        let runtime_policy = if let Some(runtime_policy) = admitted_runtime_policy {
13177            require_policy(runtime_policy, pipe_transport, acp_transport, runtime_requirement).map_err(|_| failure(
13178                NodeFailureCode::UnsupportedSpawnCapability,
13179                "pre-admitted provider runtime no longer satisfies spawn requirements",
13180            ))?;
13181            runtime_policy
13182        } else if let Some(deadline) = deadline {
13183            let remaining = spawn_deadline_remaining(deadline)?;
13184            timeout(
13185                remaining,
13186                self.admit_provider_runtime(&provider, runtime_requirement),
13187            )
13188            .await
13189            .map_err(|_| {
13190                failure(
13191                    NodeFailureCode::SpawnDeadlineExceeded,
13192                    "provider admission exceeded the spawn deadline",
13193                )
13194            })??
13195        } else {
13196            self.admit_provider_runtime(&provider, runtime_requirement).await?
13197        };
13198        let runtime_policy = self.effective_spawn_runtime_policy(runtime_policy);
13199        // The single point where this session's `runtime_policy` becomes
13200        // transport-correct: `admit_provider_runtime` (and the pre-admitted
13201        // branch above) only ever compute a PTY-terminal-verification-shaped
13202        // value, sound outright only for `TransportKind::Pty` (Pipe still
13203        // relies on that same value downstream -- see `policy_for_transport`'s
13204        // own doc for why). `policy_for_transport` is the explicit,
13205        // exhaustive match Item 1 asks for -- this is the ONE place its
13206        // output becomes the session's `runtime_policy`, so every later
13207        // reader (`ControlCommand::Start`, `session_bindings`, and every
13208        // engine gate that reads `state.runtime_policy`) sees the same,
13209        // correctly-computed value.
13210        let runtime_policy =
13211            crate::provider_runtime::policy_for_transport(transport, runtime_policy);
13212        if required_capabilities
13213            .iter()
13214            .any(|capability| !runtime_policy.admits(*capability))
13215        {
13216            return Err(failure(
13217                NodeFailureCode::UnsupportedSpawnCapability,
13218                "provider runtime does not admit a required spawn capability",
13219            ));
13220        }
13221        if let Some(deadline) = deadline {
13222            spawn_deadline_remaining(deadline)?;
13223        }
13224        self.ensure_binding_capacity()?;
13225        let mut working_directory = self.workspace_root(&workspace_id)?;
13226        let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
13227        let session = SessionKey {
13228            instance_id,
13229            generation: SessionGeneration(1),
13230        };
13231        let address = SessionAddress {
13232            workspace_id: workspace_id.clone(),
13233            session,
13234        };
13235        // This is the request-accepted checkpoint: admission (managed
13236        // worktree authority, runtime capability, dispatch deadline) already
13237        // passed and a concrete session identity is minted. It is a distinct
13238        // state from the process actually running (`ControlEventKind::Running`,
13239        // logged in `publish_control`) or the terminal outcome (`Exited`/
13240        // `Failed`, logged there too) -- this line exists so that gap is no
13241        // longer silent.
13242        tracing::info!(
13243            node_id = %self.node_id,
13244            workspace_id = %workspace_id,
13245            provider = %provider,
13246            environment_profile = ?environment_profile.as_ref().map(|receipt| &receipt.profile_id),
13247            session = ?address.session,
13248            "session spawn request accepted",
13249        );
13250        let mut prepared_spawn = SpawnPreparer::prepare(
13251            self,
13252            SpawnPrepareRequest {
13253                instance_id,
13254                address: &address,
13255                provider: &provider,
13256                mode,
13257                environment_profile: environment_profile.as_ref(),
13258                bundle: bundle.as_ref(),
13259                context: context.as_ref(),
13260                managed_authority: managed_authority.as_ref(),
13261                harness_mcp,
13262                approval_level,
13263                network_allowlist: network_allowlist.as_ref(),
13264                browser_profile_id,
13265            },
13266        )?;
13267        let materialization_id = prepared_spawn.materialization_id.take();
13268        let dispatch_timeout = spawn_dispatch_timeout(deadline)?;
13269        let record_id = self.bind_spawn_session_with_materialization(
13270            &address,
13271            provider.clone(),
13272            mode,
13273            runtime_policy,
13274            record_policy,
13275            environment_profile,
13276            bundle,
13277            context,
13278            materialization_id,
13279        )?;
13280        prepared_spawn.retain_session_guards();
13281        // `transport` was already computed above, alongside this session's
13282        // `runtime_policy`; reused here for `Register` rather than derived a
13283        // second time.
13284        let session_provider = provider.clone();
13285        let agent_id = provider;
13286        let register_command_id = match self.dispatch_bounded(
13287            ControlCommand::Register {
13288                instance_id,
13289                agent_id,
13290                transport,
13291            },
13292            dispatch_timeout,
13293        )
13294        .await {
13295            Ok(command_id) => command_id,
13296            Err(error) => {
13297                if let Some(binding) = self.remove_binding(&address) {
13298                    SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
13299                }
13300                if let Some(record_id) = record_id.as_ref() {
13301                    self.discard_record(record_id)?;
13302                }
13303                return Err(spawn_dispatch_error(error, deadline));
13304            }
13305        };
13306        let start_timeout = match spawn_dispatch_timeout(deadline) {
13307            Ok(timeout) => timeout,
13308            Err(error) => {
13309                let recovery = self
13310                    .rollback_spawn(
13311                        &address,
13312                        Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13313                    )
13314                    .await;
13315                return match recovery {
13316                    Ok(()) => {
13317                        if let Some(record_id) = record_id.as_ref() {
13318                            self.discard_record(record_id)?;
13319                        }
13320                        Err(error)
13321                    }
13322                    Err(recovery_error) => {
13323                        if let Some(record_id) = record_id.as_ref() {
13324                            self.mark_record_error(
13325                                record_id,
13326                                "spawn deadline elapsed and registration recovery failed",
13327                            )?;
13328                        }
13329                        Err(recovery_error)
13330                    }
13331                };
13332            }
13333        };
13334        if let Err(start_error) = self.verify_harness_mcp_before_start(harness_mcp) {
13335            if let Err(recovery_error) = self
13336                .rollback_spawn(&address, Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS))
13337                .await
13338            {
13339                return Err(recovery_error);
13340            }
13341            if let Some(record_id) = record_id.as_ref() {
13342                self.discard_record(record_id)?;
13343            }
13344            return Err(start_error);
13345        }
13346        // A session with its own m4a account runs in its own folder inside the workspace.
13347        if let Some(dir) = self.session_workdir(std::path::Path::new(&working_directory), instance_id, &session_provider) {
13348            working_directory = dir.to_string_lossy().into_owned();
13349        }
13350        let start_result = self
13351            .dispatch_bounded(
13352                ControlCommand::Start {
13353                    instance_id,
13354                    runtime_policy,
13355                    request: StartRequest {
13356                        working_directory,
13357                        terminal_size,
13358                        initial_prompt,
13359                        session_options: SPAWN_SESSION_OPTIONS.try_with(|o| o.clone()).ok().flatten(),
13360                        approval_level,
13361                    },
13362                },
13363                start_timeout,
13364            )
13365            .await;
13366        let start_command_id = match start_result {
13367            Ok(command_id) => command_id,
13368            Err(start_error) => {
13369                let start_error = spawn_dispatch_error(start_error, deadline);
13370                let recovery = self
13371                    .rollback_spawn(
13372                        &address,
13373                        Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13374                    )
13375                    .await;
13376                return match recovery {
13377                    Ok(()) => {
13378                        if let Some(record_id) = record_id.as_ref() {
13379                            self.discard_record(record_id)?;
13380                        }
13381                        Err(start_error)
13382                    }
13383                    Err(recovery_error) => {
13384                        let diagnostic = format!(
13385                            "start failed and registration recovery failed: {}",
13386                            recovery_error.message,
13387                        );
13388                        if let Some(record_id) = record_id.as_ref() {
13389                            self.mark_record_error(record_id, &diagnostic)?;
13390                        }
13391                        Err(failure(recovery_error.code, &diagnostic))
13392                    }
13393                };
13394            }
13395        };
13396
13397        let commit_deadline = deadline.unwrap_or_else(|| Instant::now() + dispatch_timeout);
13398        loop {
13399            // `Register`/`Start` only fail synchronously for a port-level
13400            // ingress problem (see `dispatch_bounded`); the kernel refusing
13401            // either command's content -- an unsupported transport, an
13402            // unknown agent -- lands one drive-loop tick later, and no
13403            // session ever appears in `self.handle.snapshot()` for it to
13404            // notice below. Check for that named reason before falling
13405            // through to the blind presence poll, so a rejected spawn
13406            // reports why instead of just timing out.
13407            if let Some(reason) = self.take_rejected_command_reason(register_command_id)
13408                .or_else(|| self.take_rejected_command_reason(start_command_id))
13409            {
13410                self.rollback_spawn(
13411                    &address,
13412                    Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13413                ).await.map_err(|error| {
13414                        failure(
13415                            error.code,
13416                            &format!(
13417                                "spawn was rejected ({}) and registration recovery failed: {}",
13418                                reason.message, error.message
13419                            ),
13420                        )
13421                    })?;
13422                if let Some(record_id) = record_id.as_ref() {
13423                    self.discard_record(record_id)?;
13424                }
13425                let code = if reason.unsupported_transport {
13426                    NodeFailureCode::UnsupportedTransport
13427                } else {
13428                    NodeFailureCode::BackendOperationFailed
13429                };
13430                return Err(failure(code, &reason.message));
13431            }
13432            if let Some(current) = self
13433                .handle
13434                .snapshot()
13435                .sessions
13436                .iter()
13437                .find(|current| current.instance_id == instance_id)
13438            {
13439                if current.generation != session.generation {
13440                    let divergence = failure(
13441                        NodeFailureCode::StaleGeneration,
13442                        "spawn generation diverged",
13443                    );
13444                    return match self
13445                        .rollback_spawn(
13446                            &address,
13447                            Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13448                        )
13449                        .await
13450                    {
13451                        Ok(()) => {
13452                            if let Some(record_id) = record_id.as_ref() {
13453                                self.discard_record(record_id)?;
13454                            }
13455                            Err(divergence)
13456                        }
13457                        Err(recovery_error) => {
13458                            if let Some(record_id) = record_id.as_ref() {
13459                                self.mark_record_error(
13460                                    record_id,
13461                                    "spawn generation diverged and recovery failed",
13462                                )?;
13463                            }
13464                            Err(recovery_error)
13465                        }
13466                    };
13467                }
13468                if !matches!(current.status, gate4agent_types::SessionStatus::Registered) {
13469                    prepared_spawn.retain_launch_guards();
13470                    return Ok((address, runtime_policy));
13471                }
13472            }
13473            if Instant::now() >= commit_deadline {
13474                self.rollback_spawn(
13475                    &address,
13476                    Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13477                ).await.map_err(|error| {
13478                        failure(
13479                            error.code,
13480                            &format!(
13481                                "start did not commit and registration recovery failed: {}",
13482                                error.message
13483                            ),
13484                        )
13485                    })?;
13486                if let Some(record_id) = record_id.as_ref() {
13487                    self.discard_record(record_id)?;
13488                }
13489                return Err(failure(
13490                    if deadline.is_some() {
13491                        NodeFailureCode::SpawnDeadlineExceeded
13492                    } else {
13493                        NodeFailureCode::BackendBusy
13494                    },
13495                    "start did not commit before the bounded deadline; registration was removed",
13496                ));
13497            }
13498            sleep(Duration::from_millis(2)).await;
13499        }
13500    }
13501
13502    fn effective_spawn_runtime_policy(
13503        &self,
13504        runtime_policy: ProviderRuntimePolicy,
13505    ) -> ProviderRuntimePolicy {
13506        #[cfg(feature = "fixture")]
13507        let runtime_policy = if self.fixture_semantic_hook_policy {
13508            ProviderRuntimePolicy::new(true, true, true, true, false, true)
13509                .expect("monitoring Hook fixture policy is internally valid")
13510        } else {
13511            runtime_policy
13512        };
13513        runtime_policy
13514    }
13515
13516    fn verify_harness_mcp_before_start(
13517        &self,
13518        prepared: Option<&PreparedHarnessMcpSpawn>,
13519    ) -> Result<(), NodeFailure> {
13520        prepared
13521            .map(PreparedHarnessMcpSpawn::verify_helper_program)
13522            .transpose()
13523            .map(|_| ())
13524            .map_err(harness_mcp_failure)
13525    }
13526
13527    async fn rollback_spawn(
13528        &self,
13529        address: &SessionAddress,
13530        commit_timeout: Duration,
13531    ) -> Result<(), NodeFailure> {
13532        self.dispatch_bounded(
13533            ControlCommand::Remove {
13534                instance_id: address.session.instance_id,
13535            },
13536            commit_timeout,
13537        )
13538        .await?;
13539        self.wait_until_removed(address, commit_timeout).await?;
13540        if let Some(binding) = self.remove_binding(address) {
13541            SessionLifecycleHook::cleanup_session_materialization(self, address, &binding)?;
13542        }
13543        Ok(())
13544    }
13545
13546    async fn remove_session(&self, address: &SessionAddress) -> Result<(), NodeFailure> {
13547        let commit_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
13548        self.dispatch_bounded(
13549            ControlCommand::Remove {
13550                instance_id: address.session.instance_id,
13551            },
13552            commit_timeout,
13553        )
13554            .await?;
13555        self.wait_until_removed(address, commit_timeout).await?;
13556        let removed = self.remove_binding(address);
13557        if let Some(binding) = removed.as_ref() {
13558            SessionLifecycleHook::cleanup_session_materialization(self, address, binding)?;
13559        }
13560        if let Some(lease_id) = removed.and_then(|binding| binding.managed_worktree_lease_id) {
13561            let snapshot = self.managed_worktrees.lock()
13562                .unwrap_or_else(|poisoned| poisoned.into_inner())
13563                .release_session(
13564                    &lease_id,
13565                    self.incarnation_id,
13566                    address.session.instance_id,
13567                    address.session.generation,
13568                    unix_time_ms(),
13569                );
13570            if self.persist_state().is_err() {
13571                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
13572            }
13573            if let Some(snapshot) = snapshot {
13574                self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
13575            }
13576            let _ = self.cleanup_managed_worktree(&lease_id, false).await;
13577        }
13578        Ok(())
13579    }
13580
13581    /// Polls until `address`'s session leaves the engine's own session map,
13582    /// or the bounded deadline expires.
13583    ///
13584    /// A `Remove` sent while the session is not yet in a terminal status
13585    /// (e.g. still `Stopping`, mid-effect right after a `Stop`) is rejected
13586    /// once by the engine (`SessionStatus::allows_remove`) and
13587    /// then discarded -- nothing re-issues it on its own, so the removal
13588    /// that would satisfy this wait never arrives and the caller was
13589    /// guaranteed to time out even though the session settles microseconds
13590    /// later. So this re-dispatches on every poll tick: a session that
13591    /// settles mid-wait -- the common case right after a `Stop` -- still
13592    /// gets removed inside the same bounded deadline.
13593    ///
13594    /// It re-dispatches only once the status will ACCEPT the command. A
13595    /// rejected re-dispatch is not the harmless no-op this loop first
13596    /// assumed: every rejection publishes a `ControlEventKind::Command
13597    /// Rejected` to every subscriber and writes a WARN line. Ungated, one
13598    /// ordinary teardown produced 241 of them in 1.6 seconds -- the whole
13599    /// `Stopping` window at one known-doomed dispatch per 2ms tick, fanned
13600    /// out to the harness and every attached client. Gated, the same
13601    /// teardown sends exactly one `Remove`, at the first tick the engine
13602    /// can act on it.
13603    ///
13604    /// The status is read from the snapshot this loop already takes, so
13605    /// the gate costs nothing extra. It races -- the status can move
13606    /// between the read and the dispatch -- and that is fine: a rare lost
13607    /// race is one rejection, and the next tick retries.
13608    ///
13609    /// Re-dispatch is separately rate-limited to
13610    /// [`REMOVE_REDISPATCH_INTERVAL_MS`], because the status gate alone
13611    /// leaves a second burst behind it. Once the session settles, every
13612    /// 2ms tick sent another `Remove` while the accepted one was still in
13613    /// flight, and each of those landed on an instance the engine had
13614    /// already dropped -- `agent instance … is not registered`, observed
13615    /// live as a tail of rejections immediately after the `session
13616    /// removed` line. The presence poll stays at 2ms so the wait still
13617    /// exits promptly; only the sending slows down.
13618    ///
13619    /// Rate-limited rather than once-only on purpose: the status gate
13620    /// covers the status precondition, but `Gate4AgentEngine::remove` has
13621    /// others (a pending operation, an in-flight capability probe or
13622    /// history request) that clear on their own. A single shot would turn
13623    /// those into a guaranteed `BackendBusy` timeout; at 10Hz they still
13624    /// get their retries, just twenty of them across the deadline instead
13625    /// of a thousand.
13626    async fn wait_until_removed(
13627        &self,
13628        address: &SessionAddress,
13629        commit_timeout: Duration,
13630    ) -> Result<(), NodeFailure> {
13631        let deadline = Instant::now() + commit_timeout;
13632        let mut next_dispatch = Instant::now();
13633        loop {
13634            let snapshot = self.handle.snapshot();
13635            let status = snapshot
13636                .sessions
13637                .iter()
13638                .find(|session| session.instance_id == address.session.instance_id)
13639                .map(|session| session.status.clone());
13640            let Some(status) = status else {
13641                return Ok(());
13642            };
13643            let now = Instant::now();
13644            if now >= deadline {
13645                return Err(failure(
13646                    NodeFailureCode::BackendBusy,
13647                    "remove did not commit before the bounded deadline; session binding was retained",
13648                ));
13649            }
13650            if status.allows_remove() && now >= next_dispatch {
13651                let _ = self.dispatch(ControlCommand::Remove {
13652                    instance_id: address.session.instance_id,
13653                });
13654                next_dispatch = now + Duration::from_millis(REMOVE_REDISPATCH_INTERVAL_MS);
13655            }
13656            sleep(Duration::from_millis(2)).await;
13657        }
13658    }
13659
13660    fn resync(&self, after_sequence: u64) -> NodeResponse {
13661        let history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
13662        let event_sequence = history.last_sequence;
13663        let oldest_available_sequence = history.replay_floor_sequence;
13664        let events = history
13665            .events
13666            .iter()
13667            .filter(|event| event.sequence > after_sequence)
13668            .cloned()
13669            .collect();
13670        drop(history);
13671        NodeResponse::Resync {
13672            event_sequence,
13673            oldest_available_sequence,
13674            snapshot: self.snapshot(),
13675            events,
13676        }
13677    }
13678}
13679
13680/// Coarse label for one `AgentStreamChunkKindV1`, used only by
13681/// `NodeShared::publish_agent_stream_chunk`'s own debug log -- mirrors
13682/// `hatchery-harness-service`'s `agent_stream_event_kind_label` (same
13683/// "name what crossed this boundary" reasoning, one layer upstream of it).
13684fn agent_stream_chunk_kind_label(kind: &AgentStreamChunkKindV1) -> &'static str {
13685    match kind {
13686        AgentStreamChunkKindV1::Text { .. } => "text",
13687        AgentStreamChunkKindV1::Thinking { .. } => "thinking",
13688        AgentStreamChunkKindV1::InteractionPrompt { .. } => "interaction-prompt",
13689        AgentStreamChunkKindV1::ModeCatalog { .. } => "mode-catalog",
13690        AgentStreamChunkKindV1::ConfigOptions { .. } => "config-options",
13691        AgentStreamChunkKindV1::ModelCatalog { .. } => "model-catalog",
13692        AgentStreamChunkKindV1::Blocked { .. } => "blocked",
13693    }
13694}
13695
13696/// Serves this wire on the local endpoint, and -- when `call_home` names a
13697/// relay -- over an outbound connection to it as well.
13698///
13699/// The two live in one future rather than a fourth arm of `run`'s own
13700/// `select!` because they are one concern: this is where the node's wire
13701/// is served, and the direction a given connection was opened in does not
13702/// change what happens on it. It also means the existing graceful drain
13703/// below covers both without being duplicated.
13704///
13705/// Either side finishing ends the pair, which is correct: the listener
13706/// only returns on shutdown or a bind failure, and `call_home_loop` only
13707/// returns on shutdown -- a failed relay connection is retried inside it,
13708/// never propagated out.
13709async fn accept_connections(
13710    endpoint: &str,
13711    call_home: Option<std::net::SocketAddr>,
13712    identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13713    shared: Arc<NodeShared>,
13714) -> Result<(), NodeServerError> {
13715    let mut connections = JoinSet::new();
13716    let result = match call_home {
13717        None => accept_connections_inner(endpoint, shared, &mut connections).await,
13718        Some(relay) => {
13719            let listener =
13720                accept_connections_inner(endpoint, Arc::clone(&shared), &mut connections);
13721            let dialler = call_home_loop(relay, identity, Arc::clone(&shared));
13722            tokio::pin!(listener);
13723            tokio::pin!(dialler);
13724            tokio::select! {
13725                result = &mut listener => result,
13726                result = &mut dialler => result,
13727            }
13728        }
13729    };
13730    let graceful_deadline = Instant::now() + Duration::from_millis(CONNECTION_SHUTDOWN_GRACE_MS);
13731    while !connections.is_empty() {
13732        let remaining = graceful_deadline.saturating_duration_since(Instant::now());
13733        if remaining.is_zero()
13734            || timeout(remaining, connections.join_next()).await.is_err()
13735        {
13736            break;
13737        }
13738    }
13739    connections.abort_all();
13740    while connections.join_next().await.is_some() {}
13741    result
13742}
13743
13744/// Backoff ladder for a relay that will not answer, capped rather than
13745/// unbounded: a node whose relay is down is not broken and should keep
13746/// trying forever, but it should not spend a core doing it.
13747const CALL_HOME_BACKOFFS_MS: [u64; 5] = [500, 1_000, 2_000, 4_000, 8_000];
13748
13749/// Dials the relay, serves this wire over the connection, and does it
13750/// again when the connection ends.
13751///
13752/// The node is the SERVER on the socket it just opened. That inversion is
13753/// the whole point: `serve_connection` is the same function the local
13754/// listener feeds, and it neither knows nor cares which end placed the
13755/// call. The one thing this direction adds is the preface -- the relay
13756/// accepted a socket and cannot know whose it is, so the node says its
13757/// name before the handshake it would otherwise begin with.
13758///
13759/// Never returns an error. A relay that is down, refusing, or speaking a
13760/// different protocol is a condition to retry, not a reason to take the
13761/// node's local endpoint down with it; the only way out is shutdown.
13762async fn call_home_loop(
13763    relay: std::net::SocketAddr,
13764    identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13765    shared: Arc<NodeShared>,
13766) -> Result<(), NodeServerError> {
13767    let mut failures = 0_usize;
13768    loop {
13769        if shared.shutdown.load(Ordering::Acquire) {
13770            return Ok(());
13771        }
13772        let permit = tokio::select! {
13773            permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13774                permit.map_err(|_| NodeServerError::PreauthClosed)?
13775            }
13776            _ = shared.shutdown_notify.notified() => return Ok(()),
13777        };
13778        match dial_relay_and_serve(relay, identity.as_deref(), Arc::clone(&shared), permit).await {
13779            Ok(()) => {
13780                // A clean end is the relay hanging up, which it does on
13781                // its own restart. Reconnect immediately rather than
13782                // serving the backoff a peer that is plainly there.
13783                failures = 0;
13784                tracing::info!(node_id = %shared.node_id, %relay, "call-home connection closed; redialling");
13785            }
13786            Err(error) => {
13787                failures = failures.saturating_add(1);
13788                tracing::warn!(
13789                    node_id = %shared.node_id,
13790                    %relay,
13791                    cause = %error,
13792                    attempt = failures,
13793                    "call-home connection failed",
13794                );
13795                let backoff = call_home_backoff(failures);
13796                tokio::select! {
13797                    _ = sleep(backoff) => {}
13798                    _ = shared.shutdown_notify.notified() => return Ok(()),
13799                }
13800            }
13801        }
13802    }
13803}
13804
13805/// Idle time before the first keepalive probe on a call-home socket.
13806const CALL_HOME_KEEPALIVE_IDLE: Duration = Duration::from_secs(15);
13807/// Interval between keepalive probes once the link has gone quiet.
13808const CALL_HOME_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(5);
13809
13810fn call_home_keepalive() -> socket2::TcpKeepalive {
13811    let keepalive = socket2::TcpKeepalive::new()
13812        .with_time(CALL_HOME_KEEPALIVE_IDLE)
13813        .with_interval(CALL_HOME_KEEPALIVE_INTERVAL);
13814    #[cfg(not(windows))]
13815    let keepalive = keepalive.with_retries(3);
13816    keepalive
13817}
13818
13819fn enable_call_home_keepalive(stream: &tokio::net::TcpStream) -> std::io::Result<()> {
13820    socket2::SockRef::from(stream).set_tcp_keepalive(&call_home_keepalive())
13821}
13822
13823/// Same keepalive profile for any node->relay socket (restream uplink).
13824pub(crate) fn enable_tcp_keepalive(stream: &std::net::TcpStream) -> std::io::Result<()> {
13825    socket2::SockRef::from(stream).set_tcp_keepalive(&call_home_keepalive())
13826}
13827
13828/// Backoff for the `attempt`-th consecutive failure (1-based), capped.
13829fn call_home_backoff(attempt: usize) -> Duration {
13830    Duration::from_millis(
13831        CALL_HOME_BACKOFFS_MS[attempt.saturating_sub(1).min(CALL_HOME_BACKOFFS_MS.len() - 1)],
13832    )
13833}
13834
13835
13836/// Dial the call-home relay. Tunnel addresses go through the in-process
13837/// WireGuard dialer when the userspace session is up.
13838async fn dial_call_home(relay: std::net::SocketAddr) -> Result<tokio::net::TcpStream, NodeServerError> {
13839    #[cfg(feature = "wireguard")]
13840    {
13841        let std_stream = tokio::task::spawn_blocking(move || {
13842            gate4agent_node_core::kit_wireguard::tcp_connect(relay)
13843        })
13844        .await
13845        .map_err(|e| NodeServerError::CallHomeTransport(std::io::Error::other(e.to_string())))?
13846        .map_err(NodeServerError::CallHomeTransport)?;
13847        std_stream
13848            .set_nonblocking(true)
13849            .map_err(NodeServerError::CallHomeTransport)?;
13850        return tokio::net::TcpStream::from_std(std_stream).map_err(NodeServerError::CallHomeTransport);
13851    }
13852    #[cfg(not(feature = "wireguard"))]
13853    {
13854        tokio::net::TcpStream::connect(relay)
13855            .await
13856            .map_err(NodeServerError::CallHomeTransport)
13857    }
13858}
13859
13860/// One call-home attempt: connect, name this node, then serve.
13861async fn dial_relay_and_serve(
13862    relay: std::net::SocketAddr,
13863    identity: Option<&hatchery_enroll::NodeIdentity>,
13864    shared: Arc<NodeShared>,
13865    preauth_permit: OwnedSemaphorePermit,
13866) -> Result<(), NodeServerError> {
13867    let mut stream = dial_call_home(relay).await?;
13868    // Nagle off for the same reason the rest of this wire wants it off:
13869    // the traffic is small request/response frames, and coalescing them
13870    // adds latency to every one.
13871    let _ = stream.set_nodelay(true);
13872    // A relay that vanishes without a FIN/RST reaching us (tunnel drop,
13873    // relay-side socket kill whose RST was lost) would otherwise leave this
13874    // socket ESTABLISHED forever: the node mostly reads, so nothing ever
13875    // fails and the reconnect loop never runs. Kernel keepalive turns that
13876    // half-open link into a read error within a bounded time.
13877    if let Err(error) = enable_call_home_keepalive(&stream) {
13878        tracing::warn!(cause = %error, "could not enable TCP keepalive on the call-home socket");
13879    }
13880    gate4agent_node_wire::write_call_home_announce(&mut stream, &shared.node_id)
13881        .await
13882        .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13883    tracing::info!(node_id = %shared.node_id, %relay, "announced this node to the relay");
13884    let mut session_token = None;
13885    if let Some(identity) = identity {
13886        // An enrolled relay answers the announce with a signed-call-home challenge; a
13887        // token-mode relay sends its wire hello instead. Peek to tell them apart
13888        // without consuming either.
13889        let mut magic = [0_u8; 4];
13890        let peeked = timeout(Duration::from_secs(10), stream.peek(&mut magic))
13891            .await
13892            .map_err(|_| NodeServerError::Handshake("relay sent nothing after the announce".to_owned()))?
13893            .map_err(NodeServerError::CallHomeTransport)?;
13894        if peeked == 4 && &magic == hatchery_enroll::CHALLENGE_MAGIC {
13895            let mut frame = [0_u8; hatchery_enroll::CHALLENGE_LEN];
13896            timeout(Duration::from_secs(5), tokio::io::AsyncReadExt::read_exact(&mut stream, &mut frame))
13897                .await
13898                .map_err(|_| NodeServerError::Handshake("relay challenge timed out".to_owned()))?
13899                .map_err(NodeServerError::CallHomeTransport)?;
13900            let (answer, token) = identity
13901                .answer_challenge(shared.node_id.as_str(), &frame)
13902                .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13903            tokio::io::AsyncWriteExt::write_all(&mut stream, &answer).await.map_err(NodeServerError::CallHomeTransport)?;
13904            tracing::info!(node_id = %shared.node_id, "answered the relay's signed call-home challenge");
13905            session_token = Some(token);
13906        }
13907    }
13908    serve_connection(stream, shared, session_token, preauth_permit).await
13909}
13910
13911async fn accept_connections_inner(
13912    endpoint: &str,
13913    shared: Arc<NodeShared>,
13914    connections: &mut JoinSet<Result<(), NodeServerError>>,
13915) -> Result<(), NodeServerError> {
13916    let mut listener = OwnerOnlyLocalListener::bind(endpoint).await?;
13917    loop {
13918        while connections.try_join_next().is_some() {}
13919        if shared.shutdown.load(Ordering::Acquire) {
13920            return Ok(());
13921        }
13922        let preauth_permit = tokio::select! {
13923            permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13924                permit.map_err(|_| NodeServerError::PreauthClosed)?
13925            }
13926            _ = shared.shutdown_notify.notified() => return Ok(()),
13927        };
13928        let server = tokio::select! {
13929            result = listener.accept() => result?,
13930            _ = shared.shutdown_notify.notified() => return Ok(()),
13931        };
13932        let connection_shared = Arc::clone(&shared);
13933        connections.spawn(async move {
13934            serve_connection(server, connection_shared, None, preauth_permit).await
13935        });
13936        if shared.shutdown.load(Ordering::Acquire) {
13937            return Ok(());
13938        }
13939    }
13940}
13941
13942/// Serves one connection, whichever end opened it.
13943///
13944/// Generic over the stream rather than typed to `LocalServerStream`
13945/// because a call-home connection arrives on a `TcpStream` the node itself
13946/// dialled. Nothing in the body ever depended on the concrete type -- it
13947/// reads and writes framed JSON and then `tokio::io::split`s -- so this is
13948/// the whole of what welding "who dials" to "who serves" was costing.
13949async fn serve_connection<S>(
13950    mut pipe: S,
13951    shared: Arc<NodeShared>,
13952    session_token: Option<String>,
13953    preauth_permit: OwnedSemaphorePermit,
13954) -> Result<(), NodeServerError>
13955where
13956    S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
13957{
13958    // A signed call-home connection authenticates with its own per-connection token.
13959    let auth_token: &[u8] = session_token.as_deref().map_or(shared.access_token.as_bytes(), str::as_bytes);
13960    let hello_frame = timeout(
13961        Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13962        read_json_frame_limited_body_timeout(
13963            &mut pipe,
13964            MAX_NODE_HELLO_FRAME_BYTES,
13965            Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13966        ),
13967    )
13968    .await
13969    .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13970    let ClientFrame::Hello(hello) = hello_frame else {
13971        return Err(NodeServerError::Handshake("first frame must be hello".to_owned()));
13972    };
13973    if hello.build_stamp != BUILD_STAMP {
13974        return Err(NodeServerError::Handshake(format!(
13975            "build stamp mismatch: local={BUILD_STAMP} remote={}",
13976            hello.build_stamp,
13977        )));
13978    }
13979    let compatibility = match hello.compatibility.as_ref() {
13980        Some(offer) => {
13981            let mut selected = node_compatibility_support(&shared)?
13982                .negotiate(offer)
13983                .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13984            project_negotiated_provider_ids(&mut selected);
13985            Some(selected)
13986        }
13987        None => None,
13988    };
13989    let server_nonce = random_nonce().map_err(NodeServerError::Authentication)?;
13990    let server_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13991        (Some(offer), Some(selected)) => negotiated_auth_proof(
13992            auth_token,
13993            AuthDirection::Server,
13994            hello.role,
13995            &hello.client_nonce,
13996            &server_nonce,
13997            offer,
13998            selected,
13999        ),
14000        (None, None) => auth_proof(
14001            auth_token,
14002            AuthDirection::Server,
14003            hello.role,
14004            &hello.client_nonce,
14005            &server_nonce,
14006        ),
14007        _ => Err("node compatibility authentication state is inconsistent".to_owned()),
14008    }
14009    .map_err(NodeServerError::Authentication)?;
14010    write_json_frame_limited(
14011        &mut pipe,
14012        &ServerFrame::Challenge(ServerChallenge {
14013            build_stamp: BUILD_STAMP.to_owned(),
14014            server_nonce,
14015            server_proof,
14016            compatibility: compatibility.clone(),
14017        }),
14018        MAX_NODE_HELLO_FRAME_BYTES,
14019    )
14020    .await?;
14021    let authentication = timeout(
14022        Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
14023        read_json_frame_limited_body_timeout(
14024            &mut pipe,
14025            MAX_NODE_HELLO_FRAME_BYTES,
14026            Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
14027        ),
14028    )
14029    .await
14030    .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
14031    let ClientFrame::Authenticate(authentication) = authentication else {
14032        return Err(NodeServerError::Handshake("second frame must authenticate the challenge".to_owned()));
14033    };
14034    let expected_client_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
14035        (Some(offer), Some(selected)) => negotiated_auth_proof(
14036            auth_token,
14037            AuthDirection::Client,
14038            hello.role,
14039            &hello.client_nonce,
14040            &server_nonce,
14041            offer,
14042            selected,
14043        ),
14044        (None, None) => auth_proof(
14045            auth_token,
14046            AuthDirection::Client,
14047            hello.role,
14048            &hello.client_nonce,
14049            &server_nonce,
14050        ),
14051        _ => Err("node compatibility authentication state is inconsistent".to_owned()),
14052    }
14053    .map_err(NodeServerError::Authentication)?;
14054    if !proofs_match(&authentication.client_proof, &expected_client_proof) {
14055        return Err(NodeServerError::Handshake("access denied".to_owned()));
14056    }
14057    let selected_capabilities = compatibility
14058        .as_ref()
14059        .map(|selected| selected.capabilities.clone())
14060        .unwrap_or_default();
14061    let include_provider_runtime_status = selected_capabilities.iter().any(|capability| {
14062        capability.as_str() == NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY
14063    });
14064    let include_open_provider_ids = selected_capabilities.iter().any(|capability| {
14065        capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
14066    });
14067    let include_terminal_frame_events = selected_capabilities.iter().any(|capability| {
14068        capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
14069    });
14070    let include_agent_stream_events = selected_capabilities.iter().any(|capability| {
14071        capability.as_str() == NODE_AGENT_STREAM_EVENTS_CAPABILITY
14072    });
14073    let include_spawn_profiles = selected_capabilities.iter().any(|capability| {
14074        capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
14075    });
14076    let include_managed_worktrees = selected_capabilities.iter().any(|capability| {
14077        capability.as_str() == NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY
14078    }) && selected_capabilities.iter().any(|capability| {
14079        capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
14080    });
14081    let include_child_environment_profiles = selected_capabilities.iter().any(|capability| {
14082        capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
14083    });
14084    let include_session_bundles = selected_capabilities.iter().any(|capability| {
14085        capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
14086    });
14087    let include_history_context_packs = selected_capabilities.iter().any(|capability| {
14088        capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
14089    });
14090    let include_agent_progress = selected_capabilities.iter().any(|capability| {
14091        capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
14092    });
14093    let include_session_task_correlation = selected_capabilities.iter().any(|capability| {
14094        capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
14095    });
14096    let include_harness_mcp_proxy = selected_capabilities.iter().any(|capability| {
14097        capability.as_str() == NODE_HARNESS_MCP_READ_PROXY_CAPABILITY
14098    });
14099    let authenticated_permit = Arc::clone(&shared.authenticated_slots)
14100        .try_acquire_owned()
14101        .map_err(|_| NodeServerError::AuthenticatedConnectionLimit)?;
14102    let connection_id = shared.next_connection_id.fetch_add(1, Ordering::AcqRel);
14103    let _release_guard = ControllerReleaseGuard {
14104        shared: Arc::clone(&shared),
14105        connection_id,
14106    };
14107    drop(preauth_permit);
14108    let _authenticated_permit = authenticated_permit;
14109    let mut event_rx = shared.event_tx.subscribe();
14110    let mut terminal_event_rx = terminal_event_subscription(
14111        &shared,
14112        include_terminal_frame_events,
14113    );
14114    let mut agent_stream_event_rx = agent_stream_event_subscription(
14115        &shared,
14116        include_agent_stream_events,
14117    );
14118    let mut harness_mcp_proxy_event_rx = harness_mcp_proxy_event_subscription(
14119        &shared,
14120        include_harness_mcp_proxy,
14121    );
14122    write_json_frame(
14123        &mut pipe,
14124        &ServerFrame::Hello(NodeHello {
14125            build_stamp: BUILD_STAMP.to_owned(),
14126            incarnation_id: shared.incarnation_id,
14127            connection_id,
14128            role: hello.role,
14129            event_sequence: shared.current_sequence(),
14130            controller: shared.controller_state(),
14131            snapshot: snapshot_for_wire(
14132                &shared,
14133                include_provider_runtime_status,
14134                include_open_provider_ids,
14135                include_spawn_profiles,
14136                include_managed_worktrees,
14137                include_child_environment_profiles,
14138                include_session_bundles,
14139                include_history_context_packs,
14140                include_agent_progress,
14141                include_session_task_correlation,
14142            ),
14143            compatibility,
14144        }),
14145    )
14146    .await?;
14147    let (mut reader, mut writer) = tokio::io::split(pipe);
14148    let (frame_tx, mut frame_rx) = mpsc::channel(16);
14149    let reader_task = tokio::spawn(async move {
14150        loop {
14151            let frame = read_json_frame_limited_body_timeout::<_, ClientFrame>(
14152                &mut reader,
14153                MAX_NODE_CLIENT_FRAME_BYTES,
14154                Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
14155            )
14156            .await;
14157            let terminal = frame.is_err();
14158            if frame_tx.send(frame).await.is_err() || terminal {
14159                break;
14160            }
14161        }
14162    });
14163    let _reader_abort = AbortTaskOnDrop(reader_task.abort_handle());
14164    let mut pending_events = Vec::new();
14165    let mut resync_required = false;
14166    let mut discard_events_through = 0_u64;
14167    loop {
14168        // One tick of the serve loop, whether or not it had anything to do.
14169        // Against `connection_events_sent` this separates "busy" from
14170        // "spinning": a loop turning over thousands of times a second while
14171        // sending nothing is not serving, it is burning a core.
14172        shared.connection_loop_iterations.fetch_add(1, Ordering::Relaxed);
14173        let durable_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
14174            .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
14175        for _ in 0..durable_drain_budget {
14176            match event_rx.try_recv() {
14177                Ok(event) => queue_connection_event(
14178                    &mut pending_events,
14179                    event,
14180                    discard_events_through,
14181                ),
14182                Err(broadcast::error::TryRecvError::Empty) => break,
14183                Err(broadcast::error::TryRecvError::Lagged(_)) => {
14184                    resync_required = true;
14185                    break;
14186                }
14187                Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14188            }
14189        }
14190        if let Some(receiver) = terminal_event_rx.as_mut() {
14191            match receiver.try_recv() {
14192                Ok(events) => queue_connection_event_batch(
14193                    &mut pending_events,
14194                    &events,
14195                    discard_events_through,
14196                ),
14197                Err(broadcast::error::TryRecvError::Empty) => {}
14198                Err(broadcast::error::TryRecvError::Lagged(_)) => {
14199                    resync_required = true;
14200                }
14201                Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14202            }
14203        }
14204        if let Some(receiver) = agent_stream_event_rx.as_mut() {
14205            // Loops up to the same remaining-room budget the durable
14206            // `event_rx` drain above uses, rather than one `try_recv` per
14207            // tick: each successful receive here is itself only ONE
14208            // `NodeEventEnvelope` batch (`publish_agent_stream_chunk` always
14209            // sends `vec![envelope]`, never more), so a single-shot drain
14210            // caps this connection's agent-stream throughput at one chunk
14211            // per outer-loop tick regardless of how many are already queued
14212            // -- exactly the kind of self-inflicted stall this crate's own
14213            // durable-event budget exists to avoid.
14214            let agent_stream_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
14215                .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
14216            for _ in 0..agent_stream_drain_budget {
14217                match receiver.try_recv() {
14218                    Ok(events) => queue_connection_event_batch(
14219                        &mut pending_events,
14220                        &events,
14221                        discard_events_through,
14222                    ),
14223                    Err(broadcast::error::TryRecvError::Empty) => break,
14224                    Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
14225                        tracing::warn!(
14226                            connection_id,
14227                            dropped,
14228                            "agent stream event subscription lagged; forcing a resync",
14229                        );
14230                        resync_required = true;
14231                        break;
14232                    }
14233                    Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14234                }
14235            }
14236        }
14237        if let Some(receiver) = harness_mcp_proxy_event_rx.as_mut() {
14238            // Mirrors the agent-stream drain immediately above: each
14239            // successful receive is one single-envelope batch
14240            // (`publish_transient` always sends `vec![envelope]`), so this
14241            // loops up to the same remaining-room budget rather than
14242            // stopping after one `try_recv`.
14243            let harness_mcp_proxy_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
14244                .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
14245            for _ in 0..harness_mcp_proxy_drain_budget {
14246                match receiver.try_recv() {
14247                    Ok(events) => queue_connection_event_batch(
14248                        &mut pending_events,
14249                        &events,
14250                        discard_events_through,
14251                    ),
14252                    Err(broadcast::error::TryRecvError::Empty) => break,
14253                    Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
14254                        tracing::warn!(
14255                            connection_id,
14256                            dropped,
14257                            "harness MCP proxy event subscription lagged; forcing a resync",
14258                        );
14259                        resync_required = true;
14260                        break;
14261                    }
14262                    Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14263                }
14264            }
14265        }
14266        if resync_required {
14267            let event = resync_required_event(&shared);
14268            apply_connection_resync_watermark(
14269                &mut pending_events,
14270                &mut discard_events_through,
14271                event.sequence,
14272            );
14273            write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
14274            resync_required = false;
14275            continue;
14276        }
14277        if !pending_events.is_empty() {
14278            pending_events.sort_unstable_by_key(|event| event.sequence);
14279            let burst_len = connection_event_burst_len(pending_events.len());
14280            shared
14281                .connection_events_sent
14282                .fetch_add(burst_len as u64, Ordering::Relaxed);
14283            for event in pending_events.drain(..burst_len) {
14284                let event = if include_managed_worktrees {
14285                    Some(event)
14286                } else {
14287                    project_event_without_managed_worktrees(event)
14288                };
14289                let event = event.and_then(|event| if include_open_provider_ids {
14290                    Some(event)
14291                } else {
14292                    project_event_legacy_provider_ids(&shared, event)
14293                });
14294                let event = event.map(|event| if include_child_environment_profiles {
14295                    event
14296                } else {
14297                    project_event_without_child_environment_profile(event)
14298                });
14299                let event = event.map(|event| if include_session_bundles {
14300                    event
14301                } else {
14302                    project_event_without_session_bundle(event)
14303                });
14304                let event = event.map(|event| {
14305                    project_event_history_for_wire(event, include_history_context_packs)
14306                });
14307                let event = event.map(|event| if include_history_context_packs {
14308                    event
14309                } else {
14310                    project_event_without_context_pack(event)
14311                });
14312                let event = event.map(|event| if include_session_task_correlation {
14313                    event
14314                } else {
14315                    project_event_without_session_task_binding(event)
14316                });
14317                let exact_harness_controller = hello.role == ClientRole::Operator
14318                    && shared.controller_state().is_some_and(|controller| {
14319                        controller.connection_id == connection_id
14320                    });
14321                let event = event.and_then(|event| {
14322                    if include_harness_mcp_proxy && exact_harness_controller {
14323                        Some(event)
14324                    } else {
14325                        project_event_without_harness_mcp_proxy(event)
14326                    }
14327                });
14328                if let Some(event) = event {
14329                    write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
14330                }
14331            }
14332        }
14333        tokio::select! {
14334            biased;
14335            frame = frame_rx.recv() => {
14336                let frame = match frame {
14337                    Some(frame) => frame,
14338                    None => break,
14339                };
14340                let frame = match frame {
14341                    Ok(frame) => frame,
14342                    Err(FrameError::Io(error)) if matches!(error.kind(), io::ErrorKind::BrokenPipe | io::ErrorKind::UnexpectedEof) => break,
14343                    Err(error) => return Err(error.into()),
14344                };
14345                let ClientFrame::Request(request) = frame else {
14346                    return Err(NodeServerError::Handshake("hello may only be sent once".to_owned()));
14347                };
14348                #[cfg(feature = "fixture")]
14349                let probe_spawn_managed_worktree_v2 = matches!(
14350                    &request.request,
14351                    NodeRequest::SpawnManagedWorktreeV2 { .. }
14352                );
14353                let requires_open_provider_ids =
14354                    request_requires_open_provider_ids(&shared, &request.request);
14355                let requires_child_environment_profile =
14356                    request_requires_child_environment_profile(&shared, &request.request);
14357                let requires_session_bundle =
14358                    request_requires_session_bundle(&shared, &request.request);
14359                let requires_history_context_pack =
14360                    request_requires_history_context_pack(&shared, &request.request);
14361                let mut reply = if requires_open_provider_ids && !include_open_provider_ids {
14362                    ResponseEnvelope {
14363                        request_id: request.request_id,
14364                        result: Err(failure(
14365                            NodeFailureCode::UnsupportedCapability,
14366                            "open provider IDs were not negotiated",
14367                        )),
14368                    }
14369                } else if requires_child_environment_profile
14370                    && !include_child_environment_profiles
14371                {
14372                    ResponseEnvelope {
14373                        request_id: request.request_id,
14374                        result: Err(failure(
14375                            NodeFailureCode::UnsupportedCapability,
14376                            "child environment profiles were not negotiated",
14377                        )),
14378                    }
14379                } else if requires_session_bundle && !include_session_bundles {
14380                    ResponseEnvelope {
14381                        request_id: request.request_id,
14382                        result: Err(failure(
14383                            NodeFailureCode::UnsupportedCapability,
14384                            "session bundles were not negotiated",
14385                        )),
14386                    }
14387                } else if requires_history_context_pack && !include_history_context_packs {
14388                    ResponseEnvelope {
14389                        request_id: request.request_id,
14390                        result: Err(failure(
14391                            NodeFailureCode::UnsupportedCapability,
14392                            "history context packs were not negotiated",
14393                        )),
14394                    }
14395                } else if request_uses_unnegotiated_capability(
14396                    &request.request,
14397                    &selected_capabilities,
14398                )
14399                {
14400                    ResponseEnvelope {
14401                        request_id: request.request_id,
14402                        result: Err(failure(
14403                            NodeFailureCode::UnsupportedCapability,
14404                            "request capability was not negotiated",
14405                        )),
14406                    }
14407                } else {
14408                    process_request(&shared, connection_id, hello.role, request).await
14409                };
14410                #[cfg(feature = "fixture")]
14411                if probe_spawn_managed_worktree_v2 {
14412                    shared
14413                        .fixture_spawn_managed_worktree_v2_failure_probe
14414                        .record(reply.result.as_ref().err().map(|failure| failure.code));
14415                }
14416                if !include_provider_runtime_status {
14417                    clear_response_provider_runtime_status(&mut reply);
14418                }
14419                if !include_managed_worktrees {
14420                    project_response_without_managed_worktrees(&mut reply);
14421                }
14422                if !include_open_provider_ids {
14423                    project_response_legacy_provider_ids(&shared, &mut reply);
14424                }
14425                if !include_child_environment_profiles {
14426                    project_response_without_child_environment_profile(&mut reply);
14427                }
14428                if !include_session_bundles {
14429                    project_response_without_session_bundle(&mut reply);
14430                }
14431                project_response_history_for_wire(
14432                    &mut reply,
14433                    include_history_context_packs,
14434                );
14435                if !include_history_context_packs {
14436                    project_response_without_context_pack(&mut reply);
14437                }
14438                if !include_agent_progress {
14439                    project_response_without_agent_progress(&mut reply);
14440                }
14441                if !include_session_task_correlation {
14442                    project_response_without_session_task_binding(&mut reply);
14443                }
14444                write_json_frame(&mut writer, &ServerFrame::Reply(reply)).await?;
14445            }
14446            _ = tokio::task::yield_now(), if !pending_events.is_empty() => {}
14447            event = event_rx.recv() => {
14448                match event {
14449                    Ok(event) => queue_connection_event(
14450                        &mut pending_events,
14451                        event,
14452                        discard_events_through,
14453                    ),
14454                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14455                    Err(broadcast::error::RecvError::Closed) => break,
14456                }
14457            }
14458            events = receive_terminal_event_batch(&mut terminal_event_rx) => {
14459                match events {
14460                    Ok(events) => queue_connection_event_batch(
14461                        &mut pending_events,
14462                        &events,
14463                        discard_events_through,
14464                    ),
14465                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14466                    Err(broadcast::error::RecvError::Closed) => break,
14467                }
14468            }
14469            events = receive_terminal_event_batch(&mut agent_stream_event_rx) => {
14470                match events {
14471                    Ok(events) => queue_connection_event_batch(
14472                        &mut pending_events,
14473                        &events,
14474                        discard_events_through,
14475                    ),
14476                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14477                    Err(broadcast::error::RecvError::Closed) => break,
14478                }
14479            }
14480            events = receive_terminal_event_batch(&mut harness_mcp_proxy_event_rx) => {
14481                match events {
14482                    Ok(events) => queue_connection_event_batch(
14483                        &mut pending_events,
14484                        &events,
14485                        discard_events_through,
14486                    ),
14487                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14488                    Err(broadcast::error::RecvError::Closed) => break,
14489                }
14490            }
14491            _ = shared.shutdown_notify.notified() => break,
14492        }
14493    }
14494    Ok(())
14495}
14496
14497async fn receive_terminal_event_batch(
14498    receiver: &mut Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>>,
14499) -> Result<Arc<Vec<NodeEventEnvelope>>, broadcast::error::RecvError> {
14500    match receiver {
14501        Some(receiver) => receiver.recv().await,
14502        None => std::future::pending().await,
14503    }
14504}
14505
14506fn terminal_event_subscription(
14507    shared: &NodeShared,
14508    enabled: bool,
14509) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14510    enabled.then(|| shared.terminal_event_tx.subscribe())
14511}
14512
14513/// Mirrors `terminal_event_subscription` exactly, over the agent stream's
14514/// own channel -- see `NodeShared::agent_stream_event_tx`'s own doc for why
14515/// this needs a dedicated channel rather than a flag on the terminal one.
14516fn agent_stream_event_subscription(
14517    shared: &NodeShared,
14518    enabled: bool,
14519) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14520    enabled.then(|| shared.agent_stream_event_tx.subscribe())
14521}
14522
14523/// Mirrors `agent_stream_event_subscription` exactly, over the harness MCP
14524/// proxy's own channel -- see `NodeShared::harness_mcp_proxy_event_tx`'s
14525/// own doc for why this needs a dedicated channel rather than a ride on
14526/// the durable `event_tx`.
14527fn harness_mcp_proxy_event_subscription(
14528    shared: &NodeShared,
14529    enabled: bool,
14530) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14531    enabled.then(|| shared.harness_mcp_proxy_event_tx.subscribe())
14532}
14533
14534fn queue_connection_event(
14535    pending: &mut Vec<NodeEventEnvelope>,
14536    event: NodeEventEnvelope,
14537    discard_through: u64,
14538) {
14539    if event.sequence <= discard_through
14540        && !event.event.requires_harness_mcp_proxy_capability()
14541    {
14542        return;
14543    }
14544    if let NodeEvent::TerminalFrame { address, .. } = &event.event {
14545        if let Some(index) = pending.iter().position(|current| {
14546            matches!(
14547                &current.event,
14548                NodeEvent::TerminalFrame {
14549                    address: current_address,
14550                    ..
14551                } if current_address == address
14552            )
14553        }) {
14554            pending.remove(index);
14555        }
14556    }
14557    pending.push(event);
14558}
14559
14560fn project_event_without_harness_mcp_proxy(
14561    envelope: NodeEventEnvelope,
14562) -> Option<NodeEventEnvelope> {
14563    (!envelope.event.requires_harness_mcp_proxy_capability()).then_some(envelope)
14564}
14565
14566fn queue_connection_event_batch(
14567    pending: &mut Vec<NodeEventEnvelope>,
14568    events: &[NodeEventEnvelope],
14569    discard_through: u64,
14570) {
14571    for event in events {
14572        queue_connection_event(pending, event.clone(), discard_through);
14573    }
14574}
14575
14576fn apply_connection_resync_watermark(
14577    pending: &mut Vec<NodeEventEnvelope>,
14578    discard_through: &mut u64,
14579    marker_sequence: u64,
14580) {
14581    *discard_through = (*discard_through).max(marker_sequence);
14582    pending.retain(|event| event.sequence > *discard_through);
14583}
14584
14585fn connection_event_burst_len(pending_len: usize) -> usize {
14586    pending_len.min(NODE_CONNECTION_EVENT_BURST_MAX)
14587}
14588
14589fn resync_required_event(shared: &NodeShared) -> NodeEventEnvelope {
14590    let history = shared
14591        .history
14592        .lock()
14593        .unwrap_or_else(|poisoned| poisoned.into_inner());
14594    let sequence = history.last_sequence;
14595    let oldest_available_sequence = history.replay_floor_sequence;
14596    NodeEventEnvelope {
14597        sequence,
14598        event: NodeEvent::ResyncRequired {
14599            oldest_available_sequence,
14600        },
14601    }
14602}
14603
14604fn node_compatibility_support(
14605    shared: &NodeShared,
14606) -> Result<NodeCompatibilitySupport, NodeServerError> {
14607    let mut support = node_compatibility_support_for_manifest(
14608        &shared.provider_contracts,
14609        &shared.provider_adapter_contracts,
14610    )?;
14611    if shared
14612        .delivery_store
14613        .lock()
14614        .unwrap_or_else(|poisoned| poisoned.into_inner())
14615        .is_some()
14616    {
14617        support.capabilities.push(
14618            CapabilityId::new(NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY)
14619                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14620        );
14621    }
14622    if shared.harness_mcp_registry.is_some() {
14623        support.capabilities.push(
14624            CapabilityId::new(NODE_HARNESS_MCP_READ_PROXY_CAPABILITY)
14625                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14626        );
14627    }
14628    Ok(support)
14629}
14630
14631fn node_compatibility_support_for_manifest(
14632    provider_contracts: &[ProviderContractSupport],
14633    provider_adapter_contracts: &[ProviderAdapterContractSupport],
14634) -> Result<NodeCompatibilitySupport, NodeServerError> {
14635    Ok(NodeCompatibilitySupport {
14636        build_stamp: BUILD_STAMP.to_owned(),
14637        capabilities: baseline_capabilities()?,
14638        host: platform::host_descriptor().map_err(NodeServerError::Handshake)?,
14639        path_semantics: platform::path_semantics(),
14640        local_transport: platform::local_transport(),
14641        state_schema: StateSchemaSupport {
14642            versions: ProtocolRange::new(NODE_STATE_SCHEMA_V1, NODE_STATE_SCHEMA_V10)
14643                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14644        },
14645        provider_contracts: provider_contracts.to_vec(),
14646        provider_adapter_contracts: provider_adapter_contracts.to_vec(),
14647    })
14648}
14649
14650fn baseline_capabilities() -> Result<Vec<CapabilityId>, NodeServerError> {
14651    let capabilities = [
14652        NODE_COMPATIBILITY_METADATA_CAPABILITY,
14653        CAPABILITY_HOST_DIRECTORY_BROWSE_V1,
14654        NODE_REPOSITORY_PATH_CAPABILITY,
14655        NODE_WORKSPACE_FILE_READ_CAPABILITY,
14656        NODE_WORKSPACE_FILE_WRITE_CAPABILITY,
14657        NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY,
14658        NODE_GIT_READ_CAPABILITY,
14659        NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY,
14660        NODE_PROVIDER_ID_OPEN_CAPABILITY,
14661        NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
14662        NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY,
14663        NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
14664        NODE_AGENT_STREAM_EVENTS_CAPABILITY,
14665        NODE_ACP_CONTROL_CAPABILITY,
14666        NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
14667        NODE_SPAWN_PROFILE_REVISION_CAPABILITY,
14668        NODE_WORKTREE_SELECTION_CAPABILITY,
14669        NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
14670        NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
14671        NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
14672        NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
14673        NODE_HISTORY_CONTEXT_PACK_CAPABILITY,
14674        NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
14675        NODE_NATIVE_SESSION_CATALOG_CAPABILITY,
14676        NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
14677        NODE_NATIVE_SESSION_INDEX_CAPABILITY,
14678        NODE_NATIVE_SESSION_PREVIEW_CAPABILITY,
14679        NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
14680        NODE_SESSION_TASK_CORRELATION_CAPABILITY,
14681    ]
14682        .into_iter()
14683        .map(|capability| {
14684            CapabilityId::new(capability)
14685                .map_err(|error| NodeServerError::Handshake(error.to_string()))
14686        })
14687        .collect::<Result<Vec<_>, _>>()?;
14688    #[cfg(windows)]
14689    let capabilities = {
14690        let mut capabilities = capabilities;
14691        capabilities.push(
14692            CapabilityId::new(NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY)
14693                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14694        );
14695        capabilities
14696    };
14697    Ok(capabilities)
14698}
14699
14700fn request_uses_unnegotiated_capability(
14701    request: &NodeRequest,
14702    selected_capabilities: &[CapabilityId],
14703) -> bool {
14704    let selected = |required: &str| {
14705        selected_capabilities
14706            .iter()
14707            .any(|capability| capability.as_str() == required)
14708    };
14709    request
14710        .required_capability()
14711        .is_some_and(|required| !selected(required))
14712        || (request.requires_worktree_selection_capability()
14713            && !selected(NODE_WORKTREE_SELECTION_CAPABILITY))
14714        || (request.requires_spawn_spec_defaults_overrides_capability()
14715            && !selected(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY))
14716        || (request.requires_spawn_profile_revision_capability()
14717            && !selected(NODE_SPAWN_PROFILE_REVISION_CAPABILITY))
14718        || (request.requires_child_environment_profile_capability()
14719            && !selected(NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY))
14720        || (request.requires_session_bundle_materialization_capability()
14721            && !selected(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY))
14722        || (request.requires_history_context_pack_capability()
14723            && !selected(NODE_HISTORY_CONTEXT_PACK_CAPABILITY))
14724}
14725
14726fn snapshot_for_wire(
14727    shared: &NodeShared,
14728    include_provider_runtime_status: bool,
14729    include_open_provider_ids: bool,
14730    include_spawn_profiles: bool,
14731    include_managed_worktrees: bool,
14732    include_child_environment_profiles: bool,
14733    include_session_bundles: bool,
14734    include_history_context_packs: bool,
14735    include_agent_progress: bool,
14736    include_session_task_correlation: bool,
14737) -> NodeSnapshot {
14738    let mut snapshot = shared.snapshot();
14739    if !include_provider_runtime_status {
14740        snapshot.provider_runtime_statuses.clear();
14741    }
14742    if !include_open_provider_ids {
14743        project_snapshot_legacy_provider_ids(&mut snapshot);
14744    }
14745    if !include_managed_worktrees {
14746        snapshot.managed_worktrees.clear();
14747        for workspace in &mut snapshot.workspaces {
14748            workspace.managed_worktree_profiles = None;
14749        }
14750    }
14751    if let Some(inventory) = snapshot.launch_inventory.as_mut() {
14752        if !include_spawn_profiles {
14753            inventory.spawn_profiles = None;
14754        }
14755        if !include_session_bundles {
14756            inventory.bundles = None;
14757        }
14758        if inventory.spawn_profiles.is_none()
14759            && inventory.bundles.is_none()
14760            && inventory.network_allowlists.is_none()
14761        {
14762            snapshot.launch_inventory = None;
14763        }
14764    }
14765    if !include_child_environment_profiles {
14766        clear_snapshot_child_environment_profiles(&mut snapshot);
14767    }
14768    if !include_session_bundles {
14769        clear_snapshot_session_bundles(&mut snapshot);
14770    }
14771    project_snapshot_history_for_wire(&mut snapshot, include_history_context_packs);
14772    if !include_history_context_packs {
14773        clear_snapshot_context_packs(&mut snapshot);
14774    }
14775    if !include_agent_progress {
14776        snapshot.agent_progress.clear();
14777    }
14778    if !include_session_task_correlation {
14779        clear_snapshot_session_task_bindings(&mut snapshot);
14780    }
14781    snapshot
14782}
14783
14784fn clear_snapshot_session_task_bindings(snapshot: &mut NodeSnapshot) {
14785    for record in &mut snapshot.session_records {
14786        record.task_binding = None;
14787    }
14788}
14789
14790fn project_event_without_session_task_binding(
14791    mut envelope: NodeEventEnvelope,
14792) -> NodeEventEnvelope {
14793    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14794        record.task_binding = None;
14795    }
14796    envelope
14797}
14798
14799fn project_response_without_session_task_binding(reply: &mut ResponseEnvelope) {
14800    let Ok(response) = reply.result.as_mut() else { return };
14801    match response {
14802        NodeResponse::Snapshot { snapshot, .. } => {
14803            clear_snapshot_session_task_bindings(snapshot);
14804        }
14805        NodeResponse::Resync { snapshot, events, .. } => {
14806            clear_snapshot_session_task_bindings(snapshot);
14807            for event in events {
14808                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14809                    record.task_binding = None;
14810                }
14811            }
14812        }
14813        NodeResponse::SessionRecordUpdated { record }
14814        | NodeResponse::ProviderSessionIndexed { record }
14815        | NodeResponse::NativeSessionIndexed { record, .. }
14816        | NodeResponse::SessionRecordResumed { record, .. } => {
14817            record.task_binding = None;
14818        }
14819        _ => {}
14820    }
14821}
14822
14823fn project_response_without_agent_progress(reply: &mut ResponseEnvelope) {
14824    let Ok(response) = reply.result.as_mut() else { return };
14825    match response {
14826        NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
14827            snapshot.agent_progress.clear();
14828        }
14829        _ => {}
14830    }
14831}
14832
14833fn project_event_without_managed_worktrees(
14834    mut envelope: NodeEventEnvelope,
14835) -> Option<NodeEventEnvelope> {
14836    if matches!(
14837        envelope.event,
14838        NodeEvent::ManagedWorktreeUpserted { .. }
14839            | NodeEvent::ManagedWorktreeRemoved { .. }
14840    ) {
14841        return None;
14842    }
14843    if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
14844        workspace.managed_worktree_profiles = None;
14845    }
14846    Some(envelope)
14847}
14848
14849fn project_response_without_managed_worktrees(reply: &mut ResponseEnvelope) {
14850    if matches!(
14851        reply.result,
14852        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { .. })
14853            | Ok(NodeResponse::ManagedWorktreeCleanup { .. })
14854    ) {
14855        reply.result = Err(failure(
14856            NodeFailureCode::UnsupportedCapability,
14857            "managed worktree capability was not negotiated",
14858        ));
14859        return;
14860    }
14861    let Ok(response) = reply.result.as_mut() else { return };
14862    match response {
14863        NodeResponse::Snapshot { snapshot, .. } => {
14864            snapshot.managed_worktrees.clear();
14865            for workspace in &mut snapshot.workspaces {
14866                workspace.managed_worktree_profiles = None;
14867            }
14868        }
14869        NodeResponse::Resync { snapshot, events, .. } => {
14870            snapshot.managed_worktrees.clear();
14871            for workspace in &mut snapshot.workspaces {
14872                workspace.managed_worktree_profiles = None;
14873            }
14874            *events = std::mem::take(events)
14875                .into_iter()
14876                .filter_map(project_event_without_managed_worktrees)
14877                .collect();
14878        }
14879        NodeResponse::WorkspaceRegistered { workspace }
14880        | NodeResponse::StandaloneWorkspaceCreated { workspace }
14881        | NodeResponse::WorktreeCreated { workspace, .. } => {
14882            workspace.managed_worktree_profiles = None;
14883        }
14884        NodeResponse::WorkspaceInspected { inspection } => {
14885            inspection.git.managed_worktree = None;
14886        }
14887        _ => {}
14888    }
14889}
14890
14891fn project_negotiated_provider_ids(compatibility: &mut NegotiatedNodeCompatibility) {
14892    let includes_open_provider_ids = compatibility.capabilities.iter().any(|capability| {
14893        capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
14894    });
14895    if includes_open_provider_ids {
14896        return;
14897    }
14898    compatibility
14899        .provider_contracts
14900        .retain(|contract| provider_id_is_legacy(&contract.provider));
14901    compatibility
14902        .provider_adapter_contracts
14903        .retain(|contract| provider_id_is_legacy(&contract.provider));
14904}
14905
14906fn request_requires_open_provider_ids(shared: &NodeShared, request: &NodeRequest) -> bool {
14907    if let Some(spec) = match request {
14908        NodeRequest::SpawnSpec { spec }
14909        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14910        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14911        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14912        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14913        _ => None,
14914    } {
14915        return shared
14916            .resolve_spawn_spec(spec)
14917            .is_ok_and(|resolved| !provider_id_is_legacy(&resolved.provider));
14918    }
14919    request_requires_open_provider_ids_with(
14920        request,
14921        |session| shared.validate_address(session).ok(),
14922        |record_id| shared.record(record_id).ok().map(|record| record.provider),
14923    )
14924}
14925
14926fn request_requires_child_environment_profile(
14927    shared: &NodeShared,
14928    request: &NodeRequest,
14929) -> bool {
14930    if let Some(spec) = match request {
14931        NodeRequest::SpawnSpec { spec }
14932        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14933        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14934        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14935        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14936        _ => None,
14937    } {
14938        return shared
14939            .resolve_spawn_spec(spec)
14940            .is_ok_and(|resolved| resolved.environment_profile_id.is_some());
14941    }
14942    match request {
14943        NodeRequest::Resume { session, .. }
14944        | NodeRequest::Prompt { session, .. }
14945        | NodeRequest::Paste { session, .. }
14946        | NodeRequest::Input { session, .. }
14947        | NodeRequest::TerminalBytes { session, .. }
14948        | NodeRequest::TerminalControl { session, .. }
14949        | NodeRequest::Resize { session, .. }
14950        | NodeRequest::Interrupt { session }
14951        | NodeRequest::Stop { session, .. }
14952        | NodeRequest::Remove { session }
14953        | NodeRequest::ResolveInteraction { session, .. }
14954        | NodeRequest::SetSessionMode { session, .. }
14955        | NodeRequest::SetSessionConfigOption { session, .. }
14956        | NodeRequest::SetSessionModel { session, .. }
14957        | NodeRequest::DiscoverHistory { session, .. }
14958        | NodeRequest::LoadHistory { session, .. }
14959        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14960        | NodeRequest::ExportContextPack { session } => shared
14961            .session_bindings
14962            .lock()
14963            .unwrap_or_else(|poisoned| poisoned.into_inner())
14964            .get(&session.session.instance_id)
14965            .filter(|binding| {
14966                binding.workspace_id == session.workspace_id
14967                    && binding.generation == session.session.generation
14968            })
14969            .map_or(true, |binding| binding.environment_profile.is_some()),
14970        NodeRequest::RenameSessionRecord { record_id, .. }
14971        | NodeRequest::SetSessionTask { record_id, .. }
14972        | NodeRequest::ResumeSessionRecord { record_id, .. }
14973        | NodeRequest::ForgetSessionRecord { record_id } => shared
14974            .record(record_id)
14975            .map_or(true, |record| record.environment_profile.is_some()),
14976        NodeRequest::ArmHarnessMcpReservation { .. }
14977        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14978        | NodeRequest::ActivateHarnessMcpReservation { .. }
14979        | NodeRequest::AbortHarnessMcpReservation { .. }
14980        | NodeRequest::PutHarnessMcpReplyChunk { .. }
14981        | NodeRequest::RejectHarnessMcpCall { .. }
14982        | NodeRequest::Snapshot
14983        | NodeRequest::Resync { .. }
14984        | NodeRequest::BeginDeliveryStage { .. }
14985        | NodeRequest::PutDeliveryBlobChunk { .. }
14986        | NodeRequest::CommitDeliveryStage { .. }
14987        | NodeRequest::AbortDeliveryStage { .. }
14988        | NodeRequest::BrowseHostDirectories { .. }
14989        | NodeRequest::InspectWorkspace { .. }
14990        | NodeRequest::ReadWorkspaceFile { .. }
14991        | NodeRequest::WriteWorkspaceFile { .. }
14992        | NodeRequest::CreateWorkspaceFile { .. }
14993        | NodeRequest::CreateWorkspaceDirectory { .. }
14994        | NodeRequest::ReadGitHistory { .. }
14995        | NodeRequest::ReadGitDiff { .. }
14996        | NodeRequest::AcquireController { .. }
14997        | NodeRequest::ReleaseController
14998        | NodeRequest::Spawn { .. }
14999        | NodeRequest::SpawnWithOptions { .. }
15000        | NodeRequest::SpawnSpec { .. }
15001        | NodeRequest::RegisterWorkspace { .. }
15002        | NodeRequest::CreateStandaloneWorkspace { .. }
15003        | NodeRequest::UnregisterWorkspace { .. }
15004        | NodeRequest::CreateWorktree { .. }
15005        | NodeRequest::RemoveWorktree { .. }
15006        | NodeRequest::SpawnManagedWorktree { .. }
15007        | NodeRequest::SpawnManagedWorktreeV2 { .. }
15008        | NodeRequest::CleanupManagedWorktree { .. }
15009        | NodeRequest::IndexProviderSession { .. }
15010        | NodeRequest::IndexNativeSession { .. }
15011        | NodeRequest::CatalogNativeSessions { .. }
15012        | NodeRequest::PageNativeSessions { .. }
15013        | NodeRequest::PreviewNativeSession { .. }
15014        | NodeRequest::PreviewSessionRecord { .. }
15015        | NodeRequest::ForgetContextPack { .. }
15016        | NodeRequest::ResolveDurableContextPack { .. }
15017        | NodeRequest::ReadContextPack { .. }
15018        | NodeRequest::Shutdown => false,
15019    }
15020}
15021
15022fn request_requires_session_bundle(shared: &NodeShared, request: &NodeRequest) -> bool {
15023    if let Some(spec) = match request {
15024        NodeRequest::SpawnSpec { spec }
15025        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
15026        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
15027        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
15028        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
15029        _ => None,
15030    } {
15031        return shared
15032            .resolve_spawn_spec(spec)
15033            .is_ok_and(|resolved| resolved.bundle_id.is_some());
15034    }
15035    match request {
15036        NodeRequest::Resume { session, .. }
15037        | NodeRequest::Prompt { session, .. }
15038        | NodeRequest::Paste { session, .. }
15039        | NodeRequest::Input { session, .. }
15040        | NodeRequest::TerminalBytes { session, .. }
15041        | NodeRequest::TerminalControl { session, .. }
15042        | NodeRequest::Resize { session, .. }
15043        | NodeRequest::Interrupt { session }
15044        | NodeRequest::Stop { session, .. }
15045        | NodeRequest::Remove { session }
15046        | NodeRequest::ResolveInteraction { session, .. }
15047        | NodeRequest::SetSessionMode { session, .. }
15048        | NodeRequest::SetSessionConfigOption { session, .. }
15049        | NodeRequest::SetSessionModel { session, .. }
15050        | NodeRequest::DiscoverHistory { session, .. }
15051        | NodeRequest::LoadHistory { session, .. }
15052        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
15053        | NodeRequest::ExportContextPack { session } => shared
15054            .session_bindings
15055            .lock()
15056            .unwrap_or_else(|poisoned| poisoned.into_inner())
15057            .get(&session.session.instance_id)
15058            .filter(|binding| {
15059                binding.workspace_id == session.workspace_id
15060                    && binding.generation == session.session.generation
15061            })
15062            .map_or(true, |binding| binding.bundle.is_some()),
15063        NodeRequest::RenameSessionRecord { record_id, .. }
15064        | NodeRequest::SetSessionTask { record_id, .. }
15065        | NodeRequest::ResumeSessionRecord { record_id, .. }
15066        | NodeRequest::ForgetSessionRecord { record_id } => shared
15067            .record(record_id)
15068            .map_or(true, |record| record.bundle.is_some()),
15069        NodeRequest::ArmHarnessMcpReservation { .. }
15070        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
15071        | NodeRequest::ActivateHarnessMcpReservation { .. }
15072        | NodeRequest::AbortHarnessMcpReservation { .. }
15073        | NodeRequest::PutHarnessMcpReplyChunk { .. }
15074        | NodeRequest::RejectHarnessMcpCall { .. }
15075        | NodeRequest::Snapshot
15076        | NodeRequest::Resync { .. }
15077        | NodeRequest::BeginDeliveryStage { .. }
15078        | NodeRequest::PutDeliveryBlobChunk { .. }
15079        | NodeRequest::CommitDeliveryStage { .. }
15080        | NodeRequest::AbortDeliveryStage { .. }
15081        | NodeRequest::BrowseHostDirectories { .. }
15082        | NodeRequest::InspectWorkspace { .. }
15083        | NodeRequest::ReadWorkspaceFile { .. }
15084        | NodeRequest::WriteWorkspaceFile { .. }
15085        | NodeRequest::CreateWorkspaceFile { .. }
15086        | NodeRequest::CreateWorkspaceDirectory { .. }
15087        | NodeRequest::ReadGitHistory { .. }
15088        | NodeRequest::ReadGitDiff { .. }
15089        | NodeRequest::AcquireController { .. }
15090        | NodeRequest::ReleaseController
15091        | NodeRequest::Spawn { .. }
15092        | NodeRequest::SpawnWithOptions { .. }
15093        | NodeRequest::SpawnSpec { .. }
15094        | NodeRequest::RegisterWorkspace { .. }
15095        | NodeRequest::CreateStandaloneWorkspace { .. }
15096        | NodeRequest::UnregisterWorkspace { .. }
15097        | NodeRequest::CreateWorktree { .. }
15098        | NodeRequest::RemoveWorktree { .. }
15099        | NodeRequest::SpawnManagedWorktree { .. }
15100        | NodeRequest::SpawnManagedWorktreeV2 { .. }
15101        | NodeRequest::CleanupManagedWorktree { .. }
15102        | NodeRequest::IndexProviderSession { .. }
15103        | NodeRequest::IndexNativeSession { .. }
15104        | NodeRequest::CatalogNativeSessions { .. }
15105        | NodeRequest::PageNativeSessions { .. }
15106        | NodeRequest::PreviewNativeSession { .. }
15107        | NodeRequest::PreviewSessionRecord { .. }
15108        | NodeRequest::ForgetContextPack { .. }
15109        | NodeRequest::ResolveDurableContextPack { .. }
15110        | NodeRequest::ReadContextPack { .. }
15111        | NodeRequest::Shutdown => false,
15112    }
15113}
15114
15115fn request_requires_history_context_pack(shared: &NodeShared, request: &NodeRequest) -> bool {
15116    if request.requires_history_context_pack_capability() {
15117        return true;
15118    }
15119    if let Some(spec) = match request {
15120        NodeRequest::SpawnSpec { spec }
15121        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
15122        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
15123        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
15124        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
15125        _ => None,
15126    } {
15127        return shared
15128            .resolve_spawn_spec(spec)
15129            .map_or(true, |resolved| resolved.context_id.is_some());
15130    }
15131    match request {
15132        NodeRequest::Resume { session, .. }
15133        | NodeRequest::Prompt { session, .. }
15134        | NodeRequest::Paste { session, .. }
15135        | NodeRequest::Input { session, .. }
15136        | NodeRequest::TerminalBytes { session, .. }
15137        | NodeRequest::TerminalControl { session, .. }
15138        | NodeRequest::Resize { session, .. }
15139        | NodeRequest::Interrupt { session }
15140        | NodeRequest::Stop { session, .. }
15141        | NodeRequest::Remove { session }
15142        | NodeRequest::ResolveInteraction { session, .. }
15143        | NodeRequest::SetSessionMode { session, .. }
15144        | NodeRequest::SetSessionConfigOption { session, .. }
15145        | NodeRequest::SetSessionModel { session, .. } => shared
15146            .session_bindings
15147            .lock()
15148            .unwrap_or_else(|poisoned| poisoned.into_inner())
15149            .get(&session.session.instance_id)
15150            .filter(|binding| {
15151                binding.workspace_id == session.workspace_id
15152                    && binding.generation == session.session.generation
15153            })
15154            .map_or(true, |binding| binding.context.is_some()),
15155        NodeRequest::RenameSessionRecord { record_id, .. }
15156        | NodeRequest::SetSessionTask { record_id, .. }
15157        | NodeRequest::ResumeSessionRecord { record_id, .. }
15158        | NodeRequest::ForgetSessionRecord { record_id } => shared
15159            .record(record_id)
15160            .map_or(true, |record| record.context.is_some()),
15161        NodeRequest::ArmHarnessMcpReservation { .. }
15162        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
15163        | NodeRequest::ActivateHarnessMcpReservation { .. }
15164        | NodeRequest::AbortHarnessMcpReservation { .. }
15165        | NodeRequest::PutHarnessMcpReplyChunk { .. }
15166        | NodeRequest::RejectHarnessMcpCall { .. }
15167        | NodeRequest::Snapshot
15168        | NodeRequest::Resync { .. }
15169        | NodeRequest::BeginDeliveryStage { .. }
15170        | NodeRequest::PutDeliveryBlobChunk { .. }
15171        | NodeRequest::CommitDeliveryStage { .. }
15172        | NodeRequest::AbortDeliveryStage { .. }
15173        | NodeRequest::BrowseHostDirectories { .. }
15174        | NodeRequest::InspectWorkspace { .. }
15175        | NodeRequest::ReadWorkspaceFile { .. }
15176        | NodeRequest::WriteWorkspaceFile { .. }
15177        | NodeRequest::CreateWorkspaceFile { .. }
15178        | NodeRequest::CreateWorkspaceDirectory { .. }
15179        | NodeRequest::ReadGitHistory { .. }
15180        | NodeRequest::ReadGitDiff { .. }
15181        | NodeRequest::AcquireController { .. }
15182        | NodeRequest::ReleaseController
15183        | NodeRequest::Spawn { .. }
15184        | NodeRequest::SpawnWithOptions { .. }
15185        | NodeRequest::SpawnSpec { .. }
15186        | NodeRequest::RegisterWorkspace { .. }
15187        | NodeRequest::CreateStandaloneWorkspace { .. }
15188        | NodeRequest::UnregisterWorkspace { .. }
15189        | NodeRequest::CreateWorktree { .. }
15190        | NodeRequest::RemoveWorktree { .. }
15191        | NodeRequest::SpawnManagedWorktree { .. }
15192        | NodeRequest::SpawnManagedWorktreeV2 { .. }
15193        | NodeRequest::CleanupManagedWorktree { .. }
15194        | NodeRequest::IndexProviderSession { .. }
15195        | NodeRequest::IndexNativeSession { .. }
15196        | NodeRequest::CatalogNativeSessions { .. }
15197        | NodeRequest::PageNativeSessions { .. }
15198        | NodeRequest::PreviewNativeSession { .. }
15199        | NodeRequest::PreviewSessionRecord { .. }
15200        | NodeRequest::DiscoverHistory { .. }
15201        | NodeRequest::LoadHistory { .. }
15202        | NodeRequest::ExportContextPackForSessionRecord { .. }
15203        | NodeRequest::ExportContextPack { .. }
15204        | NodeRequest::ForgetContextPack { .. }
15205        | NodeRequest::ResolveDurableContextPack { .. }
15206        | NodeRequest::ReadContextPack { .. }
15207        | NodeRequest::Shutdown => false,
15208    }
15209}
15210
15211fn request_requires_open_provider_ids_with(
15212    request: &NodeRequest,
15213    provider_for_session: impl Fn(&SessionAddress) -> Option<AgentId>,
15214    provider_for_record: impl Fn(&SessionRecordId) -> Option<AgentId>,
15215) -> bool {
15216    match request {
15217        NodeRequest::Spawn { provider, .. } | NodeRequest::SpawnWithOptions { provider, .. } => !provider_id_is_legacy(provider),
15218        NodeRequest::IndexProviderSession { provider, .. } => !provider_id_is_legacy(provider),
15219        NodeRequest::CatalogNativeSessions { route, .. }
15220        | NodeRequest::PageNativeSessions { route, .. } => {
15221            !provider_id_is_legacy(&route.provider)
15222        }
15223        NodeRequest::PreviewNativeSession { selection, .. }
15224        | NodeRequest::IndexNativeSession { selection, .. } => {
15225            !provider_id_is_legacy(&selection.route.provider)
15226        }
15227        NodeRequest::SpawnSpec { .. }
15228        | NodeRequest::SpawnSpecWithHarnessMcp { .. } => false,
15229        NodeRequest::SpawnManagedWorktree { .. }
15230        | NodeRequest::SpawnManagedWorktreeV2 { .. } => false,
15231        NodeRequest::Resume { session, .. }
15232        | NodeRequest::Prompt { session, .. }
15233        | NodeRequest::Paste { session, .. }
15234        | NodeRequest::Input { session, .. }
15235        | NodeRequest::TerminalBytes { session, .. }
15236        | NodeRequest::TerminalControl { session, .. }
15237        | NodeRequest::Resize { session, .. }
15238        | NodeRequest::Interrupt { session }
15239        | NodeRequest::Stop { session, .. }
15240        | NodeRequest::Remove { session }
15241        | NodeRequest::ResolveInteraction { session, .. }
15242        | NodeRequest::SetSessionMode { session, .. }
15243        | NodeRequest::SetSessionConfigOption { session, .. }
15244        | NodeRequest::SetSessionModel { session, .. }
15245        | NodeRequest::DiscoverHistory { session, .. }
15246        | NodeRequest::LoadHistory { session, .. }
15247        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
15248        | NodeRequest::ExportContextPack { session } => provider_for_session(session)
15249            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
15250        NodeRequest::RenameSessionRecord { record_id, .. }
15251        | NodeRequest::SetSessionTask { record_id, .. }
15252        | NodeRequest::ResumeSessionRecord { record_id, .. }
15253        | NodeRequest::ForgetSessionRecord { record_id } => provider_for_record(record_id)
15254            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
15255        NodeRequest::PreviewSessionRecord { record_id, .. } => provider_for_record(record_id)
15256            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
15257        NodeRequest::ArmHarnessMcpReservation { .. }
15258        | NodeRequest::ActivateHarnessMcpReservation { .. }
15259        | NodeRequest::AbortHarnessMcpReservation { .. }
15260        | NodeRequest::PutHarnessMcpReplyChunk { .. }
15261        | NodeRequest::RejectHarnessMcpCall { .. }
15262        | NodeRequest::Snapshot
15263        | NodeRequest::Resync { .. }
15264        | NodeRequest::BeginDeliveryStage { .. }
15265        | NodeRequest::PutDeliveryBlobChunk { .. }
15266        | NodeRequest::CommitDeliveryStage { .. }
15267        | NodeRequest::AbortDeliveryStage { .. }
15268        | NodeRequest::BrowseHostDirectories { .. }
15269        | NodeRequest::InspectWorkspace { .. }
15270        | NodeRequest::ReadWorkspaceFile { .. }
15271        | NodeRequest::WriteWorkspaceFile { .. }
15272        | NodeRequest::CreateWorkspaceFile { .. }
15273        | NodeRequest::CreateWorkspaceDirectory { .. }
15274        | NodeRequest::ReadGitHistory { .. }
15275        | NodeRequest::ReadGitDiff { .. }
15276        | NodeRequest::AcquireController { .. }
15277        | NodeRequest::ReleaseController
15278        | NodeRequest::RegisterWorkspace { .. }
15279        | NodeRequest::CreateStandaloneWorkspace { .. }
15280        | NodeRequest::UnregisterWorkspace { .. }
15281        | NodeRequest::CreateWorktree { .. }
15282        | NodeRequest::RemoveWorktree { .. }
15283        | NodeRequest::CleanupManagedWorktree { .. }
15284        | NodeRequest::Shutdown => false,
15285        NodeRequest::ForgetContextPack { .. } => true,
15286        // No session/record to cheaply resolve a provider from here either;
15287        // the resolved receipt can carry an open-id provider, so require the
15288        // same capability as ForgetContextPack unconditionally. `ReadContextPack`
15289        // is the same digest-keyed lookup one step further into the same
15290        // catalog, so it inherits the same conservative answer.
15291        NodeRequest::ResolveDurableContextPack { .. }
15292        | NodeRequest::ReadContextPack { .. } => true,
15293    }
15294}
15295
15296fn project_snapshot_legacy_provider_ids(snapshot: &mut NodeSnapshot) {
15297    snapshot.enabled_providers.retain(provider_id_is_legacy);
15298    snapshot.provider_runtime_statuses = ProviderRuntimeStatuses::new(
15299        snapshot
15300            .provider_runtime_statuses
15301            .as_slice()
15302            .iter()
15303            .filter(|status| provider_id_is_legacy(status.provider()))
15304            .cloned(),
15305    )
15306    .expect("a filtered provider runtime projection remains valid");
15307    snapshot
15308        .session_records
15309        .retain(|record| provider_id_is_legacy(&record.provider));
15310    for workspace in &mut snapshot.workspaces {
15311        workspace
15312            .sessions
15313            .retain(|session| provider_id_is_legacy(&session.agent_id));
15314    }
15315}
15316
15317fn clear_snapshot_child_environment_profiles(snapshot: &mut NodeSnapshot) {
15318    for record in &mut snapshot.session_records {
15319        record.environment_profile = None;
15320    }
15321    if let Some(profiles) = snapshot
15322        .launch_inventory
15323        .as_mut()
15324        .and_then(|inventory| inventory.spawn_profiles.as_mut())
15325    {
15326        for profile in profiles {
15327            profile.environment_profile = None;
15328        }
15329    }
15330}
15331
15332fn project_event_without_child_environment_profile(
15333    mut envelope: NodeEventEnvelope,
15334) -> NodeEventEnvelope {
15335    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15336        record.environment_profile = None;
15337    }
15338    envelope
15339}
15340
15341fn project_response_without_child_environment_profile(reply: &mut ResponseEnvelope) {
15342    let contains_environment_profile = match reply.result.as_ref() {
15343        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
15344            receipt.environment_profile.is_some()
15345        }
15346        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15347            receipt.spawn.environment_profile.is_some()
15348        }
15349        _ => false,
15350    };
15351    if contains_environment_profile {
15352        reply.result = Err(failure(
15353            NodeFailureCode::UnsupportedCapability,
15354            "child environment profiles were not negotiated",
15355        ));
15356        return;
15357    }
15358    let Ok(response) = reply.result.as_mut() else {
15359        return;
15360    };
15361    match response {
15362        NodeResponse::Snapshot { snapshot, .. } => {
15363            clear_snapshot_child_environment_profiles(snapshot);
15364        }
15365        NodeResponse::Resync { snapshot, events, .. } => {
15366            clear_snapshot_child_environment_profiles(snapshot);
15367            for event in events {
15368                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15369                    record.environment_profile = None;
15370                }
15371            }
15372        }
15373        NodeResponse::SessionRecordUpdated { record }
15374        | NodeResponse::ProviderSessionIndexed { record }
15375        | NodeResponse::NativeSessionIndexed { record, .. }
15376        | NodeResponse::SessionRecordResumed { record, .. } => {
15377            record.environment_profile = None;
15378        }
15379        NodeResponse::Armed { .. }
15380        | NodeResponse::Spawned { .. }
15381        | NodeResponse::Activated { .. }
15382        | NodeResponse::Aborted { .. }
15383        | NodeResponse::ReplyChunkAccepted { .. }
15384        | NodeResponse::CallRejected { .. }
15385        | NodeResponse::DeliveryStageBegun { .. }
15386        | NodeResponse::DeliveryBlobChunkAccepted { .. }
15387        | NodeResponse::DeliveryCommitted { .. }
15388        | NodeResponse::DeliveryStageAborted { .. }
15389        | NodeResponse::WorkspaceInspected { .. }
15390        | NodeResponse::HostDirectoriesBrowsed { .. }
15391        | NodeResponse::WorkspaceFileRead { .. }
15392        | NodeResponse::WorkspaceFileWritten { .. }
15393        | NodeResponse::WorkspaceFileCreated { .. }
15394        | NodeResponse::WorkspaceDirectoryCreated { .. }
15395        | NodeResponse::GitHistoryRead { .. }
15396        | NodeResponse::GitDiffRead { .. }
15397        | NodeResponse::Controller { .. }
15398        | NodeResponse::SpawnAccepted { .. }
15399        | NodeResponse::SpawnSpecAccepted { .. }
15400        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15401        | NodeResponse::ManagedWorktreeCleanup { .. }
15402        | NodeResponse::NativeSessionsCataloged { .. }
15403        | NodeResponse::NativeSessionsPaged { .. }
15404        | NodeResponse::NativeSessionPreviewed { .. }
15405        | NodeResponse::SessionRecordPreviewed { .. }
15406        | NodeResponse::HistoryDiscovered { .. }
15407        | NodeResponse::HistoryLoaded { .. }
15408        | NodeResponse::ContextPackForSessionRecordExported { .. }
15409        | NodeResponse::ContextPackExported { .. }
15410        | NodeResponse::ContextPackForgotten { .. }
15411        | NodeResponse::DurableContextPackResolved { .. }
15412        | NodeResponse::ContextPackBytesRead { .. }
15413        | NodeResponse::SessionRecordForgotten { .. }
15414        | NodeResponse::WorkspaceRegistered { .. }
15415        | NodeResponse::StandaloneWorkspaceCreated { .. }
15416        | NodeResponse::WorkspaceUnregistered { .. }
15417        | NodeResponse::WorktreeCreated { .. }
15418        | NodeResponse::WorktreeRemoved { .. }
15419        | NodeResponse::Accepted
15420        | NodeResponse::ShuttingDown => {}
15421    }
15422}
15423
15424fn clear_snapshot_session_bundles(snapshot: &mut NodeSnapshot) {
15425    for record in &mut snapshot.session_records {
15426        record.bundle = None;
15427    }
15428}
15429
15430fn project_event_without_session_bundle(
15431    mut envelope: NodeEventEnvelope,
15432) -> NodeEventEnvelope {
15433    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15434        record.bundle = None;
15435    }
15436    envelope
15437}
15438
15439fn project_response_without_session_bundle(reply: &mut ResponseEnvelope) {
15440    let contains_bundle = match reply.result.as_ref() {
15441        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.bundle.is_some(),
15442        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15443            receipt.spawn.bundle.is_some()
15444        }
15445        _ => false,
15446    };
15447    if contains_bundle {
15448        reply.result = Err(failure(
15449            NodeFailureCode::UnsupportedCapability,
15450            "session bundles were not negotiated",
15451        ));
15452        return;
15453    }
15454    let Ok(response) = reply.result.as_mut() else {
15455        return;
15456    };
15457    match response {
15458        NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_session_bundles(snapshot),
15459        NodeResponse::Resync { snapshot, events, .. } => {
15460            clear_snapshot_session_bundles(snapshot);
15461            for event in events {
15462                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15463                    record.bundle = None;
15464                }
15465            }
15466        }
15467        NodeResponse::SessionRecordUpdated { record }
15468        | NodeResponse::ProviderSessionIndexed { record }
15469        | NodeResponse::NativeSessionIndexed { record, .. }
15470        | NodeResponse::SessionRecordResumed { record, .. } => record.bundle = None,
15471        NodeResponse::Armed { .. }
15472        | NodeResponse::Spawned { .. }
15473        | NodeResponse::Activated { .. }
15474        | NodeResponse::Aborted { .. }
15475        | NodeResponse::ReplyChunkAccepted { .. }
15476        | NodeResponse::CallRejected { .. }
15477        | NodeResponse::DeliveryStageBegun { .. }
15478        | NodeResponse::DeliveryBlobChunkAccepted { .. }
15479        | NodeResponse::DeliveryCommitted { .. }
15480        | NodeResponse::DeliveryStageAborted { .. }
15481        | NodeResponse::WorkspaceInspected { .. }
15482        | NodeResponse::HostDirectoriesBrowsed { .. }
15483        | NodeResponse::WorkspaceFileRead { .. }
15484        | NodeResponse::WorkspaceFileWritten { .. }
15485        | NodeResponse::WorkspaceFileCreated { .. }
15486        | NodeResponse::WorkspaceDirectoryCreated { .. }
15487        | NodeResponse::GitHistoryRead { .. }
15488        | NodeResponse::GitDiffRead { .. }
15489        | NodeResponse::Controller { .. }
15490        | NodeResponse::SpawnAccepted { .. }
15491        | NodeResponse::SpawnSpecAccepted { .. }
15492        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15493        | NodeResponse::ManagedWorktreeCleanup { .. }
15494        | NodeResponse::NativeSessionsCataloged { .. }
15495        | NodeResponse::NativeSessionsPaged { .. }
15496        | NodeResponse::NativeSessionPreviewed { .. }
15497        | NodeResponse::SessionRecordPreviewed { .. }
15498        | NodeResponse::HistoryDiscovered { .. }
15499        | NodeResponse::HistoryLoaded { .. }
15500        | NodeResponse::ContextPackForSessionRecordExported { .. }
15501        | NodeResponse::ContextPackExported { .. }
15502        | NodeResponse::ContextPackForgotten { .. }
15503        | NodeResponse::DurableContextPackResolved { .. }
15504        | NodeResponse::ContextPackBytesRead { .. }
15505        | NodeResponse::SessionRecordForgotten { .. }
15506        | NodeResponse::WorkspaceRegistered { .. }
15507        | NodeResponse::StandaloneWorkspaceCreated { .. }
15508        | NodeResponse::WorkspaceUnregistered { .. }
15509        | NodeResponse::WorktreeCreated { .. }
15510        | NodeResponse::WorktreeRemoved { .. }
15511        | NodeResponse::Accepted
15512        | NodeResponse::ShuttingDown => {}
15513    }
15514}
15515
15516fn project_snapshot_history_for_wire(
15517    snapshot: &mut NodeSnapshot,
15518    include_history_metadata: bool,
15519) {
15520    for workspace in &mut snapshot.workspaces {
15521        for session in &mut workspace.sessions {
15522            if include_history_metadata {
15523                session.history.loaded = None;
15524            } else {
15525                session.history = Default::default();
15526            }
15527        }
15528    }
15529}
15530
15531fn project_event_history_for_wire(
15532    mut envelope: NodeEventEnvelope,
15533    include_history_metadata: bool,
15534) -> NodeEventEnvelope {
15535    if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
15536        for session in &mut workspace.sessions {
15537            if include_history_metadata {
15538                session.history.loaded = None;
15539            } else {
15540                session.history = Default::default();
15541            }
15542        }
15543    }
15544    envelope
15545}
15546
15547fn project_response_history_for_wire(
15548    reply: &mut ResponseEnvelope,
15549    include_history_metadata: bool,
15550) {
15551    let Ok(response) = reply.result.as_mut() else {
15552        return;
15553    };
15554    match response {
15555        NodeResponse::Snapshot { snapshot, .. } => {
15556            project_snapshot_history_for_wire(snapshot, include_history_metadata);
15557        }
15558        NodeResponse::Resync {
15559            snapshot, events, ..
15560        } => {
15561            project_snapshot_history_for_wire(snapshot, include_history_metadata);
15562            for event in events {
15563                *event = project_event_history_for_wire(
15564                    event.clone(),
15565                    include_history_metadata,
15566                );
15567            }
15568        }
15569        NodeResponse::WorkspaceRegistered { workspace }
15570        | NodeResponse::StandaloneWorkspaceCreated { workspace }
15571        | NodeResponse::WorktreeCreated { workspace, .. } => {
15572            for session in &mut workspace.sessions {
15573                if include_history_metadata {
15574                    session.history.loaded = None;
15575                } else {
15576                    session.history = Default::default();
15577                }
15578            }
15579        }
15580        _ => {}
15581    }
15582}
15583
15584fn clear_snapshot_context_packs(snapshot: &mut NodeSnapshot) {
15585    for record in &mut snapshot.session_records {
15586        record.context_id = None;
15587        record.context = None;
15588        record.exported_context = None;
15589    }
15590}
15591
15592fn project_event_without_context_pack(
15593    mut envelope: NodeEventEnvelope,
15594) -> NodeEventEnvelope {
15595    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15596        record.context_id = None;
15597        record.context = None;
15598        record.exported_context = None;
15599    }
15600    envelope
15601}
15602
15603fn project_response_without_context_pack(reply: &mut ResponseEnvelope) {
15604    let contains_context = match reply.result.as_ref() {
15605        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.context.is_some(),
15606        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15607            receipt.spawn.context.is_some()
15608        }
15609        Ok(NodeResponse::HistoryDiscovered { .. })
15610        | Ok(NodeResponse::HistoryLoaded { .. })
15611        | Ok(NodeResponse::ContextPackForSessionRecordExported { .. })
15612        | Ok(NodeResponse::ContextPackExported { .. })
15613        | Ok(NodeResponse::ContextPackForgotten { .. })
15614        | Ok(NodeResponse::DurableContextPackResolved { .. }) => true,
15615        _ => false,
15616    };
15617    if contains_context {
15618        reply.result = Err(failure(
15619            NodeFailureCode::UnsupportedCapability,
15620            "history context packs were not negotiated",
15621        ));
15622        return;
15623    }
15624    let Ok(response) = reply.result.as_mut() else {
15625        return;
15626    };
15627    match response {
15628        NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_context_packs(snapshot),
15629        NodeResponse::Resync {
15630            snapshot, events, ..
15631        } => {
15632            clear_snapshot_context_packs(snapshot);
15633            for event in events {
15634                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15635                    record.context_id = None;
15636                    record.context = None;
15637                    record.exported_context = None;
15638                }
15639            }
15640        }
15641        NodeResponse::SessionRecordUpdated { record }
15642        | NodeResponse::ProviderSessionIndexed { record }
15643        | NodeResponse::NativeSessionIndexed { record, .. }
15644        | NodeResponse::SessionRecordResumed { record, .. } => {
15645            record.context_id = None;
15646            record.context = None;
15647            record.exported_context = None;
15648        }
15649        _ => {}
15650    }
15651}
15652
15653fn project_response_legacy_provider_ids(shared: &NodeShared, reply: &mut ResponseEnvelope) {
15654    let contains_open_record = match reply.result.as_ref() {
15655        Ok(NodeResponse::SessionRecordUpdated { record })
15656        | Ok(NodeResponse::ProviderSessionIndexed { record })
15657        | Ok(NodeResponse::SessionRecordResumed { record, .. }) => {
15658            !provider_id_is_legacy(&record.provider)
15659        }
15660        Ok(NodeResponse::NativeSessionIndexed { selection, record }) => {
15661            !provider_id_is_legacy(&selection.route.provider)
15662                || !provider_id_is_legacy(&record.provider)
15663        }
15664        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
15665            !provider_id_is_legacy(&receipt.provider)
15666        }
15667        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15668            !provider_id_is_legacy(&receipt.spawn.provider)
15669        }
15670        Ok(NodeResponse::NativeSessionsCataloged { route, .. })
15671        | Ok(NodeResponse::NativeSessionsPaged { route, .. }) => {
15672            !provider_id_is_legacy(&route.provider)
15673        }
15674        Ok(NodeResponse::NativeSessionPreviewed { selection, .. }) => {
15675            !provider_id_is_legacy(&selection.route.provider)
15676        }
15677        _ => false,
15678    };
15679    if contains_open_record {
15680        reply.result = Err(failure(
15681            NodeFailureCode::UnsupportedCapability,
15682            "open provider IDs were not negotiated",
15683        ));
15684        return;
15685    }
15686    let Ok(response) = reply.result.as_mut() else {
15687        return;
15688    };
15689    match response {
15690        NodeResponse::Snapshot { snapshot, .. } => {
15691            project_snapshot_legacy_provider_ids(snapshot);
15692        }
15693        NodeResponse::Resync { snapshot, events, .. } => {
15694            project_snapshot_legacy_provider_ids(snapshot);
15695            *events = std::mem::take(events)
15696                .into_iter()
15697                .filter_map(|event| project_event_legacy_provider_ids(shared, event))
15698                .collect();
15699        }
15700        NodeResponse::WorkspaceRegistered { workspace }
15701        | NodeResponse::StandaloneWorkspaceCreated { workspace }
15702        | NodeResponse::WorktreeCreated { workspace, .. } => {
15703            workspace
15704                .sessions
15705                .retain(|session| provider_id_is_legacy(&session.agent_id));
15706        }
15707        NodeResponse::Armed { .. }
15708        | NodeResponse::Spawned { .. }
15709        | NodeResponse::Activated { .. }
15710        | NodeResponse::Aborted { .. }
15711        | NodeResponse::ReplyChunkAccepted { .. }
15712        | NodeResponse::CallRejected { .. }
15713        | NodeResponse::DeliveryStageBegun { .. }
15714        | NodeResponse::DeliveryBlobChunkAccepted { .. }
15715        | NodeResponse::DeliveryCommitted { .. }
15716        | NodeResponse::DeliveryStageAborted { .. }
15717        | NodeResponse::WorkspaceInspected { .. }
15718        | NodeResponse::HostDirectoriesBrowsed { .. }
15719        | NodeResponse::WorkspaceFileRead { .. }
15720        | NodeResponse::WorkspaceFileWritten { .. }
15721        | NodeResponse::WorkspaceFileCreated { .. }
15722        | NodeResponse::WorkspaceDirectoryCreated { .. }
15723        | NodeResponse::GitHistoryRead { .. }
15724        | NodeResponse::GitDiffRead { .. }
15725        | NodeResponse::Controller { .. }
15726        | NodeResponse::SpawnAccepted { .. }
15727        | NodeResponse::SpawnSpecAccepted { .. }
15728        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15729        | NodeResponse::ManagedWorktreeCleanup { .. }
15730        | NodeResponse::NativeSessionsCataloged { .. }
15731        | NodeResponse::NativeSessionsPaged { .. }
15732        | NodeResponse::NativeSessionPreviewed { .. }
15733        | NodeResponse::SessionRecordPreviewed { .. }
15734        | NodeResponse::HistoryDiscovered { .. }
15735        | NodeResponse::HistoryLoaded { .. }
15736        | NodeResponse::ContextPackForSessionRecordExported { .. }
15737        | NodeResponse::ContextPackExported { .. }
15738        | NodeResponse::ContextPackForgotten { .. }
15739        | NodeResponse::DurableContextPackResolved { .. }
15740        | NodeResponse::ContextPackBytesRead { .. }
15741        | NodeResponse::SessionRecordUpdated { .. }
15742        | NodeResponse::ProviderSessionIndexed { .. }
15743        | NodeResponse::NativeSessionIndexed { .. }
15744        | NodeResponse::SessionRecordResumed { .. }
15745        | NodeResponse::SessionRecordForgotten { .. }
15746        | NodeResponse::WorkspaceUnregistered { .. }
15747        | NodeResponse::WorktreeRemoved { .. }
15748        | NodeResponse::Accepted
15749        | NodeResponse::ShuttingDown => {}
15750    }
15751}
15752
15753fn project_event_legacy_provider_ids(
15754    shared: &NodeShared,
15755    mut envelope: NodeEventEnvelope,
15756) -> Option<NodeEventEnvelope> {
15757    let include = match &mut envelope.event {
15758        NodeEvent::HarnessMcpReadCall { .. } => true,
15759        NodeEvent::Control { address, .. }
15760        | NodeEvent::AgentStream { address, .. }
15761        | NodeEvent::TerminalFrame { address, .. } => shared
15762            .validate_address(address)
15763            .is_ok_and(|provider| provider_id_is_legacy(&provider)),
15764        NodeEvent::WorkspaceAdded { workspace } => {
15765            workspace
15766                .sessions
15767                .retain(|session| provider_id_is_legacy(&session.agent_id));
15768            true
15769        }
15770        NodeEvent::SessionRecordUpserted { record } => {
15771            provider_id_is_legacy(&record.provider)
15772        }
15773        NodeEvent::SessionRecordHistorySummarized { .. } => false,
15774        NodeEvent::SessionRecordRemoved { .. } => shared
15775            .history
15776            .lock()
15777            .unwrap_or_else(|poisoned| poisoned.into_inner())
15778            .removed_record_providers
15779            .get(&envelope.sequence)
15780            .is_some_and(provider_id_is_legacy),
15781        NodeEvent::ControllerChanged { .. }
15782        | NodeEvent::ManagedWorktreeUpserted { .. }
15783        | NodeEvent::ManagedWorktreeRemoved { .. }
15784        | NodeEvent::WorkspaceRemoved { .. }
15785        | NodeEvent::ResyncRequired { .. } => true,
15786    };
15787    include.then_some(envelope)
15788}
15789
15790fn clear_response_provider_runtime_status(reply: &mut ResponseEnvelope) {
15791    let Ok(response) = reply.result.as_mut() else {
15792        return;
15793    };
15794    match response {
15795        NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
15796            snapshot.provider_runtime_statuses.clear();
15797        }
15798        NodeResponse::Armed { .. }
15799        | NodeResponse::Spawned { .. }
15800        | NodeResponse::Activated { .. }
15801        | NodeResponse::Aborted { .. }
15802        | NodeResponse::ReplyChunkAccepted { .. }
15803        | NodeResponse::CallRejected { .. }
15804        | NodeResponse::DeliveryStageBegun { .. }
15805        | NodeResponse::DeliveryBlobChunkAccepted { .. }
15806        | NodeResponse::DeliveryCommitted { .. }
15807        | NodeResponse::DeliveryStageAborted { .. }
15808        | NodeResponse::WorkspaceInspected { .. }
15809        | NodeResponse::HostDirectoriesBrowsed { .. }
15810        | NodeResponse::WorkspaceFileRead { .. }
15811        | NodeResponse::WorkspaceFileWritten { .. }
15812        | NodeResponse::WorkspaceFileCreated { .. }
15813        | NodeResponse::WorkspaceDirectoryCreated { .. }
15814        | NodeResponse::GitHistoryRead { .. }
15815        | NodeResponse::GitDiffRead { .. }
15816        | NodeResponse::Controller { .. }
15817        | NodeResponse::SpawnAccepted { .. }
15818        | NodeResponse::SpawnSpecAccepted { .. }
15819        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15820        | NodeResponse::ManagedWorktreeCleanup { .. }
15821        | NodeResponse::NativeSessionsCataloged { .. }
15822        | NodeResponse::NativeSessionsPaged { .. }
15823        | NodeResponse::NativeSessionPreviewed { .. }
15824        | NodeResponse::SessionRecordPreviewed { .. }
15825        | NodeResponse::HistoryDiscovered { .. }
15826        | NodeResponse::HistoryLoaded { .. }
15827        | NodeResponse::ContextPackForSessionRecordExported { .. }
15828        | NodeResponse::ContextPackExported { .. }
15829        | NodeResponse::ContextPackForgotten { .. }
15830        | NodeResponse::DurableContextPackResolved { .. }
15831        | NodeResponse::ContextPackBytesRead { .. }
15832        | NodeResponse::SessionRecordUpdated { .. }
15833        | NodeResponse::ProviderSessionIndexed { .. }
15834        | NodeResponse::NativeSessionIndexed { .. }
15835        | NodeResponse::SessionRecordResumed { .. }
15836        | NodeResponse::SessionRecordForgotten { .. }
15837        | NodeResponse::WorkspaceRegistered { .. }
15838        | NodeResponse::StandaloneWorkspaceCreated { .. }
15839        | NodeResponse::WorkspaceUnregistered { .. }
15840        | NodeResponse::WorktreeCreated { .. }
15841        | NodeResponse::WorktreeRemoved { .. }
15842        | NodeResponse::Accepted
15843        | NodeResponse::ShuttingDown => {}
15844    }
15845}
15846
15847struct ControllerReleaseGuard {
15848    shared: Arc<NodeShared>,
15849    connection_id: u64,
15850}
15851
15852impl Drop for ControllerReleaseGuard {
15853    fn drop(&mut self) {
15854        self.shared.release_controller(self.connection_id);
15855    }
15856}
15857
15858struct AbortTaskOnDrop(AbortHandle);
15859
15860impl Drop for AbortTaskOnDrop {
15861    fn drop(&mut self) {
15862        self.0.abort();
15863    }
15864}
15865
15866/// Reads an unsigned env-config override, following the pattern of every
15867/// other bounded knob in this module: missing/unparsable/zero falls back to
15868/// `default`, and the resolved value is always clamped to `max` so a bad
15869/// override cannot reopen a bound this budget exists to enforce.
15870fn env_config_bounded(name: &str, default: u64, max: u64) -> u64 {
15871    std::env::var(name)
15872        .ok()
15873        .and_then(|value| value.parse::<u64>().ok())
15874        .filter(|value| *value > 0)
15875        .unwrap_or(default)
15876        .min(max)
15877}
15878
15879fn workspace_inspection_time_budget_ms() -> u64 {
15880    env_config_bounded(
15881        "GATE4AGENT_NODE_WORKSPACE_INSPECTION_BUDGET_MS",
15882        WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT,
15883        WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX,
15884    )
15885}
15886
15887fn workspace_inspection_entry_cap() -> usize {
15888    env_config_bounded(
15889        "GATE4AGENT_NODE_WORKSPACE_INSPECTION_ENTRY_CAP",
15890        WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT as u64,
15891        WORKSPACE_INSPECTION_ENTRY_CAP_MAX as u64,
15892    ) as usize
15893}
15894
15895/// Tracks the shared walk-side budget across the whole recursive walk: a
15896/// wall-clock deadline (shared with the git phase that runs after the walk)
15897/// and a cap on directory entries visited, whether or not they were pushed
15898/// to the response. Cheap to check per entry — one `Instant::now()` and one
15899/// integer compare.
15900struct WorkspaceWalkBudget {
15901    deadline: Instant,
15902    entry_cap: usize,
15903    entries_visited: u64,
15904    time_budget_exceeded: bool,
15905    entry_cap_exceeded: bool,
15906}
15907
15908impl WorkspaceWalkBudget {
15909    fn new(deadline: Instant, entry_cap: usize) -> Self {
15910        Self {
15911            deadline,
15912            entry_cap,
15913            entries_visited: 0,
15914            time_budget_exceeded: false,
15915            entry_cap_exceeded: false,
15916        }
15917    }
15918
15919    fn is_exhausted(&self) -> bool {
15920        self.time_budget_exceeded || self.entry_cap_exceeded
15921    }
15922
15923    /// Records one visited directory entry (pushed to the response or not)
15924    /// and reports whether the walk must stop now.
15925    fn record_visit(&mut self) -> bool {
15926        self.entries_visited += 1;
15927        if self.entries_visited as usize >= self.entry_cap {
15928            self.entry_cap_exceeded = true;
15929        }
15930        if Instant::now() >= self.deadline {
15931            self.time_budget_exceeded = true;
15932        }
15933        self.is_exhausted()
15934    }
15935}
15936
15937fn collect_workspace_entries(
15938    root: &Path,
15939    deadline: Instant,
15940    entry_cap: usize,
15941) -> (Vec<WorkspaceEntry>, bool, WorkspaceWalkBudget) {
15942    let mut entries = Vec::new();
15943    let mut truncated = false;
15944    let mut budget = WorkspaceWalkBudget::new(deadline, entry_cap);
15945    walk_workspace_directory(root, None, 0, &mut entries, &mut truncated, &mut budget);
15946    // DFS pre-order emits `x/child` between prefix-named siblings `x` and
15947    // `x-y` ('-' sorts below '/'), while the bounded operator projections
15948    // require strict global byte order over relative paths.
15949    entries.sort_by(|left, right| left.relative_path.as_utf8().cmp(&right.relative_path.as_utf8()));
15950    (entries, truncated, budget)
15951}
15952
15953/// Builds the additive `WorkspaceInspectionTruncationV1` for an
15954/// `inspect_workspace` response — `None` once the walk and git phase both
15955/// completed inside their shared budget.
15956fn workspace_inspection_truncation(
15957    walk_budget: &WorkspaceWalkBudget,
15958    git_time_budget_exceeded: bool,
15959    elapsed: Duration,
15960) -> Option<WorkspaceInspectionTruncationV1> {
15961    if !walk_budget.time_budget_exceeded
15962        && !walk_budget.entry_cap_exceeded
15963        && !git_time_budget_exceeded
15964    {
15965        return None;
15966    }
15967    Some(WorkspaceInspectionTruncationV1 {
15968        walk_time_budget_exceeded: walk_budget.time_budget_exceeded,
15969        walk_entry_cap_exceeded: walk_budget.entry_cap_exceeded,
15970        git_time_budget_exceeded,
15971        entries_visited: walk_budget.entries_visited,
15972        elapsed_ms: u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX),
15973    })
15974}
15975
15976fn walk_workspace_directory(
15977    directory: &Path,
15978    relative_directory: Option<&RepositoryPath>,
15979    depth: usize,
15980    entries: &mut Vec<WorkspaceEntry>,
15981    truncated: &mut bool,
15982    budget: &mut WorkspaceWalkBudget,
15983) {
15984    if budget.is_exhausted() {
15985        *truncated = true;
15986        return;
15987    }
15988    let read_dir = match std::fs::read_dir(directory) {
15989        Ok(read_dir) => read_dir,
15990        Err(_) => {
15991            *truncated = true;
15992            return;
15993        }
15994    };
15995    let mut children = Vec::new();
15996    for child in read_dir {
15997        match child {
15998            Ok(child) => children.push(child),
15999            Err(_) => *truncated = true,
16000        }
16001    }
16002    children.sort_by_key(|child| child.file_name());
16003    for child in children {
16004        if budget.record_visit() {
16005            *truncated = true;
16006            return;
16007        }
16008        if entries.len() >= WORKSPACE_TREE_MAX_ENTRIES {
16009            *truncated = true;
16010            return;
16011        }
16012        let file_type = match child.file_type() {
16013            Ok(file_type) => file_type,
16014            Err(_) => {
16015                *truncated = true;
16016                continue;
16017            }
16018        };
16019        if file_type.is_symlink() {
16020            continue;
16021        }
16022        let name = match child.file_name().into_string() {
16023            Ok(name) => name,
16024            Err(_) => {
16025                *truncated = true;
16026                continue;
16027            }
16028        };
16029        if file_type.is_dir() && is_skipped_workspace_directory(&name, &child.path()) {
16030            continue;
16031        }
16032        let relative_path = match relative_directory {
16033            None => windows_repository_path(name),
16034            Some(parent) => parent
16035                .as_utf8()
16036                .and_then(|parent| windows_repository_path(format!("{parent}/{name}"))),
16037        };
16038        let Some(relative_path) = relative_path else {
16039            *truncated = true;
16040            continue;
16041        };
16042        let kind = if file_type.is_dir() {
16043            WorkspaceEntryKind::Directory
16044        } else if file_type.is_file() {
16045            WorkspaceEntryKind::File
16046        } else {
16047            continue;
16048        };
16049        entries.push(WorkspaceEntry {
16050            relative_path: relative_path.clone(),
16051            kind,
16052        });
16053        if file_type.is_dir() {
16054            if depth + 1 < WORKSPACE_TREE_MAX_DEPTH {
16055                walk_workspace_directory(
16056                    &child.path(),
16057                    Some(&relative_path),
16058                    depth + 1,
16059                    entries,
16060                    truncated,
16061                    budget,
16062                );
16063            } else {
16064                *truncated = true;
16065            }
16066        }
16067    }
16068}
16069
16070fn windows_repository_path(value: String) -> Option<RepositoryPath> {
16071    if value.contains('\\') {
16072        return None;
16073    }
16074    RepositoryPath::utf8(value).ok()
16075}
16076
16077fn windows_repository_path_from_bytes(value: &[u8]) -> Option<RepositoryPath> {
16078    windows_repository_path(std::str::from_utf8(value).ok()?.to_owned())
16079}
16080
16081/// Excludes a directory from the workspace tree walk. The three literal
16082/// names are the common convention-based cases; the `CACHEDIR.TAG` check
16083/// beneath them catches every build-output directory regardless of name
16084/// (cargo writes `CACHEDIR.TAG` into every `--target-dir` it creates, so
16085/// this covers `target-a2`, `target-a2-tui`, and any future renamed build
16086/// directory without maintaining a name heuristic list). The name check
16087/// runs first and returns early so a directory rejected by name never pays
16088/// for the extra `CACHEDIR.TAG` stat.
16089fn is_skipped_workspace_directory(name: &str, path: &Path) -> bool {
16090    if name.eq_ignore_ascii_case(".git")
16091        || name.eq_ignore_ascii_case("target")
16092        || name.eq_ignore_ascii_case("node_modules")
16093    {
16094        return true;
16095    }
16096    path.join("CACHEDIR.TAG").is_file()
16097}
16098
16099fn context_pack_source_status_is_usable(status: &SessionStatus) -> bool {
16100    matches!(
16101        status,
16102        SessionStatus::Running | SessionStatus::Exited { exit_code: Some(0) }
16103    )
16104}
16105
16106#[derive(Clone, Debug, Eq, PartialEq)]
16107enum ContextPackRepositoryHead {
16108    NotRepository,
16109    Unborn,
16110    Commit(String),
16111}
16112
16113#[derive(Clone, Debug, Eq, PartialEq)]
16114struct ContextPackRepositoryObservation {
16115    head: ContextPackRepositoryHead,
16116    git: GitSnapshot,
16117    files: Vec<ContextPackRepositoryFileSource>,
16118}
16119
16120fn stable_context_pack_repository(
16121    first: ContextPackRepositoryObservation,
16122    second: ContextPackRepositoryObservation,
16123) -> Result<ContextPackRepository, NodeFailure> {
16124    if first != second {
16125        return Err(failure(
16126            NodeFailureCode::ContextPackMaterializationFailed,
16127            "context repository changed during bounded capture",
16128        ));
16129    }
16130    Ok(ContextPackRepository::from_sources(
16131        &second.git,
16132        second.files,
16133    ))
16134}
16135
16136/// Appends a "skipped: budget exhausted" diagnostic and returns `true` once
16137/// (and only once) the shared walk+git `deadline` has already passed. Every
16138/// remaining phase in `inspect_git_workspace` checks this immediately
16139/// before issuing its own git call, so a budget spent by the walk (or by an
16140/// earlier git call) skips the rest of the chain instead of paying for it
16141/// anyway.
16142fn git_budget_exhausted(snapshot: &mut GitSnapshot, deadline: Instant, stage: &str) -> bool {
16143    if Instant::now() < deadline {
16144        return false;
16145    }
16146    append_git_diagnostic(
16147        snapshot,
16148        &format!("{stage} skipped: workspace inspection time budget exhausted"),
16149    );
16150    true
16151}
16152
16153/// Deadline-aware sibling of `run_git_bounded`, used only by
16154/// `inspect_git_workspace`'s six-call chain: caps the per-call timeout to
16155/// whatever remains of the shared `deadline` (never more than
16156/// `GIT_COMMAND_TIMEOUT_MS`), so a slow walk phase that already spent most
16157/// of the budget cannot still hand a full per-call timeout to every git
16158/// probe behind it. `inspect_git_workspace` already guards each call site
16159/// with `git_budget_exhausted`, so `deadline` is guaranteed not to have
16160/// passed yet here.
16161async fn run_git_bounded_with_deadline(
16162    root: &str,
16163    arguments: &[&str],
16164    output_limit: usize,
16165    deadline: Instant,
16166) -> io::Result<GitCommandOutput> {
16167    let remaining = deadline.checked_duration_since(Instant::now());
16168    let timeout_ms = remaining
16169        .and_then(|remaining| u64::try_from(remaining.as_millis()).ok())
16170        .unwrap_or(0)
16171        .clamp(1, GIT_COMMAND_TIMEOUT_MS);
16172    run_git_read_bounded(root, arguments, output_limit, timeout_ms).await
16173}
16174
16175/// Runs the bounded six-call git probe chain used by `inspect_workspace`.
16176/// `deadline` is the same absolute instant the preceding directory walk was
16177/// bounded by, so time already spent walking counts against what remains
16178/// for git enrichment. Returns the resulting `GitSnapshot` plus whether the
16179/// shared budget (not an individual per-call timeout) is what cut the git
16180/// phase short.
16181async fn inspect_git_workspace(root: &str, deadline: Instant) -> (GitSnapshot, bool) {
16182    let mut snapshot = GitSnapshot {
16183        is_repository: false,
16184        branch: None,
16185        status: Vec::new(),
16186        recent_commits: Vec::new(),
16187        worktrees: Vec::new(),
16188        managed_worktree: None,
16189        truncated: false,
16190        diagnostic: None,
16191    };
16192    if git_budget_exhausted(&mut snapshot, deadline, "git repository probe") {
16193        return (snapshot, true);
16194    }
16195    let repository = match run_git_bounded_with_deadline(
16196        root,
16197        &["rev-parse", "--is-inside-work-tree"],
16198        4 * 1_024,
16199        deadline,
16200    )
16201    .await
16202    {
16203        Ok(output) => output,
16204        Err(error) => {
16205            append_git_diagnostic(
16206                &mut snapshot,
16207                &format!("git inspection unavailable: {error}"),
16208            );
16209            return (snapshot, false);
16210        }
16211    };
16212    snapshot.truncated |= repository.truncated;
16213    if repository.timed_out {
16214        append_git_diagnostic(&mut snapshot, "git repository probe timed out");
16215        return (snapshot, false);
16216    }
16217    if !repository.success
16218        || String::from_utf8_lossy(&repository.stdout).trim() != "true"
16219    {
16220        let stderr = String::from_utf8_lossy(&repository.stderr);
16221        if !stderr.to_ascii_lowercase().contains("not a git repository") {
16222            append_git_diagnostic(&mut snapshot, stderr.trim());
16223        }
16224        return (snapshot, false);
16225    }
16226    snapshot.is_repository = true;
16227
16228    if git_budget_exhausted(&mut snapshot, deadline, "git branch query") {
16229        return (snapshot, true);
16230    }
16231    match run_git_bounded_with_deadline(root, &["branch", "--show-current"], 4 * 1_024, deadline).await {
16232        Ok(output) => {
16233            snapshot.truncated |= output.truncated;
16234            if output.timed_out {
16235                append_git_diagnostic(&mut snapshot, "git branch query timed out");
16236            } else if output.success {
16237                let branch = String::from_utf8_lossy(&output.stdout).trim().to_owned();
16238                if !branch.is_empty() {
16239                    snapshot.branch = Some(truncate_git_field(
16240                        &branch,
16241                        MAX_REPOSITORY_PATH_BYTES,
16242                        &mut snapshot.truncated,
16243                    ));
16244                }
16245            }
16246        }
16247        Err(error) => append_git_diagnostic(
16248            &mut snapshot,
16249            &format!("git branch query failed: {error}"),
16250        ),
16251    }
16252    if snapshot.branch.is_none() && !git_budget_exhausted(&mut snapshot, deadline, "git detached-HEAD probe") {
16253        if let Ok(output) = run_git_bounded_with_deadline(root, &["rev-parse", "--short", "HEAD"], 4 * 1_024, deadline).await {
16254            snapshot.truncated |= output.truncated;
16255            if output.success && !output.timed_out {
16256                let head = String::from_utf8_lossy(&output.stdout).trim().to_owned();
16257                if !head.is_empty() {
16258                    snapshot.branch = Some(format!("detached:{head}"));
16259                }
16260            }
16261        }
16262    }
16263
16264    if git_budget_exhausted(&mut snapshot, deadline, "git status") {
16265        return (snapshot, true);
16266    }
16267    match run_git_bounded_with_deadline(
16268        root,
16269        &[
16270            "status",
16271            "--porcelain=v1",
16272            "-z",
16273            "--renames",
16274            "--untracked-files=normal",
16275            "--",
16276            ".",
16277        ],
16278        GIT_OUTPUT_MAX_BYTES,
16279        deadline,
16280    )
16281    .await
16282    {
16283        Ok(output) => {
16284            snapshot.truncated |= output.truncated;
16285            if output.timed_out {
16286                append_git_diagnostic(&mut snapshot, "git status timed out");
16287            } else if output.success {
16288                parse_git_status(&output.stdout, &mut snapshot);
16289            } else {
16290                append_git_diagnostic(
16291                    &mut snapshot,
16292                    String::from_utf8_lossy(&output.stderr).trim(),
16293                );
16294            }
16295        }
16296        Err(error) => append_git_diagnostic(
16297            &mut snapshot,
16298            &format!("git status failed: {error}"),
16299        ),
16300    }
16301
16302    if git_budget_exhausted(&mut snapshot, deadline, "git log") {
16303        return (snapshot, true);
16304    }
16305    let log_limit = (GIT_COMMIT_MAX_ENTRIES + 1).to_string();
16306    match run_git_bounded_with_deadline(
16307        root,
16308        &["log", "-n", &log_limit, "--pretty=format:%H%x1f%s", "--", "."],
16309        16 * 1_024,
16310        deadline,
16311    )
16312    .await
16313    {
16314        Ok(output) => {
16315            snapshot.truncated |= output.truncated;
16316            if output.timed_out {
16317                append_git_diagnostic(&mut snapshot, "git log timed out");
16318            } else if output.success {
16319                parse_git_commits(&output.stdout, &mut snapshot);
16320            } else {
16321                let stderr = String::from_utf8_lossy(&output.stderr);
16322                if !stderr.to_ascii_lowercase().contains("does not have any commits") {
16323                    append_git_diagnostic(&mut snapshot, stderr.trim());
16324                }
16325            }
16326        }
16327        Err(error) => append_git_diagnostic(
16328            &mut snapshot,
16329            &format!("git log failed: {error}"),
16330        ),
16331    }
16332
16333    if git_budget_exhausted(&mut snapshot, deadline, "git worktree list") {
16334        return (snapshot, true);
16335    }
16336    match list_git_worktrees_with_deadline(root, deadline).await {
16337        Ok(worktrees) => {
16338            snapshot.worktrees = worktrees.into_iter().map(protocol_worktree).collect()
16339        }
16340        Err(error) => append_git_diagnostic(
16341            &mut snapshot,
16342            &format!("git worktree list failed: {}", error.message),
16343        ),
16344    }
16345    (snapshot, false)
16346}
16347
16348fn parse_git_status(output: &[u8], snapshot: &mut GitSnapshot) {
16349    let mut remaining = output;
16350    while !remaining.is_empty() {
16351        if snapshot.status.len() >= GIT_STATUS_MAX_ENTRIES {
16352            snapshot.truncated = true;
16353            break;
16354        }
16355        let Some(record_end) = remaining.iter().position(|byte| *byte == 0) else {
16356            snapshot.truncated = true;
16357            break;
16358        };
16359        let record = &remaining[..record_end];
16360        remaining = &remaining[record_end + 1..];
16361        let has_previous_path = record
16362            .get(..2)
16363            .is_some_and(|status| status.iter().any(|byte| matches!(*byte, b'R' | b'C')));
16364        let previous_path_bytes = if has_previous_path {
16365            let Some(previous_end) = remaining.iter().position(|byte| *byte == 0) else {
16366                snapshot.truncated = true;
16367                break;
16368            };
16369            let previous = &remaining[..previous_end];
16370            remaining = &remaining[previous_end + 1..];
16371            Some(previous)
16372        } else {
16373            None
16374        };
16375        if record.len() < 4
16376            || record[2] != b' '
16377            || !is_git_status_code(record[0])
16378            || !is_git_status_code(record[1])
16379        {
16380            snapshot.truncated = true;
16381            continue;
16382        }
16383        let Some(path) = windows_repository_path_from_bytes(&record[3..]) else {
16384            snapshot.truncated = true;
16385            continue;
16386        };
16387        let previous_path = match previous_path_bytes {
16388            Some(previous) => {
16389                let Some(previous) = windows_repository_path_from_bytes(previous) else {
16390                    snapshot.truncated = true;
16391                    continue;
16392                };
16393                Some(previous)
16394            }
16395            None => None,
16396        };
16397        snapshot.status.push(GitStatusEntry {
16398            index_status: (record[0] as char).to_string(),
16399            worktree_status: (record[1] as char).to_string(),
16400            path,
16401            previous_path,
16402        });
16403    }
16404}
16405
16406fn is_git_status_code(byte: u8) -> bool {
16407    matches!(byte, b' ' | b'M' | b'T' | b'A' | b'D' | b'R' | b'C' | b'U' | b'?' | b'!')
16408}
16409
16410fn parse_git_commits(output: &[u8], snapshot: &mut GitSnapshot) {
16411    for line in String::from_utf8_lossy(output).lines() {
16412        let Some((id, summary)) = line.split_once('\u{1f}') else {
16413            continue;
16414        };
16415        if snapshot.recent_commits.len() >= GIT_COMMIT_MAX_ENTRIES {
16416            snapshot.truncated = true;
16417            break;
16418        }
16419        let id = truncate_git_field(id, 64, &mut snapshot.truncated);
16420        let summary = truncate_git_field(summary, 512, &mut snapshot.truncated);
16421        snapshot.recent_commits.push(GitCommitSummary { id, summary });
16422    }
16423}
16424
16425fn truncate_git_field(value: &str, max_bytes: usize, truncated: &mut bool) -> String {
16426    if value.len() <= max_bytes {
16427        return value.to_owned();
16428    }
16429    *truncated = true;
16430    let mut boundary = max_bytes;
16431    while boundary > 0 && !value.is_char_boundary(boundary) {
16432        boundary -= 1;
16433    }
16434    value[..boundary].to_owned()
16435}
16436
16437fn append_git_diagnostic(snapshot: &mut GitSnapshot, message: &str) {
16438    if message.is_empty() {
16439        return;
16440    }
16441    let normalized = message
16442        .chars()
16443        .map(|character| if character.is_control() { ' ' } else { character })
16444        .collect::<String>();
16445    let mut diagnostic = snapshot.diagnostic.take().unwrap_or_default();
16446    if !diagnostic.is_empty() {
16447        diagnostic.push_str("; ");
16448    }
16449    diagnostic.push_str(normalized.trim());
16450    snapshot.diagnostic = Some(truncate_git_field(
16451        &diagnostic,
16452        GIT_DIAGNOSTIC_MAX_BYTES,
16453        &mut snapshot.truncated,
16454    ));
16455}
16456
16457async fn run_git_bounded(
16458    root: &str,
16459    arguments: &[&str],
16460    output_limit: usize,
16461) -> io::Result<GitCommandOutput> {
16462    run_git_read_bounded(root, arguments, output_limit, GIT_COMMAND_TIMEOUT_MS).await
16463}
16464
16465async fn read_git_history_bounded(
16466    root: &str,
16467    path: Option<&RepositoryPath>,
16468    before: Option<&GitObjectId>,
16469    limit: u16,
16470) -> Result<GitHistoryPage, NodeFailure> {
16471    let path = path
16472        .map(|path| {
16473            path.as_utf8().ok_or_else(|| {
16474                failure(
16475                    NodeFailureCode::InvalidRepositoryPath,
16476                    "git history path must use UTF-8",
16477                )
16478            })
16479        })
16480        .transpose()?
16481        .unwrap_or(".");
16482    let count = (usize::from(limit) + 1).to_string();
16483    let start = before
16484        .map(|revision| format!("{}^", revision.as_str()))
16485        .unwrap_or_else(|| "HEAD".to_owned());
16486    let arguments = [
16487        "--no-pager",
16488        "log",
16489        "--no-decorate",
16490        "--date=iso-strict",
16491        "--pretty=format:%H%x1f%P%x1f%s%x1f%an%x1f%ae%x1f%aI%x1f%cn%x1f%ce%x1f%cI%x1e",
16492        "-n",
16493        count.as_str(),
16494        start.as_str(),
16495        "--",
16496        path,
16497    ];
16498    let output = run_git_bounded(root, &arguments, 256 * 1_024)
16499        .await
16500        .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git history command failed"))?;
16501    if output.timed_out {
16502        return Err(failure(NodeFailureCode::GitReadTimedOut, "git history exceeded its bounded deadline"));
16503    }
16504    if !output.success {
16505        return Err(failure(NodeFailureCode::GitReadFailed, "git history could not be read"));
16506    }
16507    let mut commits = parse_git_history(&output.stdout);
16508    let has_more = commits.len() > usize::from(limit) || output.truncated;
16509    commits.truncate(usize::from(limit));
16510    let next_before = has_more.then(|| commits.last().map(|commit| commit.id.clone())).flatten();
16511    Ok(GitHistoryPage { commits, next_before, truncated: output.truncated })
16512}
16513
16514fn parse_git_history(output: &[u8]) -> Vec<GitCommitDetails> {
16515    String::from_utf8_lossy(output)
16516        .split('\u{1e}')
16517        .filter_map(|record| {
16518            let fields = record.trim_matches(['\r', '\n']).split('\u{1f}').collect::<Vec<_>>();
16519            if fields.len() != 9 {
16520                return None;
16521            }
16522            let id = GitObjectId::new(fields[0].to_owned()).ok()?;
16523            let parents = if fields[1].is_empty() {
16524                Vec::new()
16525            } else {
16526                fields[1]
16527                    .split(' ')
16528                    .map(|value| GitObjectId::new(value.to_owned()))
16529                    .collect::<Result<Vec<_>, _>>()
16530                    .ok()?
16531            };
16532            Some(GitCommitDetails {
16533                id,
16534                parents,
16535                subject: bounded_git_text(fields[2], 512),
16536                author_name: bounded_git_text(fields[3], 256),
16537                author_email: bounded_git_text(fields[4], 320),
16538                authored_at: bounded_git_text(fields[5], 64),
16539                committer_name: bounded_git_text(fields[6], 256),
16540                committer_email: bounded_git_text(fields[7], 320),
16541                committed_at: bounded_git_text(fields[8], 64),
16542                signature_status: GitSignatureStatus::NoSignature,
16543                signer: None,
16544            })
16545        })
16546        .take(usize::from(MAX_GIT_HISTORY_COMMITS) + 1)
16547        .collect()
16548}
16549
16550fn bounded_git_text(value: &str, max_bytes: usize) -> String {
16551    let normalized = value
16552        .chars()
16553        .map(|character| if character.is_control() { ' ' } else { character })
16554        .collect::<String>();
16555    if normalized.len() <= max_bytes {
16556        return normalized;
16557    }
16558    let mut boundary = max_bytes;
16559    while boundary > 0 && !normalized.is_char_boundary(boundary) {
16560        boundary -= 1;
16561    }
16562    normalized[..boundary].to_owned()
16563}
16564
16565async fn read_git_diff_bounded(root: &str, request: GitDiffRequest) -> Result<GitDiff, NodeFailure> {
16566    let path = request.path.as_ref().map(|path| {
16567        path.as_utf8()
16568            .ok_or_else(|| failure(NodeFailureCode::InvalidRepositoryPath, "git diff path must use UTF-8"))
16569    }).transpose()?;
16570    let mut arguments = match &request.mode {
16571        GitDiffMode::Working => vec!["--no-pager", "-c", "diff.external=", "diff", "--no-ext-diff", "--no-textconv"],
16572        GitDiffMode::Staged => vec!["--no-pager", "-c", "diff.external=", "diff", "--cached", "--no-ext-diff", "--no-textconv"],
16573        GitDiffMode::Commit { .. } => vec!["--no-pager", "-c", "diff.external=", "show", "--format=fuller", "--no-ext-diff", "--no-textconv"],
16574    };
16575    if let GitDiffMode::Commit { revision } = &request.mode {
16576        arguments.push(revision.as_str());
16577    }
16578    arguments.push("--");
16579    if let Some(path) = path {
16580        arguments.push(path);
16581    }
16582    let output = run_git_bounded(root, &arguments, MAX_GIT_DIFF_BYTES)
16583        .await
16584        .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git diff command failed"))?;
16585    if output.timed_out {
16586        return Err(failure(NodeFailureCode::GitReadTimedOut, "git diff exceeded its bounded deadline"));
16587    }
16588    if !output.success {
16589        return Err(failure(NodeFailureCode::GitReadFailed, "git diff could not be read"));
16590    }
16591    Ok(GitDiff {
16592        mode: request.mode,
16593        path: request.path,
16594        text: String::from_utf8_lossy(&output.stdout).into_owned(),
16595        truncated: output.truncated,
16596    })
16597}
16598
16599async fn process_request(shared: &NodeShared, connection_id: u64, role: ClientRole, envelope: RequestEnvelope) -> ResponseEnvelope {
16600    let result = process_request_inner(shared, connection_id, role, envelope.request).await;
16601    ResponseEnvelope { request_id: envelope.request_id, result }
16602}
16603
16604async fn process_request_inner(shared: &NodeShared, connection_id: u64, role: ClientRole, request: NodeRequest) -> Result<NodeResponse, NodeFailure> {
16605    if !request.harness_mcp_contract_is_valid_at(unix_time_ms()) {
16606        return Err(failure(NodeFailureCode::InvalidRequest, "harness MCP request is invalid or stale"));
16607    }
16608    let read_only = matches!(
16609        &request,
16610        NodeRequest::Snapshot
16611            | NodeRequest::Resync { .. }
16612            | NodeRequest::BrowseHostDirectories { .. }
16613            | NodeRequest::InspectWorkspace { .. }
16614            | NodeRequest::ReadWorkspaceFile { .. }
16615            | NodeRequest::ReadGitHistory { .. }
16616            | NodeRequest::ReadGitDiff { .. }
16617            | NodeRequest::CatalogNativeSessions { .. }
16618            | NodeRequest::PageNativeSessions { .. }
16619            | NodeRequest::PreviewNativeSession { .. }
16620            | NodeRequest::PreviewSessionRecord { .. }
16621    );
16622    let _mutation_guard = if read_only {
16623        None
16624    } else {
16625        let guard = shared.mutation_gate.lock().await;
16626        if shared.shutdown.load(Ordering::Acquire) {
16627            return Err(failure(NodeFailureCode::ShuttingDown, "node shutdown has begun"));
16628        }
16629        Some(guard)
16630    };
16631    match request {
16632        NodeRequest::Snapshot => Ok(NodeResponse::Snapshot {
16633            event_sequence: shared.current_sequence(),
16634            controller: shared.controller_state(),
16635            snapshot: shared.snapshot(),
16636        }),
16637        NodeRequest::Resync { after_sequence } => Ok(shared.resync(after_sequence)),
16638        NodeRequest::ArmHarnessMcpReservation {
16639            reservation_id,
16640            activation_digest,
16641            spawn_spec,
16642            launch,
16643            expires_at_unix_ms,
16644        } => {
16645            shared.require_controller(connection_id, role)?;
16646            let registry = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16647                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16648            })?;
16649            let expires_at_unix_ms = registry
16650                .arm(reservation_id.clone(), activation_digest.clone(), spawn_spec, launch, expires_at_unix_ms)
16651                .await
16652                .map_err(harness_mcp_failure)?;
16653            Ok(NodeResponse::Armed { reservation_id, activation_digest, expires_at_unix_ms })
16654        }
16655        NodeRequest::SpawnSpecWithHarnessMcp {
16656            reservation_id,
16657            activation_digest,
16658            spec,
16659            deadline_unix_ms,
16660        } => {
16661            shared.require_controller(connection_id, role)?;
16662            let receipt = shared
16663                .spawn_from_spec_with_harness_mcp(
16664                    reservation_id.clone(), activation_digest.clone(), spec, deadline_unix_ms,
16665                )
16666                .await?;
16667            Ok(NodeResponse::Spawned { reservation_id, activation_digest, receipt })
16668        }
16669        NodeRequest::ActivateHarnessMcpReservation {
16670            reservation_id,
16671            activation_digest,
16672            record_id,
16673            session,
16674        } => {
16675            shared.require_controller(connection_id, role)?;
16676            let provider_root_pid = shared.handle.snapshot().sessions.iter().find(|current| {
16677                current.instance_id == session.session.instance_id
16678                    && current.generation == session.session.generation
16679            }).and_then(|current| current.process_id).ok_or_else(|| {
16680                failure(NodeFailureCode::BindingMismatch, "provider root process is unavailable")
16681            })?;
16682            shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16683                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16684            })?.activate(
16685                &reservation_id, &activation_digest, &record_id, &session, provider_root_pid,
16686            ).map_err(harness_mcp_failure)?;
16687            Ok(NodeResponse::Activated { reservation_id, activation_digest, record_id, session })
16688        }
16689        NodeRequest::AbortHarnessMcpReservation { reservation_id, activation_digest } => {
16690            shared.require_controller(connection_id, role)?;
16691            let instance_id = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16692                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16693            })?.abort(&reservation_id, &activation_digest).map_err(harness_mcp_failure)?;
16694            if let Some(instance_id) = instance_id {
16695                SessionLifecycleHook::on_harness_reservation_aborted(&*shared, instance_id);
16696            }
16697            Ok(NodeResponse::Aborted { reservation_id, activation_digest })
16698        }
16699        NodeRequest::PutHarnessMcpReplyChunk {
16700            reservation_id, activation_digest, record_id, session, call_id,
16701            offset, final_chunk, chunk_hex,
16702        } => {
16703            shared.require_controller(connection_id, role)?;
16704            let (next_offset, completed) = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16705                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16706            })?.put_reply_chunk(
16707                &reservation_id, &activation_digest, &record_id, &session, &call_id,
16708                offset, final_chunk, &chunk_hex,
16709            ).map_err(harness_mcp_failure)?;
16710            Ok(NodeResponse::ReplyChunkAccepted {
16711                reservation_id, activation_digest, record_id, session, call_id,
16712                next_offset, completed,
16713            })
16714        }
16715        NodeRequest::RejectHarnessMcpCall {
16716            reservation_id, activation_digest, record_id, session, call_id, reason,
16717        } => {
16718            shared.require_controller(connection_id, role)?;
16719            shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16720                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16721            })?.reject_call(
16722                &reservation_id, &activation_digest, &record_id, &session, &call_id, reason,
16723            ).map_err(harness_mcp_failure)?;
16724            Ok(NodeResponse::CallRejected {
16725                reservation_id, activation_digest, record_id, session, call_id,
16726            })
16727        }
16728        NodeRequest::BeginDeliveryStage { manifest } => {
16729            shared.require_controller(connection_id, role)?;
16730            shared.begin_delivery_stage(manifest)
16731        }
16732        NodeRequest::PutDeliveryBlobChunk {
16733            stage_id,
16734            blob_digest,
16735            offset,
16736            chunk_hex,
16737        } => {
16738            shared.require_controller(connection_id, role)?;
16739            shared.put_delivery_blob_chunk(stage_id, blob_digest, offset, chunk_hex)
16740        }
16741        NodeRequest::CommitDeliveryStage { stage_id } => {
16742            shared.require_controller(connection_id, role)?;
16743            let receipt = shared.commit_delivery_stage(stage_id)?;
16744            Ok(NodeResponse::DeliveryCommitted { receipt })
16745        }
16746        NodeRequest::AbortDeliveryStage { stage_id } => {
16747            shared.require_controller(connection_id, role)?;
16748            shared.abort_delivery_stage(&stage_id)?;
16749            Ok(NodeResponse::DeliveryStageAborted { stage_id })
16750        }
16751        NodeRequest::BrowseHostDirectories { directory, after } => {
16752            let listing = shared.browse_host_directories(directory, after).await?;
16753            Ok(NodeResponse::HostDirectoriesBrowsed { listing })
16754        }
16755        NodeRequest::InspectWorkspace { workspace_id } => {
16756            let inspection = shared.inspect_workspace(workspace_id).await?;
16757            Ok(NodeResponse::WorkspaceInspected { inspection })
16758        }
16759        NodeRequest::ReadWorkspaceFile { workspace_id, path } => {
16760            let file = shared.read_workspace_file(workspace_id, path).await?;
16761            Ok(NodeResponse::WorkspaceFileRead { file })
16762        }
16763        NodeRequest::WriteWorkspaceFile { workspace_id, path, expected_revision, text } => {
16764            shared.require_controller(connection_id, role)?;
16765            let file = shared
16766                .write_workspace_file(workspace_id, path, expected_revision, text)
16767                .await?;
16768            Ok(NodeResponse::WorkspaceFileWritten { file })
16769        }
16770        NodeRequest::CreateWorkspaceFile { workspace_id, path } => {
16771            shared.require_controller(connection_id, role)?;
16772            let file = shared.create_workspace_file(workspace_id, path).await?;
16773            Ok(NodeResponse::WorkspaceFileCreated { file })
16774        }
16775        NodeRequest::CreateWorkspaceDirectory { workspace_id, path } => {
16776            shared.require_controller(connection_id, role)?;
16777            let entry = shared
16778                .create_workspace_directory(workspace_id.clone(), path)
16779                .await?;
16780            Ok(NodeResponse::WorkspaceDirectoryCreated { workspace_id, entry })
16781        }
16782        NodeRequest::ReadGitHistory { workspace_id, path, before, limit } => {
16783            if role != ClientRole::Operator {
16784                return Err(failure(NodeFailureCode::ObserverReadOnly, "git history requires an operator connection"));
16785            }
16786            if !(1..=MAX_GIT_HISTORY_COMMITS).contains(&limit) {
16787                return Err(failure(NodeFailureCode::InvalidRequest, "git history limit is invalid"));
16788            }
16789            let page = shared
16790                .read_git_history(workspace_id.clone(), path, before, limit)
16791                .await?;
16792            Ok(NodeResponse::GitHistoryRead { workspace_id, page })
16793        }
16794        NodeRequest::ReadGitDiff { workspace_id, request } => {
16795            if role != ClientRole::Operator {
16796                return Err(failure(NodeFailureCode::ObserverReadOnly, "git diff requires an operator connection"));
16797            }
16798            let diff = shared.read_git_diff(workspace_id.clone(), request).await?;
16799            Ok(NodeResponse::GitDiffRead { workspace_id, diff })
16800        }
16801        NodeRequest::CatalogNativeSessions { route, limit } => {
16802            if role != ClientRole::Operator {
16803                return Err(failure(
16804                    NodeFailureCode::ObserverReadOnly,
16805                    "native session catalog requires an operator connection",
16806                ));
16807            }
16808            if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit) {
16809                return Err(failure(
16810                    NodeFailureCode::InvalidRequest,
16811                    "native session catalog limit is invalid",
16812                ));
16813            }
16814            let (entries, summary) = shared
16815                .catalog_native_sessions(route.clone(), limit)
16816                .await?;
16817            Ok(NodeResponse::NativeSessionsCataloged {
16818                route,
16819                entries,
16820                summary: Some(summary),
16821            })
16822        }
16823        NodeRequest::PageNativeSessions {
16824            route,
16825            window,
16826            catalog_revision,
16827            recent_cutoff_unix_ms,
16828            after_selection_id,
16829            limit,
16830        } => {
16831            if role != ClientRole::Operator {
16832                return Err(failure(
16833                    NodeFailureCode::ObserverReadOnly,
16834                    "native session catalog paging requires an operator connection",
16835                ));
16836            }
16837            if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit)
16838                || after_selection_id
16839                    .as_deref()
16840                    .is_some_and(|cursor| validate_candidate_id(cursor).is_err())
16841            {
16842                return Err(failure(
16843                    NodeFailureCode::InvalidRequest,
16844                    "native session catalog page request is invalid",
16845                ));
16846            }
16847            let page = shared
16848                .page_native_sessions(
16849                    route.clone(),
16850                    window,
16851                    catalog_revision,
16852                    recent_cutoff_unix_ms,
16853                    after_selection_id,
16854                    limit,
16855                )
16856                .await?;
16857            Ok(NodeResponse::NativeSessionsPaged {
16858                route,
16859                page,
16860            })
16861        }
16862        NodeRequest::PreviewNativeSession {
16863            selection,
16864            message_limit,
16865        } => {
16866            if role != ClientRole::Operator {
16867                return Err(failure(
16868                    NodeFailureCode::ObserverReadOnly,
16869                    "native session preview requires an operator connection",
16870                ));
16871            }
16872            if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16873                .contains(&message_limit)
16874                || selection.validate().is_err()
16875            {
16876                return Err(failure(
16877                    NodeFailureCode::InvalidRequest,
16878                    "native session preview request is invalid",
16879                ));
16880            }
16881            let preview = shared
16882                .preview_native_session(selection.clone(), message_limit)
16883                .await?;
16884            Ok(NodeResponse::NativeSessionPreviewed {
16885                selection,
16886                preview,
16887            })
16888        }
16889        NodeRequest::IndexNativeSession {
16890            selection,
16891            display_name,
16892        } => {
16893            shared.require_controller(connection_id, role)?;
16894            let record = shared
16895                .index_native_session(selection.clone(), display_name)
16896                .await?;
16897            Ok(NodeResponse::NativeSessionIndexed { selection, record })
16898        }
16899        NodeRequest::PreviewSessionRecord {
16900            record_id,
16901            message_limit,
16902        } => {
16903            if role != ClientRole::Operator {
16904                return Err(failure(
16905                    NodeFailureCode::ObserverReadOnly,
16906                    "native session preview requires an operator connection",
16907                ));
16908            }
16909            if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16910                .contains(&message_limit)
16911            {
16912                return Err(failure(
16913                    NodeFailureCode::InvalidRequest,
16914                    "native session preview request is invalid",
16915                ));
16916            }
16917            let record = shared.record(&record_id)?;
16918            let working_directory = shared.workspace_root(&record.workspace_id)?;
16919            if !platform::roots_equal(
16920                &working_directory,
16921                windows_path_text(&record.canonical_root),
16922            ) {
16923                return Err(failure(
16924                    NodeFailureCode::SessionRecordConflict,
16925                    "managed session workspace root changed; refusing to preview another directory",
16926                ));
16927            }
16928            let identity = record.provider_session.clone().ok_or_else(|| {
16929                failure(
16930                    NodeFailureCode::BackendOperationFailed,
16931                    "native session preview is unavailable for this session record",
16932                )
16933            })?;
16934            if identity.key != ProviderSessionKey::SessionId {
16935                return Err(failure(
16936                    NodeFailureCode::BackendOperationFailed,
16937                    "native session preview is unavailable for this session identity",
16938                ));
16939            }
16940            let preview = shared
16941                .preview_native_session_by_session_id(
16942                    record.workspace_id.clone(), record.provider.clone(), identity.id.clone(), message_limit,
16943                )
16944                .await?;
16945            if preview.session_id != identity.id {
16946                return Err(failure(
16947                    NodeFailureCode::SessionRecordConflict,
16948                    "native session preview identity changed while loading",
16949                ));
16950            }
16951            shared.revalidate_session_record_preview(&record, &identity)?;
16952            let preview: gate4agent_types::SessionRecordPreview = preview.into();
16953            shared.publish(NodeEvent::SessionRecordHistorySummarized {
16954                record_id: record_id.clone(),
16955                summary: SessionHistorySummaryV1::from(&preview),
16956            });
16957            Ok(NodeResponse::SessionRecordPreviewed {
16958                record_id,
16959                preview,
16960            })
16961        }
16962        NodeRequest::AcquireController { lease_ms } => {
16963            let controller = shared.acquire_controller(connection_id, role, lease_ms)?;
16964            Ok(NodeResponse::Controller { controller: Some(controller) })
16965        }
16966        NodeRequest::ReleaseController => {
16967            shared.release_controller(connection_id);
16968            Ok(NodeResponse::Controller { controller: shared.controller_state() })
16969        }
16970        NodeRequest::RegisterWorkspace { workspace_id, root } => {
16971            shared.require_controller(connection_id, role)?;
16972            let root = require_windows_path(root)?;
16973            shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16974            let workspace = shared.register_workspace(workspace_id, root).await?;
16975            Ok(NodeResponse::WorkspaceRegistered { workspace })
16976        }
16977        NodeRequest::CreateStandaloneWorkspace {
16978            workspace_id,
16979            root,
16980            initial_branch,
16981        } => {
16982            shared.require_controller(connection_id, role)?;
16983            let root = require_windows_path(root)?;
16984            shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16985            let workspace = shared
16986                .create_standalone_workspace(workspace_id, root, initial_branch)
16987                .await?;
16988            Ok(NodeResponse::StandaloneWorkspaceCreated { workspace })
16989        }
16990        NodeRequest::UnregisterWorkspace { workspace_id } => {
16991            shared.require_controller(connection_id, role)?;
16992            shared.reject_managed_reservation(Some(&workspace_id), None)?;
16993            shared.unregister_workspace(&workspace_id)?;
16994            Ok(NodeResponse::WorkspaceUnregistered { workspace_id })
16995        }
16996        NodeRequest::CreateWorktree {
16997            source_workspace_id,
16998            workspace_id,
16999            target_root,
17000            branch,
17001            base,
17002        } => {
17003            shared.require_controller(connection_id, role)?;
17004            let target_root = require_windows_path(target_root)?;
17005            shared.reject_managed_reservation(Some(&workspace_id), Some(&target_root))?;
17006            let (worktree, workspace) = shared
17007                .create_worktree(
17008                    source_workspace_id,
17009                    workspace_id,
17010                    target_root,
17011                    branch,
17012                    base,
17013                )
17014                .await?;
17015            Ok(NodeResponse::WorktreeCreated { worktree, workspace })
17016        }
17017        NodeRequest::RemoveWorktree {
17018            source_workspace_id,
17019            target_root,
17020        } => {
17021            shared.require_controller(connection_id, role)?;
17022            let native_target_root = require_windows_path(target_root.clone())?;
17023            shared.reject_managed_reservation(None, Some(&native_target_root))?;
17024            let workspace_id = shared
17025                .remove_worktree(source_workspace_id, native_target_root)
17026                .await?;
17027            Ok(NodeResponse::WorktreeRemoved {
17028                target_root,
17029                workspace_id,
17030            })
17031        }
17032        NodeRequest::Spawn { workspace_id, provider, mode, terminal_size, initial_prompt } => {
17033            shared.require_controller(connection_id, role)?;
17034            if !terminal_size.is_valid() {
17035                return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
17036            }
17037            if let Some(prompt) = initial_prompt.as_deref() {
17038                validate_node_text("spawn initial prompt", prompt)?;
17039            }
17040            match shared
17041                .spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt)
17042                .await
17043            {
17044                Ok(session) => {
17045                    shared.mark_spawn_provisional(session.clone());
17046                    Ok(NodeResponse::SpawnAccepted { session })
17047                }
17048                Err(err) => {
17049                    tracing::warn!(
17050                        %provider,
17051                        %workspace_id,
17052                        ?mode,
17053                        code = ?err.code,
17054                        message = %err.message,
17055                        "session spawn refused"
17056                    );
17057                    Err(err)
17058                }
17059            }
17060        }
17061        NodeRequest::SpawnWithOptions { workspace_id, provider, mode, terminal_size, initial_prompt, options } => {
17062            shared.require_controller(connection_id, role)?;
17063            if !terminal_size.is_valid() {
17064                return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
17065            }
17066            if let Some(prompt) = initial_prompt.as_deref() {
17067                validate_node_text("spawn initial prompt", prompt)?;
17068            }
17069            let plan = backend_paths::plan_launch_options(
17070                provider.as_str(),
17071                mode,
17072                options.model.is_some(),
17073                options.effort.is_some(),
17074            )
17075            .map_err(|r| {
17076                tracing::warn!(%provider, reason = %r.message, "launch option path refused");
17077                failure(NodeFailureCode::InvalidRequest, "launch option path refused")
17078            })?;
17079            let selection = spawn_selection(&options)?;
17080            let nick_model = selection.as_ref().map(|s| s.model.clone());
17081            let pty_selection = match plan {
17082                backend_paths::LaunchOptionsPlan::PtyFlags => selection,
17083                _ => None,
17084            };
17085            let spawned = SPAWN_ORIGIN.scope(options.origin.clone(), async {
17086                SPAWN_PARENT.scope(options.parent_session_id.clone(), async {
17087                    SPAWN_NICK_MODEL.scope(nick_model, SPAWN_SESSION_OPTIONS
17088                        .scope(pty_selection, shared.spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt)))
17089                        .await
17090                }).await
17091            }).await;
17092            match spawned {
17093                Ok(session) => {
17094                    if plan == backend_paths::LaunchOptionsPlan::AcpAfterSpawn {
17095                        shared
17096                            .pending_acp_options
17097                            .lock()
17098                            .unwrap_or_else(|p| p.into_inner())
17099                            .insert(session.session.instance_id, (options.model.clone(), options.effort.clone()));
17100                    }
17101                    shared.mark_spawn_provisional(session.clone());
17102                    tracing::info!(%provider, model = ?options.model, effort = ?options.effort, "session spawned with launch options");
17103                    Ok(NodeResponse::SpawnAccepted { session })
17104                }
17105                Err(err) => {
17106                    tracing::warn!(%provider, %workspace_id, code = ?err.code, message = %err.message, "session spawn with options refused");
17107                    Err(err)
17108                }
17109            }
17110        }
17111        NodeRequest::SpawnSpec { spec } => {
17112            shared.require_controller(connection_id, role)?;
17113            let receipt = shared.spawn_from_spec(spec).await?;
17114            Ok(NodeResponse::SpawnSpecAccepted { receipt })
17115        }
17116        NodeRequest::SpawnManagedWorktree { request } => {
17117            shared.require_controller(connection_id, role)?;
17118            let receipt = shared.spawn_managed_worktree(request).await?;
17119            Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
17120        }
17121        NodeRequest::SpawnManagedWorktreeV2 { request } => {
17122            shared.require_controller(connection_id, role)?;
17123            let receipt = shared.spawn_managed_worktree_v2(request).await?;
17124            Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
17125        }
17126        NodeRequest::CleanupManagedWorktree { lease_id } => {
17127            shared.require_controller(connection_id, role)?;
17128            let lease = shared.cleanup_managed_worktree(&lease_id, true).await?;
17129            Ok(NodeResponse::ManagedWorktreeCleanup { lease })
17130        }
17131        NodeRequest::DiscoverHistory { session, limit } => {
17132            controlled_session(shared, connection_id, role, &session)?;
17133            let candidates = shared.discover_history(&session, limit).await?;
17134            Ok(NodeResponse::HistoryDiscovered {
17135                session,
17136                candidates,
17137            })
17138        }
17139        NodeRequest::LoadHistory {
17140            session,
17141            candidate_id,
17142        } => {
17143            controlled_session(shared, connection_id, role, &session)?;
17144            let loaded = shared
17145                .load_history(&session, candidate_id)
17146                .await?;
17147            Ok(NodeResponse::HistoryLoaded {
17148                session,
17149                session_id: loaded.session_id,
17150                message_count: loaded.message_count,
17151                completed_turn_count: loaded.completed_turn_count,
17152            })
17153        }
17154        NodeRequest::ExportContextPackForSessionRecord { record_id, session } => {
17155            shared.require_controller(connection_id, role)?;
17156            let context = shared
17157                .export_context_pack_for_session_record(&record_id, &session, false)
17158                .await?;
17159            Ok(NodeResponse::ContextPackForSessionRecordExported {
17160                record_id,
17161                session,
17162                context,
17163            })
17164        }
17165        NodeRequest::ExportContextPack { session } => {
17166            controlled_session(shared, connection_id, role, &session)?;
17167            let context = shared.export_context_pack(&session).await?;
17168            Ok(NodeResponse::ContextPackExported { context })
17169        }
17170        NodeRequest::ForgetContextPack { context_id } => {
17171            shared.require_controller(connection_id, role)?;
17172            shared.forget_context_pack(&context_id)?;
17173            Ok(NodeResponse::ContextPackForgotten { context_id })
17174        }
17175        NodeRequest::ResolveDurableContextPack { context_id } => {
17176            // Read-only, idempotent, no controller lease required: this lets
17177            // a caller preflight "is this durably exported pack still here"
17178            // without holding the Node's mutation controller.
17179            let context = shared
17180                .context_catalog
17181                .read()
17182                .unwrap_or_else(|poisoned| poisoned.into_inner())
17183                .get(&context_id)
17184                .map(|pack| pack.receipt().clone())
17185                .ok_or_else(|| {
17186                    failure(
17187                        NodeFailureCode::UnknownContextPack,
17188                        "context pack is not present",
17189                    )
17190                })?;
17191            Ok(NodeResponse::DurableContextPackResolved { context })
17192        }
17193        NodeRequest::ReadContextPack { digest } => {
17194            // Read-only fetch of the pack's own bytes, keyed by content digest
17195            // rather than the caller's `SpawnContextId`: same no-controller-lease
17196            // rationale as `ResolveDurableContextPack` above, one step further
17197            // down the same catalog.
17198            let (id, byte_len, bytes) = shared
17199                .context_catalog
17200                .read()
17201                .unwrap_or_else(|poisoned| poisoned.into_inner())
17202                .get_by_digest(&digest)
17203                .map(|pack| {
17204                    (pack.receipt().id.clone(), pack.receipt().byte_len, pack.bytes().to_vec())
17205                })
17206                .ok_or_else(|| {
17207                    failure(
17208                        NodeFailureCode::UnknownContextPack,
17209                        &format!("context pack {digest} not on this node"),
17210                    )
17211                })?;
17212            tracing::debug!(
17213                digest = digest.as_str(),
17214                byte_len,
17215                "read context pack bytes",
17216            );
17217            Ok(NodeResponse::ContextPackBytesRead {
17218                pack: ContextPackBytesRead {
17219                    digest,
17220                    id,
17221                    byte_len,
17222                    bytes,
17223                },
17224            })
17225        }
17226        NodeRequest::Resume { session, terminal_size, initial_prompt } => {
17227            let provider = controlled_session(shared, connection_id, role, &session)?;
17228            if let Some(prompt) = initial_prompt.as_deref() {
17229                validate_node_text("resume initial prompt", prompt)?;
17230            }
17231            let runtime_requirement = if initial_prompt.is_some() {
17232                ProviderRuntimeRequirement::ResumeWithPrompt
17233            } else {
17234                ProviderRuntimeRequirement::Resume
17235            };
17236            shared.require_session_runtime_policy(&session, runtime_requirement)?;
17237            let runtime_policy = shared
17238                .admit_provider_runtime(&provider, runtime_requirement)
17239                .await?;
17240            let working_directory = shared.bound_workspace_root(&session)?;
17241            let request = ResumeLaunchRequest {
17242                working_directory,
17243                terminal_size,
17244                initial_prompt,
17245            };
17246            request
17247                .validate()
17248                .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
17249            let command = shared.prepare_command(ControlCommand::Resume {
17250                instance_id: session.session.instance_id,
17251                target: ResumeTarget::CurrentProvider,
17252                runtime_policy,
17253                request,
17254            });
17255            shared.arm_resume(&session, command.id, runtime_policy)?;
17256            let dispatch = shared.dispatch_envelope(command);
17257            if let Err(error) = dispatch {
17258                shared.clear_armed_resume(&session);
17259                return Err(error);
17260            }
17261            Ok(NodeResponse::Accepted)
17262        }
17263        NodeRequest::RenameSessionRecord {
17264            record_id,
17265            display_name,
17266        } => {
17267            shared.require_controller(connection_id, role)?;
17268            let record = shared.rename_session_record(&record_id, display_name)?;
17269            Ok(NodeResponse::SessionRecordUpdated { record })
17270        }
17271        NodeRequest::SetSessionTask {
17272            record_id,
17273            expected_revision,
17274            target,
17275        } => {
17276            shared.require_controller(connection_id, role)?;
17277            let record = shared.set_session_task(&record_id, expected_revision, target)?;
17278            Ok(NodeResponse::SessionRecordUpdated { record })
17279        }
17280        NodeRequest::IndexProviderSession {
17281            workspace_id,
17282            provider,
17283            identity,
17284            display_name,
17285        } => {
17286            shared.require_controller(connection_id, role)?;
17287            let record = shared.index_provider_session(
17288                workspace_id,
17289                provider,
17290                identity,
17291                display_name,
17292            )?;
17293            Ok(NodeResponse::ProviderSessionIndexed { record })
17294        }
17295        NodeRequest::ResumeSessionRecord {
17296            record_id,
17297            terminal_size,
17298            initial_prompt,
17299        } => {
17300            shared.require_controller(connection_id, role)?;
17301            if let Some(prompt) = initial_prompt.as_deref() {
17302                validate_node_text("managed resume initial prompt", prompt)?;
17303            }
17304            let (record, session) = shared
17305                .resume_session_record(
17306                    &record_id,
17307                    terminal_size,
17308                    initial_prompt,
17309                )
17310                .await?;
17311            Ok(NodeResponse::SessionRecordResumed { record, session })
17312        }
17313        NodeRequest::ForgetSessionRecord { record_id } => {
17314            shared.require_controller(connection_id, role)?;
17315            shared.forget_session_record(&record_id).await?;
17316            Ok(NodeResponse::SessionRecordForgotten { record_id })
17317        }
17318        NodeRequest::Prompt { session, text } => {
17319            let agent_id = controlled_session(shared, connection_id, role, &session)?;
17320            validate_node_text("prompt", &text)?;
17321            shared.require_session_runtime_policy(
17322                &session,
17323                ProviderRuntimeRequirement::SemanticPrompt,
17324            )?;
17325            let framing = prompt_framing(&agent_id);
17326            shared
17327                .dispatch_input_bounded(
17328                    &session,
17329                    InputAction::SubmitPrompt(PromptPayload { text, framing }),
17330                )
17331                .await?;
17332            Ok(NodeResponse::Accepted)
17333        }
17334        NodeRequest::Paste { session, text } => {
17335            controlled_session(shared, connection_id, role, &session)?;
17336            validate_node_text("paste", &text)?;
17337            shared.require_session_runtime_policy(
17338                &session,
17339                ProviderRuntimeRequirement::SemanticPrompt,
17340            )?;
17341            shared
17342                .dispatch_input_bounded(
17343                    &session,
17344                    InputAction::InsertDraft(PromptPayload {
17345                        text,
17346                        framing: PromptFraming::BracketedPaste,
17347                    }),
17348                )
17349                .await?;
17350            Ok(NodeResponse::Accepted)
17351        }
17352        NodeRequest::Input { session, text } => {
17353            controlled_session(shared, connection_id, role, &session)?;
17354            validate_node_text("terminal input", &text)?;
17355            shared
17356                .dispatch_input_bounded(
17357                    &session,
17358                    InputAction::TerminalText(TerminalText { text }),
17359                )
17360                .await?;
17361            Ok(NodeResponse::Accepted)
17362        }
17363        NodeRequest::TerminalBytes { session, bytes } => {
17364            controlled_session(shared, connection_id, role, &session)?;
17365            shared.sessionize_spawn(session.session.instance_id);
17366            let action = terminal_bytes_action(bytes)?;
17367            shared
17368                .dispatch_input_bounded(&session, action)
17369                .await?;
17370            Ok(NodeResponse::Accepted)
17371        }
17372        NodeRequest::TerminalControl { session, control } => {
17373            controlled_session(shared, connection_id, role, &session)?;
17374            shared
17375                .dispatch_input_bounded(&session, InputAction::TerminalControl(control))
17376                .await?;
17377            Ok(NodeResponse::Accepted)
17378        }
17379        NodeRequest::Resize { session, size } => {
17380            controlled_session(shared, connection_id, role, &session)?;
17381            shared.sessionize_spawn(session.session.instance_id);
17382            if !size.is_valid() {
17383                return Err(failure(NodeFailureCode::InvalidRequest, "terminal size is invalid"));
17384            }
17385            shared.dispatch_resize_bounded(&session, size).await?;
17386            Ok(NodeResponse::Accepted)
17387        }
17388        NodeRequest::Interrupt { session } => {
17389            controlled_session(shared, connection_id, role, &session)?;
17390            shared
17391                .dispatch_input_bounded(
17392                    &session,
17393                    InputAction::TerminalControl(TerminalControl::Interrupt),
17394                )
17395                .await?;
17396            Ok(NodeResponse::Accepted)
17397        }
17398        NodeRequest::Stop { session, force } => {
17399            controlled_session(shared, connection_id, role, &session)?;
17400            shared.sessionize_spawn(session.session.instance_id);
17401            shared.dispatch(ControlCommand::Stop { instance_id: session.session.instance_id, force })?;
17402            shared.retire_session_grok_leader(session.session.instance_id);
17403            Ok(NodeResponse::Accepted)
17404        }
17405        NodeRequest::Remove { session } => {
17406            controlled_session(shared, connection_id, role, &session)?;
17407            shared.remove_session(&session).await?;
17408            Ok(NodeResponse::Accepted)
17409        }
17410        NodeRequest::ResolveInteraction { session, correlation_id, response } => {
17411            shared.require_controller(connection_id, role)?;
17412            let snapshot = shared.require_acp_session(&session)?;
17413            let interaction = snapshot
17414                .provider
17415                .interactions
17416                .iter()
17417                .find(|interaction| {
17418                    interaction_correlation(
17419                        session.session.instance_id,
17420                        session.session.generation,
17421                        interaction.id.0,
17422                    ) == correlation_id
17423                })
17424                .ok_or_else(|| failure(
17425                    NodeFailureCode::InvalidRequest,
17426                    "unknown interaction correlation",
17427                ))?;
17428            if interaction.status != ProviderInteractionStatus::Pending {
17429                return Err(failure(
17430                    NodeFailureCode::BackendOperationFailed,
17431                    "interaction has already been resolved",
17432                ));
17433            }
17434            response
17435                .validate_for(interaction.interaction_kind)
17436                .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
17437            let interaction_id = interaction.id;
17438            let response_kind = response.kind();
17439            let response_outcome = response.outcome();
17440            shared.dispatch(ControlCommand::ResolveInteraction {
17441                instance_id: session.session.instance_id,
17442                generation: session.session.generation,
17443                interaction_id,
17444                response,
17445            })?;
17446            // The one line that lets an operator compare what THIS node
17447            // recorded against what the provider itself reports doing --
17448            // measured live on a kimi run where our side resolved `true`
17449            // while the agent reported "rejected" and neither layer had a
17450            // line to line up against the other.
17451            tracing::info!(
17452                node_id = %shared.node_id,
17453                session = ?session.session,
17454                correlation_id = %correlation_id,
17455                interaction_id = interaction_id.0,
17456                response_kind = ?response_kind,
17457                outcome = ?response_outcome,
17458                "interaction resolved",
17459            );
17460            Ok(NodeResponse::Accepted)
17461        }
17462        NodeRequest::SetSessionMode { session, mode_id } => {
17463            shared.require_controller(connection_id, role)?;
17464            shared.require_acp_session(&session)?;
17465            shared.dispatch(ControlCommand::SetSessionMode {
17466                instance_id: session.session.instance_id,
17467                mode_id,
17468            })?;
17469            Ok(NodeResponse::Accepted)
17470        }
17471        NodeRequest::SetSessionConfigOption {
17472            session,
17473            option_id,
17474            value_json,
17475        } => {
17476            shared.require_controller(connection_id, role)?;
17477            shared.require_acp_session(&session)?;
17478            shared.dispatch(ControlCommand::SetSessionConfigOption {
17479                instance_id: session.session.instance_id,
17480                option_id,
17481                value_json,
17482            })?;
17483            Ok(NodeResponse::Accepted)
17484        }
17485        NodeRequest::SetSessionModel { session, model_id } => {
17486            shared.require_controller(connection_id, role)?;
17487            shared.require_acp_session(&session)?;
17488            shared.dispatch(ControlCommand::SetSessionModel {
17489                instance_id: session.session.instance_id,
17490                model_id,
17491            })?;
17492            Ok(NodeResponse::Accepted)
17493        }
17494        NodeRequest::Shutdown => {
17495            shared.require_controller(connection_id, role)?;
17496            shared.begin_shutdown_locked().await?;
17497            Ok(NodeResponse::ShuttingDown)
17498        }
17499    }
17500}
17501
17502fn controlled_session(shared: &NodeShared, connection_id: u64, role: ClientRole, session: &SessionAddress) -> Result<AgentId, NodeFailure> {
17503    shared.require_controller(connection_id, role)?;
17504    shared.validate_address(session)
17505}
17506
17507/// Selects the sole history candidate matching `provider_session_id` when
17508/// the managed record carries a verified identity, or the sole candidate in
17509/// the discovered set at all when it does not (see
17510/// `export_context_pack_for_session_record_inner`'s identity match). Either
17511/// way, more than one match refuses instead of guessing which one is exact.
17512fn select_history_candidate(
17513    candidates: &[HistoryCandidateSummary],
17514    provider_session_id: Option<&str>,
17515) -> Result<String, NodeFailure> {
17516    let mut matches = candidates.iter().filter(|candidate| match provider_session_id {
17517        Some(id) => candidate.session_id_hint == id,
17518        None => true,
17519    });
17520    let candidate = matches.next().ok_or_else(|| {
17521        failure(
17522            NodeFailureCode::SessionRecordConflict,
17523            if provider_session_id.is_some() {
17524                "no history candidate matches the managed provider session"
17525            } else {
17526                "no history candidate exists for the managed session"
17527            },
17528        )
17529    })?;
17530    if matches.next().is_some() {
17531        return Err(failure(
17532            NodeFailureCode::SessionRecordConflict,
17533            if provider_session_id.is_some() {
17534                "history identity is ambiguous for the managed provider session"
17535            } else {
17536                "history is ambiguous without a verified provider session identity"
17537            },
17538        ));
17539    }
17540    Ok(candidate.id.clone())
17541}
17542
17543fn resolved_spawn_profile_summary(
17544    profile: &SpawnProfileDefaults,
17545    environment_profiles: &BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>,
17546) -> Option<SpawnProfileSummary> {
17547    let environment_profile = match profile.environment_profile_id.as_ref() {
17548        None => None,
17549        Some(profile_id) => {
17550            let binding = environment_profiles.get(profile_id)?;
17551            if binding.provider != profile.provider
17552                || binding.native_profile_id(profile.mode).is_none()
17553            {
17554                return None;
17555            }
17556            Some(ResolvedEnvironmentProfileReceipt {
17557                profile_id: binding.id.clone(),
17558                profile_revision: binding.revision.clone(),
17559                network_allowlist: None,
17560                browser_profile_id: None,
17561            })
17562        }
17563    };
17564    Some(SpawnProfileSummary {
17565        id: profile.profile_id.clone(),
17566        revision: profile.revision.clone(),
17567        environment_profile,
17568    })
17569}
17570
17571/// True if `current` is an acceptable version of `expected` at export
17572/// revalidation/commit time.
17573///
17574/// When `allow_clean_detachment` is `false` (the on-demand,
17575/// `NodeRequest::ExportContextPackForSessionRecord` API path), `current` must
17576/// be byte-identical to `expected` and still actively bound to `session` —
17577/// the original, unchanged invariant.
17578///
17579/// When `true` (the reactive auto-export-at-exit path, `§2.1`), `current` may
17580/// ALSO be the exact Live -> Dormant transition that the same clean exit this
17581/// export reacts to produces on its own: `state` Live -> Dormant,
17582/// `active_session` `Some(session)` -> `None`, `updated_at_unix_ms`
17583/// advancing. Every other field — including `provider_session`, `context`,
17584/// `bundle`, `task_binding` — must still match exactly. A record that changed
17585/// in any other way (rebind, a different active session, an identity
17586/// conflict, capacity eviction) is rejected either way; this widens nothing
17587/// about which sessions are eligible, only how the record's own expected
17588/// detachment is tolerated mid-export.
17589fn session_record_export_target_matches(
17590    expected: &ManagedSessionRecord,
17591    current: &ManagedSessionRecord,
17592    session: &SessionAddress,
17593    allow_clean_detachment: bool,
17594) -> bool {
17595    if current == expected && current.active_session.as_ref() == Some(session) {
17596        return current.state == ManagedSessionState::Live;
17597    }
17598    allow_clean_detachment
17599        && current.state == ManagedSessionState::Dormant
17600        && current.active_session.is_none()
17601        && current.record_id == expected.record_id
17602        && current.display_name == expected.display_name
17603        && current.provider == expected.provider
17604        && current.mode == expected.mode
17605        && current.workspace_id == expected.workspace_id
17606        && current.canonical_root == expected.canonical_root
17607        && current.provider_session == expected.provider_session
17608        && current.environment_profile == expected.environment_profile
17609        && current.bundle == expected.bundle
17610        && current.context_id == expected.context_id
17611        && current.context == expected.context
17612        && current.exported_context == expected.exported_context
17613        && current.task_binding == expected.task_binding
17614        && current.created_at_unix_ms == expected.created_at_unix_ms
17615        && current.last_error == expected.last_error
17616}
17617
17618fn session_record_context_export_binding_is_exact(
17619    expected: &ManagedSessionRecord,
17620    current: &ManagedSessionRecord,
17621    identity: Option<&ProviderSessionIdentity>,
17622    session: &SessionAddress,
17623    binding: &SessionBinding,
17624    provider: &AgentId,
17625    current_root: &str,
17626    allow_clean_detachment: bool,
17627) -> bool {
17628    session_record_export_target_matches(expected, current, session, allow_clean_detachment)
17629        && current.provider_session.as_ref() == identity
17630        && current.workspace_id == session.workspace_id
17631        && provider == &current.provider
17632        && binding.workspace_id == session.workspace_id
17633        && binding.generation == session.session.generation
17634        && binding.record_id.as_ref() == Some(&current.record_id)
17635        && platform::roots_equal(
17636            current_root,
17637            windows_path_text(&current.canonical_root),
17638        )
17639}
17640
17641fn session_record_context_export_source_is_usable(
17642    record: &ManagedSessionRecord,
17643    status: &SessionStatus,
17644    allow_clean_detachment: bool,
17645) -> bool {
17646    (record.state == ManagedSessionState::Live
17647        || (allow_clean_detachment && record.state == ManagedSessionState::Dormant))
17648        && context_pack_source_status_is_usable(status)
17649}
17650
17651fn prompt_framing(_agent_id: &AgentId) -> PromptFraming {
17652    PromptFraming::BracketedPaste
17653}
17654
17655fn validate_node_text(field: &str, text: &str) -> Result<(), NodeFailure> {
17656    if text.len() > MAX_NODE_TEXT_BYTES {
17657        return Err(failure(
17658            NodeFailureCode::InvalidRequest,
17659            &format!(
17660                "{field} length {} exceeds the {MAX_NODE_TEXT_BYTES}-byte node limit",
17661                text.len(),
17662            ),
17663        ));
17664    }
17665    Ok(())
17666}
17667
17668fn validate_terminal_bytes(bytes: &[u8]) -> Result<(), NodeFailure> {
17669    if bytes.is_empty() || bytes.len() > MAX_NODE_TERMINAL_BYTES {
17670        return Err(failure(
17671            NodeFailureCode::InvalidRequest,
17672            &format!(
17673                "terminal byte sequence must contain 1..={MAX_NODE_TERMINAL_BYTES} bytes; received {}",
17674                bytes.len(),
17675            ),
17676        ));
17677    }
17678    Ok(())
17679}
17680
17681fn terminal_bytes_action(bytes: Vec<u8>) -> Result<InputAction, NodeFailure> {
17682    validate_terminal_bytes(&bytes)?;
17683    Ok(InputAction::TerminalBytes(bytes))
17684}
17685
17686fn validate_workspace_request_root(root: &str) -> Result<(), NodeFailure> {
17687    if root.is_empty()
17688        || root.len() > MAX_WORKSPACE_ROOT_BYTES
17689        || root.chars().any(char::is_control)
17690    {
17691        return Err(failure(
17692            NodeFailureCode::InvalidWorkspaceRoot,
17693            &format!(
17694                "workspace root must contain 1..={MAX_WORKSPACE_ROOT_BYTES} bytes and no control characters",
17695            ),
17696        ));
17697    }
17698    Ok(())
17699}
17700
17701fn canonical_standalone_workspace_candidate(
17702    workspace_id: &WorkspaceId,
17703    root: &str,
17704) -> Result<String, NodeServerError> {
17705    let path = Path::new(root);
17706    match std::fs::symlink_metadata(path) {
17707        Ok(_) => WorkspaceConfig::new(workspace_id.clone(), path)
17708            .map(|workspace| workspace.canonical_root),
17709        Err(error) if error.kind() == io::ErrorKind::NotFound => {
17710            if !path.is_absolute() {
17711                return Err(NodeServerError::InvalidWorkspaceRoot {
17712                    workspace_id: workspace_id.clone(),
17713                    path: root.to_owned(),
17714                    message: "path is not absolute".to_owned(),
17715                });
17716            }
17717            let name = path.file_name().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
17718                workspace_id: workspace_id.clone(),
17719                path: root.to_owned(),
17720                message: "path has no standalone directory name".to_owned(),
17721            })?;
17722            let parent = path.parent().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
17723                workspace_id: workspace_id.clone(),
17724                path: root.to_owned(),
17725                message: "path has no parent directory".to_owned(),
17726            })?;
17727            let canonical = std::fs::canonicalize(parent)
17728                .map(|parent| parent.join(name))
17729                .map_err(|error| NodeServerError::InvalidWorkspaceRoot {
17730                    workspace_id: workspace_id.clone(),
17731                    path: root.to_owned(),
17732                    message: error.to_string(),
17733                })?;
17734            let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
17735                NodeServerError::InvalidWorkspaceRoot {
17736                    workspace_id: workspace_id.clone(),
17737                    path: path.to_string_lossy().into_owned(),
17738                    message: "canonical path is not valid Unicode".to_owned(),
17739                }
17740            })?;
17741            let canonical_root = platform::normalize_canonical_root(canonical_root);
17742            validate_workspace_root(workspace_id, &canonical_root)?;
17743            Ok(canonical_root)
17744        }
17745        Err(error) => Err(NodeServerError::InvalidWorkspaceRoot {
17746            workspace_id: workspace_id.clone(),
17747            path: root.to_owned(),
17748            message: error.to_string(),
17749        }),
17750    }
17751}
17752
17753fn validate_git_revision(field: &str, revision: Option<&str>) -> Result<(), NodeFailure> {
17754    let Some(revision) = revision else {
17755        return Ok(());
17756    };
17757    if revision.is_empty()
17758        || revision.len() > MAX_REPOSITORY_PATH_BYTES
17759        || revision.chars().any(char::is_control)
17760    {
17761        return Err(failure(
17762            NodeFailureCode::InvalidRequest,
17763            &format!(
17764                "{field} must contain 1..={MAX_REPOSITORY_PATH_BYTES} bytes and no control characters",
17765            ),
17766        ));
17767    }
17768    Ok(())
17769}
17770
17771fn git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
17772    let code = match error.kind {
17773        GitWorktreeErrorKind::Invalid => NodeFailureCode::InvalidRequest,
17774        GitWorktreeErrorKind::NotRepository => NodeFailureCode::NotGitRepository,
17775        GitWorktreeErrorKind::Conflict => NodeFailureCode::WorktreeConflict,
17776        GitWorktreeErrorKind::Protected => NodeFailureCode::WorktreeProtected,
17777        GitWorktreeErrorKind::Dirty => NodeFailureCode::WorktreeDirty,
17778        GitWorktreeErrorKind::Locked => NodeFailureCode::WorktreeLocked,
17779        GitWorktreeErrorKind::Failed => NodeFailureCode::BackendOperationFailed,
17780    };
17781    failure(code, &error.message)
17782}
17783
17784fn standalone_workspace_failure(error: StandaloneWorkspaceError) -> NodeFailure {
17785    let code = match error.kind {
17786        StandaloneWorkspaceErrorKind::InvalidRoot => NodeFailureCode::InvalidWorkspaceRoot,
17787        StandaloneWorkspaceErrorKind::InvalidInitialBranch => NodeFailureCode::InvalidRequest,
17788        StandaloneWorkspaceErrorKind::GitInitializationFailed => {
17789            NodeFailureCode::BackendOperationFailed
17790        }
17791        StandaloneWorkspaceErrorKind::RecoveryRequired => {
17792            NodeFailureCode::StandaloneWorkspaceRecoveryRequired
17793        }
17794    };
17795    failure(code, error.message)
17796}
17797
17798fn managed_git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
17799    let (code, message) = match error.kind {
17800        GitWorktreeErrorKind::Invalid => (
17801            NodeFailureCode::InvalidRequest,
17802            "managed Git input is invalid",
17803        ),
17804        GitWorktreeErrorKind::NotRepository => (
17805            NodeFailureCode::NotGitRepository,
17806            "managed source is not a Git repository",
17807        ),
17808        GitWorktreeErrorKind::Conflict => (
17809            NodeFailureCode::WorktreeConflict,
17810            "managed Git identity conflicts with existing repository state",
17811        ),
17812        GitWorktreeErrorKind::Protected => (
17813            NodeFailureCode::ManagedWorktreeOwnershipConflict,
17814            "managed worktree ownership validation failed",
17815        ),
17816        GitWorktreeErrorKind::Dirty => (
17817            NodeFailureCode::WorktreeDirty,
17818            "managed worktree contains uncommitted changes",
17819        ),
17820        GitWorktreeErrorKind::Locked => (
17821            NodeFailureCode::WorktreeLocked,
17822            "managed worktree is locked",
17823        ),
17824        GitWorktreeErrorKind::Failed => (
17825            NodeFailureCode::BackendOperationFailed,
17826            "managed Git operation failed",
17827        ),
17828    };
17829    NodeFailure { code, message: message.to_owned() }
17830}
17831
17832fn managed_spawn_receipt(
17833    resolved: &ResolvedSpawnSpec,
17834    incarnation_id: NodeIncarnationId,
17835    session: SessionAddress,
17836    lease: ManagedWorktreeLeaseSnapshot,
17837    environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
17838    bundle: Option<ResolvedBundleReceipt>,
17839    context: Option<ResolvedContextPackReceipt>,
17840) -> ManagedWorktreeSpawnReceipt {
17841    let mut managed = resolved.clone();
17842    managed.target.worktree_id = Some(lease.workspace_id.clone());
17843    ManagedWorktreeSpawnReceipt {
17844        spawn: managed.receipt_with_materialization(
17845            incarnation_id,
17846            session,
17847            environment_profile,
17848            bundle,
17849            context,
17850        ),
17851        lease,
17852    }
17853}
17854
17855fn validate_selected_worktree(
17856    source_root: &str,
17857    selected_root: &str,
17858    worktrees: &[NativeGitWorktreeSnapshot],
17859) -> Result<(), NodeFailure> {
17860    if !worktrees
17861        .iter()
17862        .any(|worktree| worktree_paths_equal(&worktree.path, source_root) && !worktree.is_bare)
17863    {
17864        return Err(failure(
17865            NodeFailureCode::WorktreeProtected,
17866            "source workspace is not an authoritative non-bare Git worktree root",
17867        ));
17868    }
17869    if !worktrees.iter().any(|worktree| {
17870        worktree_paths_equal(&worktree.path, selected_root)
17871            && !worktree.is_main
17872            && !worktree.is_bare
17873    }) {
17874        return Err(failure(
17875            NodeFailureCode::WorktreeProtected,
17876            "selected workspace is not an eligible Git worktree in the source repository",
17877        ));
17878    }
17879    Ok(())
17880}
17881
17882fn workspace_file_failure(error: WorkspaceFileReadError) -> NodeFailure {
17883    let code = match error.kind() {
17884        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17885        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17886            NodeFailureCode::RepositoryPathUnsafe
17887        }
17888        WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17889        WorkspaceFileReadErrorKind::NotRegularFile => {
17890            NodeFailureCode::RepositoryFileNotRegular
17891        }
17892        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17893            NodeFailureCode::RepositoryFileReadFailed
17894        }
17895        WorkspaceFileReadErrorKind::RevisionConflict => {
17896            NodeFailureCode::RepositoryFileRevisionConflict
17897        }
17898        WorkspaceFileReadErrorKind::AlreadyExists
17899        | WorkspaceFileReadErrorKind::ParentNotFound
17900        | WorkspaceFileReadErrorKind::ParentNotDirectory
17901        | WorkspaceFileReadErrorKind::Canceled => {
17902            NodeFailureCode::RepositoryFileReadFailed
17903        }
17904    };
17905    failure(code, "repository file read failed")
17906}
17907
17908fn workspace_file_write_failure(error: WorkspaceFileReadError) -> NodeFailure {
17909    let code = match error.kind() {
17910        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17911        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17912            NodeFailureCode::RepositoryPathUnsafe
17913        }
17914        WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17915        WorkspaceFileReadErrorKind::NotRegularFile => {
17916            NodeFailureCode::RepositoryFileNotRegular
17917        }
17918        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17919            NodeFailureCode::RepositoryFileWriteFailed
17920        }
17921        WorkspaceFileReadErrorKind::RevisionConflict => {
17922            NodeFailureCode::RepositoryFileRevisionConflict
17923        }
17924        WorkspaceFileReadErrorKind::AlreadyExists
17925        | WorkspaceFileReadErrorKind::ParentNotFound
17926        | WorkspaceFileReadErrorKind::ParentNotDirectory
17927        | WorkspaceFileReadErrorKind::Canceled => {
17928            NodeFailureCode::RepositoryFileWriteFailed
17929        }
17930    };
17931    failure(code, "repository file write failed")
17932}
17933
17934async fn settle_workspace_entry_create<T>(
17935    mut task: tokio::task::JoinHandle<Result<T, WorkspaceFileReadError>>,
17936    commit_state: Arc<AtomicU8>,
17937    deadline: Duration,
17938    timeout_message: &'static str,
17939    task_failure_message: &'static str,
17940) -> Result<T, NodeFailure> {
17941    let joined = match timeout(deadline, &mut task).await {
17942        Ok(joined) => joined,
17943        Err(_) => {
17944            if commit_state
17945                .compare_exchange(
17946                    WORKSPACE_ENTRY_CREATE_PENDING,
17947                    WORKSPACE_ENTRY_CREATE_CANCELED,
17948                    Ordering::AcqRel,
17949                    Ordering::Acquire,
17950                )
17951                .is_ok()
17952            {
17953                return Err(failure(
17954                    NodeFailureCode::RepositoryEntryCreateTimedOut,
17955                    timeout_message,
17956                ));
17957            }
17958            debug_assert_eq!(
17959                commit_state.load(Ordering::Acquire),
17960                WORKSPACE_ENTRY_CREATE_COMMITTING,
17961            );
17962            task.await
17963        }
17964    };
17965    joined
17966        .map_err(|_| {
17967            failure(
17968                NodeFailureCode::RepositoryEntryCreateFailed,
17969                task_failure_message,
17970            )
17971        })?
17972        .map_err(workspace_entry_create_failure)
17973}
17974
17975fn workspace_entry_create_failure(error: WorkspaceFileReadError) -> NodeFailure {
17976    let code = match error.kind() {
17977        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17978        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17979            NodeFailureCode::RepositoryPathUnsafe
17980        }
17981        WorkspaceFileReadErrorKind::AlreadyExists => {
17982            NodeFailureCode::RepositoryEntryAlreadyExists
17983        }
17984        WorkspaceFileReadErrorKind::ParentNotFound | WorkspaceFileReadErrorKind::NotFound => {
17985            NodeFailureCode::RepositoryParentNotFound
17986        }
17987        WorkspaceFileReadErrorKind::ParentNotDirectory
17988        | WorkspaceFileReadErrorKind::NotRegularFile => {
17989            NodeFailureCode::RepositoryParentNotDirectory
17990        }
17991        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17992            NodeFailureCode::RepositoryEntryCreateFailed
17993        }
17994        WorkspaceFileReadErrorKind::RevisionConflict => {
17995            NodeFailureCode::RepositoryEntryCreateFailed
17996        }
17997        WorkspaceFileReadErrorKind::Canceled => {
17998            NodeFailureCode::RepositoryEntryCreateTimedOut
17999        }
18000    };
18001    failure(code, "repository entry create failed")
18002}
18003
18004fn workspace_file_revision(bytes: &[u8]) -> WorkspaceFileRevision {
18005    let value = digest(&SHA256, bytes)
18006        .as_ref()
18007        .iter()
18008        .map(|byte| format!("{byte:02x}"))
18009        .collect();
18010    WorkspaceFileRevision::new(value).expect("SHA-256 produces a valid workspace revision")
18011}
18012
18013fn managed_spawn_request_digest_v2(
18014    request: &ManagedWorktreeSpawnRequestV2,
18015) -> Result<String, NodeFailure> {
18016    let encoded = serde_json::to_vec(request).map_err(|_| {
18017        failure(
18018            NodeFailureCode::InvalidRequest,
18019            "managed worktree V2 request could not be canonicalized",
18020        )
18021    })?;
18022    let mut value = String::with_capacity(71);
18023    value.push_str("sha256:");
18024    for byte in digest(&SHA256, &encoded).as_ref() {
18025        use std::fmt::Write as _;
18026        write!(&mut value, "{byte:02x}").expect("writing to a String cannot fail");
18027    }
18028    Ok(value)
18029}
18030
18031fn native_session_preview_failure(error: NativeSessionPreviewError) -> NodeFailure {
18032    let code = match error {
18033        NativeSessionPreviewError::InvalidLimit => NodeFailureCode::InvalidRequest,
18034        NativeSessionPreviewError::WorkspaceUnavailable => NodeFailureCode::UnknownWorkspace,
18035        NativeSessionPreviewError::StaleCatalog => NodeFailureCode::StaleNativeSessionCatalog,
18036        NativeSessionPreviewError::UnsupportedProvider
18037        | NativeSessionPreviewError::PreviewUnavailable
18038        | NativeSessionPreviewError::SessionNotFound
18039        | NativeSessionPreviewError::AmbiguousSession => {
18040            NodeFailureCode::BackendOperationFailed
18041        }
18042    };
18043    failure(code, "native session preview failed")
18044}
18045
18046fn host_directory_failure(error: HostDirectoryBrowseError) -> NodeFailure {
18047    let code = match error.kind() {
18048        HostDirectoryBrowseErrorKind::Invalid => NodeFailureCode::HostDirectoryInvalid,
18049        HostDirectoryBrowseErrorKind::ReadFailed => NodeFailureCode::HostDirectoryReadFailed,
18050    };
18051    failure(code, "host directory browse failed")
18052}
18053
18054fn delivery_failure(error: DeliveryStoreError) -> NodeFailure {
18055    let code = match error {
18056        DeliveryStoreError::InvalidManifest => NodeFailureCode::DeliveryManifestInvalid,
18057        DeliveryStoreError::UnknownStage => NodeFailureCode::UnknownDeliveryStage,
18058        DeliveryStoreError::Capacity | DeliveryStoreError::StageConflict => {
18059            NodeFailureCode::DeliveryStageConflict
18060        }
18061        DeliveryStoreError::UnexpectedBlob => NodeFailureCode::DeliveryBlobUnexpected,
18062        DeliveryStoreError::ChunkOutOfOrder | DeliveryStoreError::ChunkOverflow => {
18063            NodeFailureCode::DeliveryChunkOutOfOrder
18064        }
18065        DeliveryStoreError::BlobDigestMismatch => {
18066            NodeFailureCode::DeliveryBlobDigestMismatch
18067        }
18068        DeliveryStoreError::BundleDigestMismatch => {
18069            NodeFailureCode::DeliveryBundleDigestMismatch
18070        }
18071        DeliveryStoreError::StageIncomplete => NodeFailureCode::DeliveryStageIncomplete,
18072        DeliveryStoreError::Unavailable
18073        | DeliveryStoreError::Corrupt
18074        | DeliveryStoreError::Storage(_) => NodeFailureCode::DeliveryStageStorageFailed,
18075    };
18076    failure(code, "delivery operation failed")
18077}
18078
18079fn validate_workspace_root(
18080    workspace_id: &WorkspaceId,
18081    canonical_root: &str,
18082) -> Result<(), NodeServerError> {
18083    #[cfg(windows)]
18084    if canonical_root.starts_with(r"\\") {
18085        return Err(NodeServerError::InvalidWorkspaceRoot {
18086            workspace_id: workspace_id.clone(),
18087            path: canonical_root.to_owned(),
18088            message: "UNC workspace roots are unsupported by Windows PTY providers".to_owned(),
18089        });
18090    }
18091    if !platform::workspace_root_supported(canonical_root)
18092        || canonical_root.len() > WORKING_DIRECTORY_MAX_BYTES
18093    {
18094        return Err(NodeServerError::InvalidWorkspaceRoot {
18095            workspace_id: workspace_id.clone(),
18096            path: canonical_root.to_owned(),
18097            message: "canonical path must be absolute, bounded, and free of control characters"
18098                .to_owned(),
18099        });
18100    }
18101    Ok(())
18102}
18103
18104fn failure(code: NodeFailureCode, _message: &str) -> NodeFailure {
18105    NodeFailure {
18106        code,
18107        message: node_failure_category(code).to_owned(),
18108    }
18109}
18110
18111fn harness_mcp_failure(error: HarnessMcpProxyError) -> NodeFailure {
18112    let code = match error {
18113        HarnessMcpProxyError::Unavailable => NodeFailureCode::HarnessMcpUnavailable,
18114        HarnessMcpProxyError::NotFound => NodeFailureCode::ReservationNotFound,
18115        HarnessMcpProxyError::Conflict => NodeFailureCode::ReservationConflict,
18116        HarnessMcpProxyError::Expired => NodeFailureCode::ReservationExpired,
18117        HarnessMcpProxyError::BindingMismatch => NodeFailureCode::BindingMismatch,
18118        HarnessMcpProxyError::NotActivated => NodeFailureCode::NotActivated,
18119        HarnessMcpProxyError::CallNotFound => NodeFailureCode::CallNotFound,
18120        HarnessMcpProxyError::ChunkOutOfOrder => NodeFailureCode::ChunkOutOfOrder,
18121        HarnessMcpProxyError::ResponseTooLarge => NodeFailureCode::ResponseTooLarge,
18122    };
18123    failure(code, "harness MCP proxy operation failed")
18124}
18125
18126/// Numbers each harness-MCP overlay this process constructs, in
18127/// construction order, so two overlays built back to back in one node process
18128/// still get distinct trace files.
18129static NEXT_HARNESS_MCP_TRACE_SEQUENCE: AtomicU64 = AtomicU64::new(1);
18130
18131/// Builds the optional trace environment entry of one harness-MCP overlay.
18132/// The caller's launch description names both the entry and the node-process
18133/// variable that opts in: when that variable holds a non-empty directory, the
18134/// entry names a trace file under it. Returns `None` when the launch has no
18135/// trace, or the directory is unset, empty, or not valid Unicode. Never creates
18136/// the directory; that is the operator's job.
18137fn harness_mcp_trace_env_entry(launch: &HarnessMcpLaunchV1) -> Option<(OsString, OsString)> {
18138    let trace = launch.trace.as_ref()?;
18139    let trace_dir = std::env::var(&trace.dir_env).ok()?;
18140    if trace_dir.is_empty() {
18141        return None;
18142    }
18143    let sequence = NEXT_HARNESS_MCP_TRACE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
18144    let path = std::path::Path::new(&trace_dir).join(format!("harness-mcp-{sequence}.trace"));
18145    Some((OsString::from(trace.env.as_str()), path.into_os_string()))
18146}
18147
18148fn spawn_deadline_remaining(deadline: Instant) -> Result<Duration, NodeFailure> {
18149    let remaining = deadline.saturating_duration_since(Instant::now());
18150    if remaining.is_zero() {
18151        Err(failure(
18152            NodeFailureCode::SpawnDeadlineExceeded,
18153            "spawn deadline elapsed",
18154        ))
18155    } else {
18156        Ok(remaining)
18157    }
18158}
18159
18160fn spawn_dispatch_timeout(deadline: Option<Instant>) -> Result<Duration, NodeFailure> {
18161    let bounded = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
18162    match deadline {
18163        Some(deadline) => Ok(spawn_deadline_remaining(deadline)?.min(bounded)),
18164        None => Ok(bounded),
18165    }
18166}
18167
18168fn spawn_dispatch_error(error: NodeFailure, deadline: Option<Instant>) -> NodeFailure {
18169    if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
18170        failure(
18171            NodeFailureCode::SpawnDeadlineExceeded,
18172            "spawn dispatch exceeded the spawn deadline",
18173        )
18174    } else {
18175        error
18176    }
18177}
18178
18179fn spawn_runtime_capabilities(
18180    capabilities: &SpawnRequiredCapabilities,
18181) -> Result<Vec<ProviderRuntimeCapability>, NodeFailure> {
18182    capabilities
18183        .iter()
18184        .map(|capability| match capability.as_str() {
18185            SPAWN_RUNTIME_RAW_PTY_LIFECYCLE => Ok(ProviderRuntimeCapability::RawPtyLifecycle),
18186            SPAWN_RUNTIME_SEMANTIC_READINESS => Ok(ProviderRuntimeCapability::SemanticReadiness),
18187            SPAWN_RUNTIME_STRUCTURED_PROMPT => Ok(ProviderRuntimeCapability::StructuredPrompt),
18188            SPAWN_RUNTIME_PROVIDER_SESSION_IDENTITY => {
18189                Ok(ProviderRuntimeCapability::ProviderSessionIdentity)
18190            }
18191            SPAWN_RUNTIME_SEMANTIC_RESUME => Ok(ProviderRuntimeCapability::SemanticResume),
18192            _ => Err(failure(
18193                NodeFailureCode::UnsupportedSpawnCapability,
18194                "spawn required capability is unknown",
18195            )),
18196        })
18197        .collect()
18198}
18199
18200fn persistence_failure(_error: io::Error) -> NodeFailure {
18201    NodeFailure {
18202        code: NodeFailureCode::BackendOperationFailed,
18203        message: DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned(),
18204    }
18205}
18206
18207fn node_failure_category(code: NodeFailureCode) -> &'static str {
18208    match code {
18209        NodeFailureCode::InvalidRequest => "invalid-request",
18210        NodeFailureCode::UnsupportedCapability => "unsupported-capability",
18211        NodeFailureCode::HarnessMcpUnavailable => "harness-mcp-unavailable",
18212        NodeFailureCode::ReservationNotFound => "reservation-not-found",
18213        NodeFailureCode::ReservationConflict => "reservation-conflict",
18214        NodeFailureCode::ReservationExpired => "reservation-expired",
18215        NodeFailureCode::BindingMismatch => "binding-mismatch",
18216        NodeFailureCode::NotActivated => "not-activated",
18217        NodeFailureCode::CallNotFound => "call-not-found",
18218        NodeFailureCode::ChunkOutOfOrder => "chunk-out-of-order",
18219        NodeFailureCode::ResponseTooLarge => "response-too-large",
18220        NodeFailureCode::DeliveryManifestInvalid => "delivery-manifest-invalid",
18221        NodeFailureCode::UnknownDeliveryStage => "unknown-delivery-stage",
18222        NodeFailureCode::DeliveryStageConflict => "delivery-stage-conflict",
18223        NodeFailureCode::DeliveryBlobUnexpected => "delivery-blob-unexpected",
18224        NodeFailureCode::DeliveryChunkOutOfOrder => "delivery-chunk-out-of-order",
18225        NodeFailureCode::DeliveryBlobDigestMismatch => "delivery-blob-digest-mismatch",
18226        NodeFailureCode::DeliveryBundleDigestMismatch => "delivery-bundle-digest-mismatch",
18227        NodeFailureCode::DeliveryStageIncomplete => "delivery-stage-incomplete",
18228        NodeFailureCode::DeliveryStageStorageFailed => "delivery-stage-storage-failed",
18229        NodeFailureCode::Unauthorized => "unauthorized",
18230        NodeFailureCode::ObserverReadOnly => "observer-read-only",
18231        NodeFailureCode::ControllerBusy => "controller-busy",
18232        NodeFailureCode::ControllerRequired => "controller-required",
18233        NodeFailureCode::UnknownWorkspace => "unknown-workspace",
18234        NodeFailureCode::HostDirectoryInvalid => "host-directory-invalid",
18235        NodeFailureCode::HostDirectoryReadFailed => "host-directory-read-failed",
18236        NodeFailureCode::HostDirectoryReadTimedOut => "host-directory-read-timed-out",
18237        NodeFailureCode::InvalidRepositoryPath => "invalid-repository-path",
18238        NodeFailureCode::RepositoryFileNotFound => "repository-file-not-found",
18239        NodeFailureCode::RepositoryFileNotRegular => "repository-file-not-regular",
18240        NodeFailureCode::RepositoryPathUnsafe => "repository-path-unsafe",
18241        NodeFailureCode::RepositoryFileReadTimedOut => "repository-file-read-timed-out",
18242        NodeFailureCode::RepositoryFileReadFailed => "repository-file-read-failed",
18243        NodeFailureCode::RepositoryFileWriteTimedOut => "repository-file-write-timed-out",
18244        NodeFailureCode::RepositoryFileWriteFailed => "repository-file-write-failed",
18245        NodeFailureCode::RepositoryFileRevisionConflict => "repository-file-revision-conflict",
18246        NodeFailureCode::RepositoryEntryAlreadyExists => "repository-entry-already-exists",
18247        NodeFailureCode::RepositoryParentNotFound => "repository-parent-not-found",
18248        NodeFailureCode::RepositoryParentNotDirectory => "repository-parent-not-directory",
18249        NodeFailureCode::RepositoryEntryCreateTimedOut => "repository-entry-create-timed-out",
18250        NodeFailureCode::RepositoryEntryCreateFailed => "repository-entry-create-failed",
18251        NodeFailureCode::GitReadTimedOut => "git-read-timed-out",
18252        NodeFailureCode::GitReadFailed => "git-read-failed",
18253        NodeFailureCode::UnknownSpawnProfile => "unknown-spawn-profile",
18254        NodeFailureCode::SpawnProfileRevisionMismatch => "spawn-profile-revision-mismatch",
18255        NodeFailureCode::SpawnTargetMismatch => "spawn-target-mismatch",
18256        NodeFailureCode::SpawnIdempotencyConflict => "spawn-idempotency-conflict",
18257        NodeFailureCode::SpawnIdempotencyCapacity => "spawn-idempotency-capacity",
18258        NodeFailureCode::SpawnDeadlineExceeded => "spawn-deadline-exceeded",
18259        NodeFailureCode::UnsupportedSpawnCapability => "unsupported-spawn-capability",
18260        NodeFailureCode::UnsupportedTransport => "unsupported-transport",
18261        NodeFailureCode::TurnInFlight => "turn-in-flight",
18262        NodeFailureCode::UnknownEnvironmentProfile => "unknown-environment-profile",
18263        NodeFailureCode::UnknownNetworkAllowlist => "unknown-network-allowlist",
18264        NodeFailureCode::UnsupportedNetworkAllowlistMapping => {
18265            "unsupported-network-allowlist-mapping"
18266        }
18267        NodeFailureCode::BrowserStationProbeUnavailable => {
18268            "browser-station-probe-unavailable"
18269        }
18270        NodeFailureCode::BrowserStationUnreachable => "browser-station-unreachable",
18271        NodeFailureCode::BrowserStationProfileBusy => "browser-station-profile-busy",
18272        NodeFailureCode::EnvironmentProfileBindingMismatch => {
18273            "environment-profile-binding-mismatch"
18274        }
18275        NodeFailureCode::UnknownBundle => "unknown-bundle",
18276        NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
18277        NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
18278        NodeFailureCode::UnknownContextPack => "unknown-context-pack",
18279        NodeFailureCode::ContextPackBusy => "context-pack-busy",
18280        NodeFailureCode::ContextPackMaterializationFailed => {
18281            "context-pack-materialization-failed"
18282        }
18283        NodeFailureCode::InvalidWorkspaceRoot => "invalid-workspace-root",
18284        NodeFailureCode::DuplicateWorkspaceId => "duplicate-workspace-id",
18285        NodeFailureCode::DuplicateWorkspaceRoot => "duplicate-workspace-root",
18286        NodeFailureCode::WorkspaceBusy => "workspace-busy",
18287        NodeFailureCode::LastWorkspace => "last-workspace",
18288        NodeFailureCode::NotGitRepository => "not-git-repository",
18289        NodeFailureCode::WorktreeConflict => "worktree-conflict",
18290        NodeFailureCode::WorktreeProtected => "worktree-protected",
18291        NodeFailureCode::WorktreeDirty => "worktree-dirty",
18292        NodeFailureCode::WorktreeLocked => "worktree-locked",
18293        NodeFailureCode::UnknownManagedWorktreeLease => "unknown-managed-worktree-lease",
18294        NodeFailureCode::ManagedWorktreeBusy => "managed-worktree-busy",
18295        NodeFailureCode::ManagedWorktreeOwnershipConflict => "managed-worktree-ownership-conflict",
18296        NodeFailureCode::ManagedWorktreeProfileRevisionMismatch => {
18297            "managed-worktree-profile-revision-mismatch"
18298        }
18299        NodeFailureCode::ManagedWorktreeRecoveryRequired => "managed-worktree-recovery-required",
18300        NodeFailureCode::StandaloneWorkspaceRecoveryRequired => {
18301            "standalone-workspace-recovery-required"
18302        }
18303        NodeFailureCode::UnknownSession => "unknown-session",
18304        NodeFailureCode::UnknownSessionRecord => "unknown-session-record",
18305        NodeFailureCode::SessionRecordNotResumable => "session-record-not-resumable",
18306        NodeFailureCode::SessionRecordBusy => "session-record-busy",
18307        NodeFailureCode::SessionRecordConflict => "session-record-conflict",
18308        NodeFailureCode::SessionWorkspaceMismatch => "session-workspace-mismatch",
18309        NodeFailureCode::WorkspaceRegistrationRequired => {
18310            "workspace-registration-required"
18311        }
18312        NodeFailureCode::StaleNativeSessionCatalog => "stale-native-session-catalog",
18313        NodeFailureCode::StaleGeneration => "stale-generation",
18314        NodeFailureCode::BackendBusy => "backend-busy",
18315        NodeFailureCode::BackendDisconnected => "backend-disconnected",
18316        NodeFailureCode::BackendOperationFailed => "backend-operation-failed",
18317        NodeFailureCode::ShuttingDown => "shutting-down",
18318    }
18319}
18320
18321fn durable_state_server_error(error: io::Error, category: &'static str) -> NodeServerError {
18322    NodeServerError::DurableState(io::Error::new(error.kind(), category))
18323}
18324
18325fn durable_state_load_error(error: io::Error) -> NodeServerError {
18326    let category = match session_registry::state_load_refusal(&error) {
18327        Some(session_registry::StateLoadRefusal::UnsupportedSchema) => {
18328            DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR
18329        }
18330        Some(session_registry::StateLoadRefusal::PathSemanticsUnsupported) => {
18331            DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR
18332        }
18333        Some(session_registry::StateLoadRefusal::NodeIdentityMismatch) => {
18334            DURABLE_STATE_CONFLICT_ERROR
18335        }
18336        _ => DURABLE_STATE_LOAD_FAILED_ERROR,
18337    };
18338    durable_state_server_error(error, category)
18339}
18340
18341fn unix_time_ms() -> u64 {
18342    std::time::SystemTime::now()
18343        .duration_since(std::time::UNIX_EPOCH)
18344        .unwrap_or_default()
18345        .as_millis()
18346        .min(u64::MAX as u128) as u64
18347}
18348
18349fn detached_record_state(record: &ManagedSessionRecord) -> ManagedSessionState {
18350    if record.provider_session.is_some() {
18351        ManagedSessionState::Dormant
18352    } else {
18353        ManagedSessionState::Unavailable
18354    }
18355}
18356
18357fn active_record_state(
18358    identity_admitted: bool,
18359    provider_session_is_known: bool,
18360) -> ManagedSessionState {
18361    if identity_admitted && !provider_session_is_known {
18362        ManagedSessionState::IdentityPending
18363    } else {
18364        ManagedSessionState::Live
18365    }
18366}
18367
18368fn managed_worktree_record_holders(
18369    records: &[ManagedSessionRecord],
18370) -> Vec<(SessionRecordId, WorkspaceId)> {
18371    records
18372        .iter()
18373        .filter(|record| record.provider_session.is_some())
18374        .map(|record| (record.record_id.clone(), record.workspace_id.clone()))
18375        .collect()
18376}
18377
18378fn managed_spawn_record_ownership(
18379    binding: &SessionBinding,
18380    effective_runtime_policy: ProviderRuntimePolicy,
18381) -> (Option<SessionRecordId>, Option<SessionRecordId>) {
18382    debug_assert_eq!(binding.runtime_policy, effective_runtime_policy);
18383    if effective_runtime_policy.provider_session_identity {
18384        (binding.record_id.clone(), None)
18385    } else {
18386        (None, binding.record_id.clone())
18387    }
18388}
18389
18390#[derive(Debug, Error)]
18391pub enum NodeServerError {
18392    #[error("node endpoint must be a bounded absolute local transport path")]
18393    InvalidEndpoint,
18394    #[error("node access token must contain 1..=4096 bytes")]
18395    InvalidAccessToken,
18396    #[error("node HTTP observer must listen on a loopback address: {0}")]
18397    InvalidApiListen(std::net::SocketAddr),
18398    #[error("node browser bridge must listen on a loopback address: {0}")]
18399    InvalidBridgeListen(std::net::SocketAddr),
18400    #[error("node browser bridge token must contain 1..=4096 bytes")]
18401    InvalidBridgeToken,
18402    #[error("bridge underlay requires --bridge-listen (local HTTP+WS door)")]
18403    BridgeUnderlayRequiresBridgeListen,
18404    #[error("bridge underlay requires GATE4AGENT_BRIDGE_TOKEN (application barrier)")]
18405    BridgeUnderlayRequiresBridgeToken,
18406    #[error("mesh underlay auth token must contain 1..=4096 bytes")]
18407    InvalidBridgeUnderlayToken,
18408    #[error("mesh underlay transport key must be 32 bytes (64 hex digits)")]
18409    InvalidBridgeUnderlayKey,
18410    #[error("node call-home relay must be loopback or a concrete C2 tunnel address with a nonzero port: {0}")]
18411    InvalidCallHome(std::net::SocketAddr),
18412    #[error("node could not reach its call-home relay: {0}")]
18413    CallHomeTransport(#[source] std::io::Error),
18414    #[error("node durable state path must be an absolute file path: {0}")]
18415    InvalidStatePath(String),
18416    #[error("harness MCP helper must be an exact reviewed absolute regular file")]
18417    InvalidHarnessMcpHelper,
18418    #[error("network allowlist catalog path must be an absolute regular file: {0}")]
18419    InvalidNetworkAllowlistCatalogPath(String),
18420    #[error("network allowlist catalog could not be read: {0}")]
18421    NetworkAllowlistCatalogIo(#[source] std::io::Error),
18422    #[error("network allowlist catalog line {line}: {message}")]
18423    InvalidNetworkAllowlistCatalogEntry { line: usize, message: String },
18424    #[error("network allowlist catalog: {0}")]
18425    InvalidNetworkAllowlistCatalog(String),
18426    #[error("network allowlist catalog exceeds the {max}-entry limit")]
18427    NetworkAllowlistCatalogCapacity { max: usize },
18428    #[error("the local state directory is unavailable; supply an explicit state path")]
18429    LocalStateDirectoryUnavailable,
18430    #[error("the local runtime directory is unavailable; supply an explicit endpoint")]
18431    LocalRuntimeDirectoryUnavailable,
18432    #[error("node durable state failed: {0}")]
18433    DurableState(io::Error),
18434    #[error("node requires at least one configured workspace")]
18435    NoWorkspaces,
18436    #[error("workspace '{workspace_id}' root '{path}' is invalid: {message}")]
18437    InvalidWorkspaceRoot {
18438        workspace_id: WorkspaceId,
18439        path: String,
18440        message: String,
18441    },
18442    #[error("duplicate workspace ID: {0}")]
18443    DuplicateWorkspaceId(WorkspaceId),
18444    #[error("workspaces '{first}' and '{second}' resolve to the same root '{root}'")]
18445    DuplicateWorkspaceRoot {
18446        first: WorkspaceId,
18447        second: WorkspaceId,
18448        root: String,
18449    },
18450    #[error("workspace '{0}' uses managed worktrees but has no local profile")]
18451    ManagedWorktreeProfileRequired(WorkspaceId),
18452    #[error("workspace '{0}' has managed worktree profiles but is not in managed mode")]
18453    ManagedWorktreeProfileModeMismatch(WorkspaceId),
18454    #[error("workspace '{workspace_id}' has duplicate managed worktree profile '{profile_id}'")]
18455    DuplicateManagedWorktreeProfile {
18456        workspace_id: WorkspaceId,
18457        profile_id: WorktreeProfileId,
18458    },
18459    #[error("workspace '{workspace_id}' exceeds the {max}-profile managed worktree limit")]
18460    ManagedWorktreeProfileCapacity {
18461        workspace_id: WorkspaceId,
18462        max: usize,
18463    },
18464    #[error("workspace '{workspace_id}' managed worktree profile '{profile_id}' is invalid: {message}")]
18465    InvalidManagedWorktreeProfile {
18466        workspace_id: WorkspaceId,
18467        profile_id: WorktreeProfileId,
18468        message: String,
18469    },
18470    #[error("duplicate node environment profile: {0}")]
18471    DuplicateEnvironmentProfile(SpawnEnvironmentProfileId),
18472    #[error("node environment profile provider is unavailable: {0}")]
18473    EnvironmentProfileProviderUnavailable(AgentId),
18474    #[error("node environment profile capacity is {max}")]
18475    EnvironmentProfileCapacity { max: usize },
18476    #[error("node environment profiles reuse native profile ID '{0}'")]
18477    DuplicateNativeEnvironmentProfile(String),
18478    #[error("node native environment profile is invalid: {0}")]
18479    NativeEnvironmentProfile(String),
18480    #[error("node session-environment materialization root must be absolute")]
18481    InvalidSessionEnvironmentRoot,
18482    #[error("node session-environment materializer is unavailable")]
18483    SessionEnvironmentMaterializerRequired,
18484    #[error("node session-environment materializer failed to initialize")]
18485    SessionEnvironmentMaterializer,
18486    #[error("node bundle catalog is invalid: {0}")]
18487    BundleCatalog(String),
18488    #[error("node delivery store failed to initialize: {0}")]
18489    DeliveryStore(String),
18490    #[error("node context pack store failed to initialize")]
18491    ContextPackStore,
18492    #[error("active agent registry failed: {0}")]
18493    Registry(String),
18494    #[error("node provider contract manifest is invalid: {0}")]
18495    ProviderContractManifest(String),
18496    #[error("named pipe I/O failed: {0}")]
18497    Io(#[from] io::Error),
18498    #[error("node HTTP observer failed: {0}")]
18499    HttpApi(io::Error),
18500    #[error("node browser bridge failed: {0}")]
18501    BridgeApi(io::Error),
18502    #[error(transparent)]
18503    Frame(#[from] FrameError),
18504    #[error("node handshake failed: {0}")]
18505    Handshake(String),
18506    #[error("node authentication frame was not received before the bounded deadline")]
18507    AuthenticationTimedOut,
18508    #[error("node authentication primitive failed: {0}")]
18509    Authentication(String),
18510    #[error("node incarnation identity generation failed: {0}")]
18511    IncarnationIdentity(String),
18512    #[error("node pre-authentication limiter is closed")]
18513    PreauthClosed,
18514    #[error("node authenticated connection limit was reached")]
18515    AuthenticatedConnectionLimit,
18516    #[error("node Ctrl+C/CtrlBreak signal stream closed")]
18517    SignalStreamClosed,
18518    #[error("node stop-all dispatch failed: {0:?}")]
18519    ShutdownDispatch(NodeFailure),
18520    #[error("node shutdown timed out with {active_native_sessions} physical sessions retained")]
18521    ShutdownTimedOut { active_native_sessions: usize },
18522}
18523
18524impl From<crate::network_allowlist_catalog::NetworkAllowlistCatalogError> for NodeServerError {
18525    fn from(error: crate::network_allowlist_catalog::NetworkAllowlistCatalogError) -> Self {
18526        use crate::network_allowlist_catalog::NetworkAllowlistCatalogError as E;
18527        match error {
18528            E::InvalidPath(path) => Self::InvalidNetworkAllowlistCatalogPath(path),
18529            E::Io(error) => Self::NetworkAllowlistCatalogIo(error),
18530            E::InvalidEntry { line, message } => {
18531                Self::InvalidNetworkAllowlistCatalogEntry { line, message }
18532            }
18533            E::InvalidCatalog(message) => Self::InvalidNetworkAllowlistCatalog(message),
18534            E::Capacity { max } => Self::NetworkAllowlistCatalogCapacity { max },
18535        }
18536    }
18537}
18538
18539#[cfg(test)]
18540mod agent_stream_blocked_tests {
18541    use super::*;
18542    use gate4agent_types::{
18543        AdapterId, AdapterVerification, HostRequestOutcome as ProviderHostRequestOutcome,
18544        ProviderSource,
18545    };
18546
18547    fn provider_source(family: AdapterFamily, adapter: &str) -> ProviderSource {
18548        ProviderSource {
18549            family,
18550            binding: AdapterBinding::new(
18551                AdapterId::new(adapter).unwrap(),
18552                "fixture/v1",
18553                AdapterVerification::SyntheticFixture,
18554            )
18555            .unwrap(),
18556        }
18557    }
18558
18559    fn provider_control_event_at(
18560        family: AdapterFamily,
18561        adapter: &str,
18562        source_sequence: u64,
18563        provider_event: ProviderEvent,
18564    ) -> ControlEvent {
18565        ControlEvent {
18566            sequence: 41,
18567            command_id: None,
18568            instance_id: AgentInstanceId(7),
18569            generation: SessionGeneration(3),
18570            event: ControlEventKind::ProviderEvent {
18571                sequence: 8,
18572                source: provider_source(family, adapter),
18573                source_sequence,
18574                event: provider_event,
18575            },
18576        }
18577    }
18578
18579    fn provider_control_event(family: AdapterFamily, provider_event: ProviderEvent) -> ControlEvent {
18580        provider_control_event_at(family, "fixture", 9, provider_event)
18581    }
18582
18583    fn denied_host_request(
18584        by: ProviderHostDecisionAuthority,
18585        reason: Option<String>,
18586    ) -> ControlEvent {
18587        provider_control_event_at(
18588            AdapterFamily::Acp,
18589            "claude-code",
18590            2,
18591            ProviderEvent::HostRequestObserved {
18592                method: "terminal/create".to_owned(),
18593                params_json: String::new(),
18594                decision: ProviderHostRequestDecision::Denied { by },
18595                outcome: ProviderHostRequestOutcome::Executed,
18596                reason,
18597            },
18598        )
18599    }
18600
18601    fn blocked(
18602        event: &ControlEvent,
18603    ) -> (
18604        Option<String>,
18605        String,
18606        BlockAuthorityV1,
18607        Option<String>,
18608        String,
18609        Option<String>,
18610    ) {
18611        let chunk = agent_stream_chunk(event).expect("expected a Blocked chunk");
18612        chunk.validate().expect("minted chunk must validate");
18613        let AgentStreamChunkKindV1::Blocked {
18614            correlation_id,
18615            tool_class,
18616            authority,
18617            reason_kind,
18618            reason,
18619            help,
18620        } = chunk.kind
18621        else {
18622            panic!("expected AgentStreamChunkKindV1::Blocked");
18623        };
18624        (correlation_id, tool_class, authority, reason_kind, reason, help)
18625    }
18626
18627    /// Pins the fleet and every adapter each member declares: the fleet is
18628    /// exactly what `active_registry` says, and no member declares a hook
18629    /// adapter (lifecycle hooks are retired).
18630    #[test]
18631    fn the_enabled_provider_matrix_is_exact() {
18632        let registry = active_registry().unwrap();
18633        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
18634        assert_eq!(
18635            providers
18636                .iter()
18637                .map(|contract| contract.provider.as_str())
18638                .collect::<Vec<_>>(),
18639            // `shell` is the plain PTY provider (operator terminals and smoke tests).
18640            vec!["shell", "claude", "codex", "grok", "kimi"],
18641        );
18642        assert!(adapters.iter().all(|contract| {
18643            !matches!(contract.family, AdapterFamily::Hook | AdapterFamily::ManagedHook)
18644        }));
18645    }
18646
18647    /// A gate-denied host request, WITH its reason text carried all the way
18648    /// from the ACP host, mints a `Blocked` chunk naming the gate.
18649    #[test]
18650    fn gate_denied_host_request_mints_a_blocked_chunk() {
18651        let gate_text =
18652            "blocked by dangerous-command gate: rule=filesystem-wipe, argument=rm -rf /";
18653        let event = denied_host_request(
18654            ProviderHostDecisionAuthority::Gate,
18655            Some(gate_text.to_owned()),
18656        );
18657        let chunk = agent_stream_chunk(&event).expect("expected a Blocked chunk");
18658        assert_eq!(chunk.source_sequence, 2);
18659        let (correlation_id, tool_class, authority, reason_kind, reason, help) = blocked(&event);
18660        assert_eq!(authority, BlockAuthorityV1::HostGate);
18661        assert_eq!(reason, gate_text);
18662        assert_eq!(correlation_id, None);
18663        assert_eq!(reason_kind, None);
18664        assert_eq!(help, None);
18665        assert_eq!(tool_class, "Write");
18666    }
18667
18668    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.";
18669
18670    /// The owner's classifier sample, verbatim, arriving as a
18671    /// `tool_call_update`-derived `ProviderEvent::ToolCompleted` with
18672    /// `non_execution_kind: "permission-rule"` -- mints a `Blocked` chunk with
18673    /// `authority: ProviderPermissionRule`, the `Reason:` sentence as `reason`
18674    /// and the guidance tail as `help`.
18675    #[test]
18676    fn classifier_sample_permission_rule_mints_a_blocked_chunk() {
18677        let event = provider_control_event(
18678            AdapterFamily::Acp,
18679            ProviderEvent::ToolCompleted {
18680                id: "toolu_9".to_owned(),
18681                output: CLASSIFIER_SAMPLE.to_owned(),
18682                is_error: true,
18683                duration_ms: None,
18684                agent_id: None,
18685                non_execution_kind: Some("permission-rule".to_owned()),
18686            },
18687        );
18688        let (correlation_id, _, authority, reason_kind, reason, help) = blocked(&event);
18689        assert_eq!(authority, BlockAuthorityV1::ProviderPermissionRule);
18690        assert_eq!(reason, "Blocked by classifier");
18691        assert_eq!(reason_kind, Some("permission-rule".to_owned()));
18692        assert!(help.as_deref().is_some_and(|help| help.starts_with("If you have other tasks")));
18693        assert!(correlation_id.is_some(), "a tool-correlated block must carry a correlation id");
18694    }
18695
18696    /// A `session/prompt` RPC error with `data.codexErrorInfo:
18697    /// "usageLimitExceeded"` (measured live 2026-09-05) mints a `Blocked` chunk
18698    /// with `authority: ProviderQuota`, the message verbatim as `reason` and the
18699    /// vendor code as `reason_kind`.
18700    #[test]
18701    fn codex_quota_rpc_error_mints_a_blocked_chunk() {
18702        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.";
18703        let event = provider_control_event(
18704            AdapterFamily::Acp,
18705            ProviderEvent::SessionEnded {
18706                result: "JSON-RPC error -32603: Internal error".to_owned(),
18707                cost_usd: None,
18708                is_error: true,
18709                stop_reason: Some(ProviderStopReason::ProviderError {
18710                    code: -32603,
18711                    message: message.to_owned(),
18712                    vendor_code: Some("usageLimitExceeded".to_owned()),
18713                }),
18714            },
18715        );
18716        let (_, tool_class, authority, reason_kind, reason, _) = blocked(&event);
18717        assert_eq!(authority, BlockAuthorityV1::ProviderQuota);
18718        assert_eq!(reason, message);
18719        assert_eq!(reason_kind, Some("usageLimitExceeded".to_owned()));
18720        assert_eq!(tool_class, "Session");
18721    }
18722
18723    /// ACP's `stopReason: "refusal"` mints a `Blocked` chunk with
18724    /// `authority: ProviderRefusal`.
18725    #[test]
18726    fn session_end_stop_reason_refusal_mints_a_blocked_chunk() {
18727        let event = provider_control_event(
18728            AdapterFamily::Acp,
18729            ProviderEvent::SessionEnded {
18730                result: "refusal".to_owned(),
18731                cost_usd: None,
18732                is_error: true,
18733                stop_reason: Some(ProviderStopReason::Refusal),
18734            },
18735        );
18736        let (_, _, authority, _, _, _) = blocked(&event);
18737        assert_eq!(authority, BlockAuthorityV1::ProviderRefusal);
18738    }
18739
18740    /// A failed tool completion with no typed `non_execution_kind` -- or one
18741    /// that only says the turn stopped -- is a plain execution failure, never a
18742    /// block; nor is an agent's own narrative claiming one.
18743    #[test]
18744    fn failures_and_narrative_without_a_typed_block_mint_no_blocked_chunk() {
18745        for non_execution_kind in [None, Some("interrupted".to_owned()), Some("cancelled".to_owned())] {
18746            let event = provider_control_event(
18747                AdapterFamily::Acp,
18748                ProviderEvent::ToolCompleted {
18749                    id: "toolu_1".to_owned(),
18750                    output: "command exited with status 1".to_owned(),
18751                    is_error: true,
18752                    duration_ms: Some(12),
18753                    agent_id: None,
18754                    non_execution_kind,
18755                },
18756            );
18757            assert!(agent_stream_chunk(&event).is_none());
18758        }
18759        let narrative = provider_control_event(
18760            AdapterFamily::Acp,
18761            ProviderEvent::ToolCompleted {
18762                id: "toolu_2".to_owned(),
18763                output: "the harness blocked the write, approval needed".to_owned(),
18764                is_error: false,
18765                duration_ms: Some(3),
18766                agent_id: None,
18767                non_execution_kind: None,
18768            },
18769        );
18770        assert!(agent_stream_chunk(&narrative).is_none());
18771    }
18772
18773    /// `Granted`/`Deferred` decisions never mint a `Blocked` chunk, whatever the
18774    /// execution outcome: a granted request that failed while running was
18775    /// still authorized.
18776    #[test]
18777    fn granted_and_deferred_host_requests_never_mint_a_blocked_chunk() {
18778        for (decision, outcome) in [
18779            (
18780                ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
18781                ProviderHostRequestOutcome::Executed,
18782            ),
18783            (ProviderHostRequestDecision::Deferred, ProviderHostRequestOutcome::Executed),
18784            (
18785                ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
18786                ProviderHostRequestOutcome::Failed {
18787                    error: "terminal/create spawn failed: os error 3".to_owned(),
18788                },
18789            ),
18790        ] {
18791            let event = provider_control_event_at(
18792                AdapterFamily::Acp,
18793                "claude-code",
18794                3,
18795                ProviderEvent::HostRequestObserved {
18796                    method: "fs/read_text_file".to_owned(),
18797                    params_json: String::new(),
18798                    decision: decision.clone(),
18799                    outcome,
18800                    reason: None,
18801                },
18802            );
18803            assert!(agent_stream_chunk(&event).is_none(), "{decision:?}");
18804        }
18805    }
18806
18807    /// A deferred `session/request_permission` resolved by an operator or an
18808    /// expired deadline carries no reason text -- the chunk falls back to a
18809    /// categorical sentence naming the authority rather than inventing detail.
18810    #[test]
18811    fn denied_host_request_with_no_reason_falls_back_to_a_categorical_sentence() {
18812        for (by, expected_authority, expected_reason) in [
18813            (
18814                ProviderHostDecisionAuthority::Operator,
18815                BlockAuthorityV1::Operator,
18816                "declined by the operator",
18817            ),
18818            (
18819                ProviderHostDecisionAuthority::DeadlinePolicy,
18820                BlockAuthorityV1::HostDeadline,
18821                "denied by host policy after the operator deadline expired",
18822            ),
18823        ] {
18824            let (_, _, authority, _, reason, _) = blocked(&denied_host_request(by, None));
18825            assert_eq!(authority, expected_authority);
18826            assert_eq!(reason, expected_reason);
18827        }
18828    }
18829
18830    /// An over-long reason is cut to the wire bound BEFORE the chunk is built,
18831    /// at a safe UTF-8 boundary -- `validate` rejects an over-long value
18832    /// outright rather than truncating.
18833    #[test]
18834    fn overlong_blocked_reason_is_truncated_to_the_wire_bound() {
18835        let oversized = "x".repeat(MAX_ACP_BLOCKED_REASON_BYTES + 500);
18836        let (_, _, _, _, reason, _) = blocked(&denied_host_request(
18837            ProviderHostDecisionAuthority::Policy,
18838            Some(oversized),
18839        ));
18840        assert!(reason.len() <= MAX_ACP_BLOCKED_REASON_BYTES);
18841    }
18842}
18843
18844#[cfg(test)]
18845mod standalone_capability_platform_tests {
18846    use super::*;
18847
18848    #[test]
18849    fn standalone_workspace_capability_requires_windows_identity_guards() {
18850        let capabilities = baseline_capabilities().unwrap();
18851        let advertised = capabilities.iter().any(|capability| {
18852            capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
18853        });
18854        assert_eq!(advertised, cfg!(windows));
18855
18856        let request = NodeRequest::CreateStandaloneWorkspace {
18857            workspace_id: WorkspaceId::new("standalone-platform").unwrap(),
18858            root: OpaqueHostPath::utf8("standalone-platform-root".to_owned()).unwrap(),
18859            initial_branch: None,
18860        };
18861        assert_eq!(
18862            request_uses_unnegotiated_capability(&request, &capabilities),
18863            !cfg!(windows),
18864        );
18865    }
18866}
18867
18868
18869#[cfg(test)]
18870mod station_knobs_resolve_tests {
18871    use super::*;
18872    use gate4agent_catalog::EnvMutation;
18873    use gate4agent_runtime_native::{
18874        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
18875        NativeLaunchProfileId,
18876    };
18877    use std::ffi::OsString;
18878    use std::sync::Arc;
18879
18880    struct EmptyEnvironmentResolver;
18881
18882    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
18883        fn resolve_child_environment(
18884            &self,
18885        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
18886            Ok(vec![EnvMutation {
18887                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
18888                value: None,
18889            }])
18890        }
18891    }
18892
18893    fn agent(value: &str) -> AgentId {
18894        AgentId::new(value).unwrap()
18895    }
18896
18897    fn station_knobs_fixture() -> (NodeShared, crate::protocol::SpawnProfileDefaults) {
18898        let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
18899        let profile_revision =
18900            crate::protocol::SpawnEnvironmentProfileRevision::new("local-claude-r1").unwrap();
18901        let spawn_defaults = crate::protocol::SpawnProfileDefaults {
18902            profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
18903            revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
18904            provider: agent("claude"),
18905            mode: SessionMode::Pty,
18906            terminal_size: gate4agent_types::TerminalSize {
18907                rows: 24,
18908                columns: 80,
18909            },
18910            prompt: None,
18911            bundle_id: None,
18912            context_id: None,
18913            environment_profile_id: Some(profile_id.clone()),
18914        };
18915        let spawn_profiles = SpawnProfileRegistry::new([spawn_defaults.clone()]).unwrap();
18916        let catalog = active_registry().unwrap();
18917        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18918        let control = runtime.native_launch_profile_control();
18919        let node_profile = NodeEnvironmentProfile::new(
18920            profile_id.clone(),
18921            profile_revision,
18922            agent("claude"),
18923            [NativeLaunchProfile::new(
18924                NativeLaunchProfileId::new("local-claude-pty").unwrap(),
18925                agent("claude"),
18926                TransportKind::Pty,
18927                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
18928                Arc::new(EmptyEnvironmentResolver),
18929            )
18930            .unwrap()],
18931        )
18932        .unwrap();
18933        let (binding, native_profiles, _materialization) = node_profile.into_parts();
18934        for native_profile in native_profiles {
18935            runtime.upsert_native_launch_profile(native_profile).unwrap();
18936        }
18937        let workspace = WorkspaceConfig::new(
18938            WorkspaceId::new("primary").unwrap(),
18939            std::env::current_dir().unwrap(),
18940        )
18941        .unwrap();
18942        let shared = NodeShared::new_with_incarnation(
18943            handle,
18944            "fixture-token".to_owned(),
18945            NodeId::new("node-station-knobs-test").unwrap(),
18946            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
18947            vec![workspace],
18948            vec![agent("claude")],
18949            ProviderRuntimeStatuses::default(),
18950            None,
18951            Vec::new(),
18952            Vec::new(),
18953            spawn_profiles,
18954            Some(control),
18955            None,
18956            None,
18957            None,
18958            Vec::new(),
18959            Vec::new(),
18960            Vec::new(),
18961            Vec::new(),
18962            None,
18963            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
18964        );
18965        shared
18966            .environment_profiles
18967            .write()
18968            .unwrap_or_else(|poisoned| poisoned.into_inner())
18969            .insert(binding.id.clone(), binding);
18970        (shared, spawn_defaults)
18971    }
18972
18973    /// Empty-default catalog refuses unknown network_allowlist ids.
18974    /// Feature-off dig2-station-probe keeps browser_profile stub echo.
18975    #[test]
18976    fn resolve_environment_profile_refuses_unknown_network_allowlist() {
18977        let (shared, defaults) = station_knobs_fixture();
18978        let unknown = crate::protocol::SpawnNetworkAllowlistId::new("not-registered").unwrap();
18979        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18980        let spec = crate::protocol::SpawnSpec {
18981            target: crate::protocol::SpawnTarget {
18982                node_id: shared.node_id.clone(),
18983                workspace_id: WorkspaceId::new("primary").unwrap(),
18984                worktree_id: None,
18985            },
18986            profile_id: defaults.profile_id.clone(),
18987            expected_profile_revision: defaults.revision.clone(),
18988            overrides: crate::protocol::SpawnOverrides {
18989                network_allowlist: Some(unknown),
18990                browser_profile_id: Some(browser),
18991                ..crate::protocol::SpawnOverrides::default()
18992            },
18993            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18994            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18995                "station-knobs-unknown-allowlist",
18996            )
18997            .unwrap(),
18998            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18999        };
19000        let resolved = spec.resolve(&defaults).unwrap();
19001        let failure = shared
19002            .resolve_environment_profile(&resolved)
19003            .expect_err("unknown allowlist must refuse");
19004        assert_eq!(failure.code, NodeFailureCode::UnknownNetworkAllowlist);
19005    }
19006
19007    /// Registered network_allowlist (+ browser_profile_id when feature off)
19008    /// echo onto the env-profile receipt. Empty catalog still allows omit
19009    /// (None). Empty/whitespace refuse is type-level. Feature-off
19010    /// dig2-station-probe keeps stub browser echo; feature-on refuse is
19011    /// covered by `…_refuses_browser_profile_when_station_probe_fails`.
19012    #[test]
19013    fn resolve_environment_profile_echoes_registered_station_network_and_browser_knobs() {
19014        let (shared, defaults) = station_knobs_fixture();
19015        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19016        #[cfg(not(feature = "dig2-station-probe"))]
19017        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
19018        shared
19019            .network_allowlist_catalog
19020            .write()
19021            .unwrap_or_else(|poisoned| poisoned.into_inner())
19022            .insert_id(allowlist.clone())
19023            .unwrap();
19024        let spec = crate::protocol::SpawnSpec {
19025            target: crate::protocol::SpawnTarget {
19026                node_id: shared.node_id.clone(),
19027                workspace_id: WorkspaceId::new("primary").unwrap(),
19028                worktree_id: None,
19029            },
19030            profile_id: defaults.profile_id.clone(),
19031            expected_profile_revision: defaults.revision.clone(),
19032            overrides: crate::protocol::SpawnOverrides {
19033                network_allowlist: Some(allowlist.clone()),
19034                #[cfg(not(feature = "dig2-station-probe"))]
19035                browser_profile_id: Some(browser.clone()),
19036                ..crate::protocol::SpawnOverrides::default()
19037            },
19038            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19039            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-echo")
19040                .unwrap(),
19041            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19042        };
19043        let resolved = spec.resolve(&defaults).unwrap();
19044        assert_eq!(resolved.network_allowlist.as_ref(), Some(&allowlist));
19045        #[cfg(not(feature = "dig2-station-probe"))]
19046        assert_eq!(resolved.browser_profile_id.as_ref(), Some(&browser));
19047        let receipt = shared
19048            .resolve_environment_profile(&resolved)
19049            .expect("resolve ok")
19050            .expect("env profile present");
19051        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
19052        #[cfg(not(feature = "dig2-station-probe"))]
19053        assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
19054        #[cfg(feature = "dig2-station-probe")]
19055        assert_eq!(receipt.browser_profile_id, None);
19056        assert_eq!(receipt.profile_id.as_str(), "local-claude");
19057
19058        let omitted = crate::protocol::SpawnSpec {
19059            overrides: crate::protocol::SpawnOverrides::default(),
19060            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-omit")
19061                .unwrap(),
19062            ..spec
19063        };
19064        let resolved_omit = omitted.resolve(&defaults).unwrap();
19065        let bare = shared
19066            .resolve_environment_profile(&resolved_omit)
19067            .expect("resolve ok")
19068            .expect("env profile present");
19069        assert_eq!(bare.network_allowlist, None);
19070        assert_eq!(bare.browser_profile_id, None);
19071    }
19072
19073    /// Feature `dig2-station-probe`: browser_profile_id set refuses when the
19074    /// cheap station probe cannot succeed. On non-Windows (this CI host) that
19075    /// is `BrowserStationProbeUnavailable`. Never cookies on C2. Lease map is
19076    /// unit-tested separately (probe refuses before lease on Linux).
19077    #[cfg(feature = "dig2-station-probe")]
19078    #[test]
19079    fn resolve_environment_profile_refuses_browser_profile_when_station_probe_fails() {
19080        let (shared, defaults) = station_knobs_fixture();
19081        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
19082        let spec = crate::protocol::SpawnSpec {
19083            target: crate::protocol::SpawnTarget {
19084                node_id: shared.node_id.clone(),
19085                workspace_id: WorkspaceId::new("primary").unwrap(),
19086                worktree_id: None,
19087            },
19088            profile_id: defaults.profile_id.clone(),
19089            expected_profile_revision: defaults.revision.clone(),
19090            overrides: crate::protocol::SpawnOverrides {
19091                browser_profile_id: Some(browser),
19092                ..crate::protocol::SpawnOverrides::default()
19093            },
19094            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19095            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19096                "station-knobs-probe-refuse",
19097            )
19098            .unwrap(),
19099            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19100        };
19101        let resolved = spec.resolve(&defaults).unwrap();
19102        let failure = shared
19103            .resolve_environment_profile(&resolved)
19104            .expect_err("browser_profile with failing probe must refuse");
19105        #[cfg(windows)]
19106        {
19107            assert!(
19108                matches!(
19109                    failure.code,
19110                    NodeFailureCode::BrowserStationUnreachable
19111                        | NodeFailureCode::BrowserStationProbeUnavailable
19112                ),
19113                "unexpected code {:?}",
19114                failure.code
19115            );
19116        }
19117        #[cfg(not(windows))]
19118        {
19119            assert_eq!(
19120                failure.code,
19121                NodeFailureCode::BrowserStationProbeUnavailable
19122            );
19123        }
19124    }
19125
19126    /// Feature on: omitting browser_profile_id still resolves (no probe).
19127    #[cfg(feature = "dig2-station-probe")]
19128    #[test]
19129    fn resolve_environment_profile_skips_station_probe_when_browser_profile_omitted() {
19130        let (shared, defaults) = station_knobs_fixture();
19131        let spec = crate::protocol::SpawnSpec {
19132            target: crate::protocol::SpawnTarget {
19133                node_id: shared.node_id.clone(),
19134                workspace_id: WorkspaceId::new("primary").unwrap(),
19135                worktree_id: None,
19136            },
19137            profile_id: defaults.profile_id.clone(),
19138            expected_profile_revision: defaults.revision.clone(),
19139            overrides: crate::protocol::SpawnOverrides::default(),
19140            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19141            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19142                "station-knobs-probe-omit",
19143            )
19144            .unwrap(),
19145            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19146        };
19147        let resolved = spec.resolve(&defaults).unwrap();
19148        let receipt = shared
19149            .resolve_environment_profile(&resolved)
19150            .expect("omit browser_profile must not probe")
19151            .expect("env profile present");
19152        assert_eq!(receipt.browser_profile_id, None);
19153    }
19154
19155    /// Feature dig2-station-probe: exclusive BrowserStationLease on NodeShared
19156    /// refuses a second holder for the same opaque profile id; release on
19157    /// remove_binding allows reacquire. Never ImportSession/cookies on C2.
19158    #[cfg(feature = "dig2-station-probe")]
19159    #[test]
19160    fn browser_station_lease_exclusive_and_clears_on_remove_binding() {
19161        let (shared, _defaults) = station_knobs_fixture();
19162        let profile = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
19163        let workspace_id = WorkspaceId::new("primary").unwrap();
19164        let address_a = SessionAddress {
19165            workspace_id: workspace_id.clone(),
19166            session: SessionKey {
19167                instance_id: AgentInstanceId(101),
19168                generation: SessionGeneration(1),
19169            },
19170        };
19171        let address_b = SessionAddress {
19172            workspace_id: workspace_id.clone(),
19173            session: SessionKey {
19174                instance_id: AgentInstanceId(102),
19175                generation: SessionGeneration(1),
19176            },
19177        };
19178        let guard_a = crate::dig2_station_lease::acquire_lease(
19179            &shared.browser_station_leases,
19180            profile.clone(),
19181            address_a.session.instance_id,
19182            1,
19183        )
19184        .expect("first lease");
19185        let busy = crate::dig2_station_lease::acquire_lease(
19186            &shared.browser_station_leases,
19187            profile.clone(),
19188            address_b.session.instance_id,
19189            2,
19190        )
19191        .expect_err("second holder must refuse");
19192        assert!(matches!(
19193            busy,
19194            crate::dig2_station_lease::BrowserStationLeaseError::ProfileBusy { .. }
19195        ));
19196        // Simulate a bound session so remove_binding clears the lease.
19197        shared.bind_session_with_policy(
19198            &address_a,
19199            ProviderRuntimePolicy::raw_pty(),
19200            None,
19201        );
19202        guard_a.retain();
19203        assert!(shared.remove_binding(&address_a).is_some());
19204        crate::dig2_station_lease::acquire_lease(
19205            &shared.browser_station_leases,
19206            profile,
19207            address_b.session.instance_id,
19208            3,
19209        )
19210        .expect("reacquire after clear")
19211        .retain();
19212        assert!(
19213            crate::dig2_station_lease::release_lease_for_holder(
19214                &shared.browser_station_leases,
19215                address_b.session.instance_id,
19216            )
19217        );
19218    }
19219
19220    /// Feature-off: no browser_station_leases field; stub echo documented in
19221    /// resolve_environment_profile_echoes_registered_station_network_and_browser_knobs.
19222    #[cfg(not(feature = "dig2-station-probe"))]
19223    #[test]
19224    fn browser_station_lease_feature_off_keeps_stub_without_lease_table() {
19225        let (shared, defaults) = station_knobs_fixture();
19226        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
19227        let spec = crate::protocol::SpawnSpec {
19228            target: crate::protocol::SpawnTarget {
19229                node_id: shared.node_id.clone(),
19230                workspace_id: WorkspaceId::new("primary").unwrap(),
19231                worktree_id: None,
19232            },
19233            profile_id: defaults.profile_id.clone(),
19234            expected_profile_revision: defaults.revision.clone(),
19235            overrides: crate::protocol::SpawnOverrides {
19236                browser_profile_id: Some(browser.clone()),
19237                ..crate::protocol::SpawnOverrides::default()
19238            },
19239            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19240            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("lease-stub-off")
19241                .unwrap(),
19242            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19243        };
19244        let resolved = spec.resolve(&defaults).unwrap();
19245        let receipt = shared
19246            .resolve_environment_profile(&resolved)
19247            .expect("stub resolve")
19248            .expect("env profile");
19249        assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
19250    }
19251
19252
19253    /// Startup file loader registers ids into the empty-default catalog.
19254    /// Feature-off dig2 probe stays stub. Never secrets on C2.
19255    #[test]
19256    fn network_allowlist_catalog_loads_from_temp_file() {
19257        let unique = std::time::SystemTime::now()
19258            .duration_since(std::time::UNIX_EPOCH)
19259            .unwrap()
19260            .as_nanos();
19261        let path = std::env::temp_dir().join(format!(
19262            "gate4agent-node-allowlist-config-{}-{unique}.txt",
19263            std::process::id(),
19264        ));
19265        std::fs::write(
19266            &path,
19267            "# opaque station allowlist ids\negress-default\nlab-egress\n",
19268        )
19269        .unwrap();
19270        let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
19271            .expect("temp catalog file must load");
19272        let _ = std::fs::remove_file(&path);
19273        assert_eq!(loaded.len(), 2);
19274        let (shared, defaults) = station_knobs_fixture();
19275        {
19276            let mut catalog = shared
19277                .network_allowlist_catalog
19278                .write()
19279                .unwrap_or_else(|poisoned| poisoned.into_inner());
19280            *catalog = loaded;
19281        }
19282        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19283        let spec = crate::protocol::SpawnSpec {
19284            target: crate::protocol::SpawnTarget {
19285                node_id: shared.node_id.clone(),
19286                workspace_id: WorkspaceId::new("primary").unwrap(),
19287                worktree_id: None,
19288            },
19289            profile_id: defaults.profile_id.clone(),
19290            expected_profile_revision: defaults.revision.clone(),
19291            overrides: crate::protocol::SpawnOverrides {
19292                network_allowlist: Some(allowlist.clone()),
19293                ..crate::protocol::SpawnOverrides::default()
19294            },
19295            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19296            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19297                "station-knobs-catalog-file",
19298            )
19299            .unwrap(),
19300            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19301        };
19302        let resolved = spec.resolve(&defaults).unwrap();
19303        let receipt = shared
19304            .resolve_environment_profile(&resolved)
19305            .expect("registered via file load")
19306            .expect("env profile present");
19307        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
19308    }
19309
19310    /// Snapshot launch inventory echoes registered catalog ids (opaque).
19311    /// Dig2 station probe is orthogonal (feature `dig2-station-probe`). Never secrets on C2.
19312    #[test]
19313    fn snapshot_launch_inventory_lists_network_allowlist_catalog_ids() {
19314        let (shared, _defaults) = station_knobs_fixture();
19315        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19316        shared
19317            .network_allowlist_catalog
19318            .write()
19319            .unwrap_or_else(|poisoned| poisoned.into_inner())
19320            .insert_id(allowlist.clone())
19321            .unwrap();
19322        let snapshot = shared.snapshot();
19323        let inventory = snapshot
19324            .launch_inventory
19325            .expect("launch inventory present");
19326        let ids = inventory
19327            .network_allowlists
19328            .expect("non-empty catalog surfaces ids");
19329        assert_eq!(ids, vec![allowlist]);
19330    }
19331
19332    /// Track B: registered id with provider_native codex_network_access refuses
19333    /// clearly for Claude (fixture provider). Unknown id still UnknownNetworkAllowlist.
19334    /// Never secrets on C2. Codex Moderate maps to `-c` overlay; RO/FullAuto refuse.
19335    #[test]
19336    fn resolve_environment_profile_refuses_unsupported_provider_native_mapping() {
19337        let (shared, defaults) = station_knobs_fixture();
19338        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-only").unwrap();
19339        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19340            id: allowlist.clone(),
19341            permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
19342                protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19343                peer: "127.0.0.1:443".to_owned(),
19344            }],
19345            provider_native: Some(
19346                crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
19347                    codex_network_access: Some(true),
19348                },
19349            ),
19350        };
19351        shared
19352            .network_allowlist_catalog
19353            .write()
19354            .unwrap_or_else(|poisoned| poisoned.into_inner())
19355            .insert(entry)
19356            .unwrap();
19357        let spec = crate::protocol::SpawnSpec {
19358            target: crate::protocol::SpawnTarget {
19359                node_id: shared.node_id.clone(),
19360                workspace_id: WorkspaceId::new("primary").unwrap(),
19361                worktree_id: None,
19362            },
19363            profile_id: defaults.profile_id.clone(),
19364            expected_profile_revision: defaults.revision.clone(),
19365            overrides: crate::protocol::SpawnOverrides {
19366                network_allowlist: Some(allowlist),
19367                ..crate::protocol::SpawnOverrides::default()
19368            },
19369            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19370            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19371                "station-knobs-unsupported-mapping",
19372            )
19373            .unwrap(),
19374            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19375        };
19376        let resolved = spec.resolve(&defaults).unwrap();
19377        assert_eq!(resolved.provider.as_str(), "claude");
19378        let failure = shared
19379            .resolve_environment_profile(&resolved)
19380            .expect_err("claude + codex_network_access must refuse");
19381        assert_eq!(
19382            failure.code,
19383            NodeFailureCode::UnsupportedNetworkAllowlistMapping
19384        );
19385    }
19386
19387    /// Codex Moderate + codex_network_access yields `-c` overlay argv.
19388    /// ReadOnly / FullAuto refuse UnsupportedNetworkAllowlistMapping.
19389    /// Never secrets on C2.
19390    #[test]
19391    fn resolve_codex_network_access_overlay_moderate_only() {
19392        let (shared, defaults) = station_knobs_fixture();
19393        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-net").unwrap();
19394        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19395            id: allowlist.clone(),
19396            permits: Vec::new(),
19397            provider_native: Some(
19398                crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
19399                    codex_network_access: Some(true),
19400                },
19401            ),
19402        };
19403        shared
19404            .network_allowlist_catalog
19405            .write()
19406            .unwrap_or_else(|poisoned| poisoned.into_inner())
19407            .insert(entry)
19408            .unwrap();
19409
19410        let overlay = shared
19411            .resolve_codex_network_access_overlay_args(
19412                &agent("codex"),
19413                SessionMode::Pty,
19414                ApprovalLevel::Moderate,
19415                Some(&allowlist),
19416            )
19417            .expect("Moderate Codex maps network_access");
19418        assert_eq!(
19419            overlay,
19420            vec![
19421                std::ffi::OsString::from("-c"),
19422                std::ffi::OsString::from("sandbox_workspace_write.network_access=true"),
19423            ]
19424        );
19425
19426        let acp = shared
19427            .resolve_codex_network_access_overlay_args(
19428                &agent("codex"),
19429                SessionMode::Acp,
19430                ApprovalLevel::Moderate,
19431                Some(&allowlist),
19432            )
19433            .unwrap();
19434        assert_eq!(acp, overlay);
19435
19436        let inline = shared
19437            .resolve_codex_network_access_overlay_args(
19438                &agent("codex"),
19439                SessionMode::Inline,
19440                ApprovalLevel::Moderate,
19441                Some(&allowlist),
19442            )
19443            .unwrap_err();
19444        assert_eq!(
19445            inline.code,
19446            NodeFailureCode::UnsupportedNetworkAllowlistMapping
19447        );
19448
19449        for level in [ApprovalLevel::ReadOnly, ApprovalLevel::FullAuto] {
19450            let spec = crate::protocol::SpawnSpec {
19451                target: crate::protocol::SpawnTarget {
19452                    node_id: shared.node_id.clone(),
19453                    workspace_id: WorkspaceId::new("primary").unwrap(),
19454                    worktree_id: None,
19455                },
19456                profile_id: defaults.profile_id.clone(),
19457                expected_profile_revision: defaults.revision.clone(),
19458                overrides: crate::protocol::SpawnOverrides {
19459                    provider: crate::protocol::SpawnOverride::Set {
19460                        value: agent("codex"),
19461                    },
19462                    approval_level: Some(level),
19463                    network_allowlist: Some(allowlist.clone()),
19464                    ..crate::protocol::SpawnOverrides::default()
19465                },
19466                deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19467                idempotency_key: crate::protocol::SpawnIdempotencyKey::new(format!(
19468                    "codex-net-refuse-{}",
19469                    match level {
19470                        ApprovalLevel::ReadOnly => "ro",
19471                        ApprovalLevel::FullAuto => "fa",
19472                        _ => "other",
19473                    }
19474                ))
19475                .unwrap(),
19476                required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19477            };
19478            let resolved = spec.resolve(&defaults).unwrap();
19479            let failure = shared
19480                .resolve_environment_profile(&resolved)
19481                .expect_err("RO/FullAuto must refuse");
19482            assert_eq!(
19483                failure.code,
19484                NodeFailureCode::UnsupportedNetworkAllowlistMapping
19485            );
19486        }
19487    }
19488
19489    /// Claude PTY + catalog permits → ProviderHome `--settings` sidecar with
19490    /// sandbox.network.allowedDomains. ACP / missing ProviderHome refuse.
19491    /// Never secrets on C2; does not invent provider_native Claude keys.
19492    #[test]
19493    fn resolve_claude_settings_network_overlay_pty_writes_sidecar() {
19494        let (shared, _defaults) = station_knobs_fixture();
19495        let allowlist =
19496            crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-domains").unwrap();
19497        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19498            id: allowlist.clone(),
19499            permits: vec![
19500                crate::network_allowlist_catalog::NetworkPermitSketch {
19501                    protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19502                    peer: "github.com:443".to_owned(),
19503                },
19504                crate::network_allowlist_catalog::NetworkPermitSketch {
19505                    protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19506                    peer: "*.npmjs.org".to_owned(),
19507                },
19508            ],
19509            provider_native: None,
19510        };
19511        shared
19512            .network_allowlist_catalog
19513            .write()
19514            .unwrap_or_else(|poisoned| poisoned.into_inner())
19515            .insert(entry)
19516            .unwrap();
19517
19518        let unique = std::time::SystemTime::now()
19519            .duration_since(std::time::UNIX_EPOCH)
19520            .unwrap()
19521            .as_nanos();
19522        let provider_home = std::env::temp_dir().join(format!(
19523            "gate4agent-claude-provider-home-{}-{unique}",
19524            std::process::id(),
19525        ));
19526        std::fs::create_dir_all(&provider_home).unwrap();
19527
19528        let overlay = shared
19529            .resolve_network_allowlist_overlay_args(
19530                &agent("claude"),
19531                SessionMode::Pty,
19532                ApprovalLevel::Moderate,
19533                Some(&allowlist),
19534                Some(provider_home.as_path()),
19535            )
19536            .expect("Claude PTY settings overlay on unix");
19537        let settings_path = provider_home.join(
19538            crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
19539        );
19540        assert_eq!(
19541            overlay,
19542            vec![
19543                std::ffi::OsString::from("--settings"),
19544                std::ffi::OsString::from(settings_path.as_os_str()),
19545            ]
19546        );
19547        let body = std::fs::read_to_string(&settings_path).unwrap();
19548        let value: serde_json::Value = serde_json::from_str(&body).unwrap();
19549        assert_eq!(value["sandbox"]["enabled"], true);
19550        assert_eq!(
19551            value["sandbox"]["network"]["allowedDomains"],
19552            serde_json::json!(["github.com", "*.npmjs.org"])
19553        );
19554        assert!(settings_path.starts_with(&provider_home));
19555
19556        let acp = shared
19557            .resolve_network_allowlist_overlay_args(
19558                &agent("claude"),
19559                SessionMode::Acp,
19560                ApprovalLevel::Moderate,
19561                Some(&allowlist),
19562                Some(provider_home.as_path()),
19563            )
19564            .expect_err("Claude ACP must refuse settings overlay");
19565        assert_eq!(
19566            acp.code,
19567            NodeFailureCode::UnsupportedNetworkAllowlistMapping
19568        );
19569
19570        let no_home = shared
19571            .resolve_network_allowlist_overlay_args(
19572                &agent("claude"),
19573                SessionMode::Pty,
19574                ApprovalLevel::Moderate,
19575                Some(&allowlist),
19576                None,
19577            )
19578            .expect_err("Claude overlay requires ProviderHome");
19579        assert_eq!(
19580            no_home.code,
19581            NodeFailureCode::UnsupportedNetworkAllowlistMapping
19582        );
19583
19584        let bare = crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-bare").unwrap();
19585        shared
19586            .network_allowlist_catalog
19587            .write()
19588            .unwrap_or_else(|poisoned| poisoned.into_inner())
19589            .insert_id(bare.clone())
19590            .unwrap();
19591        let empty = shared
19592            .resolve_network_allowlist_overlay_args(
19593                &agent("claude"),
19594                SessionMode::Pty,
19595                ApprovalLevel::Moderate,
19596                Some(&bare),
19597                Some(provider_home.as_path()),
19598            )
19599            .unwrap();
19600        assert!(empty.is_empty());
19601
19602        let _ = std::fs::remove_dir_all(&provider_home);
19603    }
19604
19605    /// Kimi / Grok with permits stay without invented argv overlays.
19606    #[test]
19607    fn resolve_kimi_grok_network_overlay_stays_empty_without_native() {
19608        let (shared, _defaults) = station_knobs_fixture();
19609        let allowlist =
19610            crate::protocol::SpawnNetworkAllowlistId::new("egress-kimi-permits").unwrap();
19611        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19612            id: allowlist.clone(),
19613            permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
19614                protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19615                peer: "example.com:443".to_owned(),
19616            }],
19617            provider_native: None,
19618        };
19619        shared
19620            .network_allowlist_catalog
19621            .write()
19622            .unwrap_or_else(|poisoned| poisoned.into_inner())
19623            .insert(entry)
19624            .unwrap();
19625        for provider in ["kimi", "grok"] {
19626            let overlay = shared
19627                .resolve_network_allowlist_overlay_args(
19628                    &agent(provider),
19629                    SessionMode::Pty,
19630                    ApprovalLevel::Moderate,
19631                    Some(&allowlist),
19632                    None,
19633                )
19634                .unwrap();
19635            assert!(
19636                overlay.is_empty(),
19637                "{provider} must not invent network argv from permits"
19638            );
19639        }
19640    }
19641
19642    /// Track B: JSON v2 catalog file loads permits; inventory still ids only.
19643    #[test]
19644    fn network_allowlist_catalog_loads_json_v2_and_lists_ids_only() {
19645        let unique = std::time::SystemTime::now()
19646            .duration_since(std::time::UNIX_EPOCH)
19647            .unwrap()
19648            .as_nanos();
19649        let path = std::env::temp_dir().join(format!(
19650            "gate4agent-node-allowlist-json-{}-{unique}.json",
19651            std::process::id(),
19652        ));
19653        std::fs::write(
19654            &path,
19655            r#"{
19656              "schema_version": 2,
19657              "entries": [
19658                {
19659                  "id": "egress-default",
19660                  "permits": [{ "protocol": "tcp", "peer": "127.0.0.1:443" }]
19661                },
19662                { "id": "lab-egress" }
19663              ]
19664            }"#,
19665        )
19666        .unwrap();
19667        let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
19668            .expect("json v2 catalog must load");
19669        let _ = std::fs::remove_file(&path);
19670        assert_eq!(loaded.len(), 2);
19671        let entry = loaded
19672            .get(&crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap())
19673            .unwrap();
19674        assert_eq!(entry.permits.len(), 1);
19675        let (shared, defaults) = station_knobs_fixture();
19676        {
19677            let mut catalog = shared
19678                .network_allowlist_catalog
19679                .write()
19680                .unwrap_or_else(|poisoned| poisoned.into_inner());
19681            *catalog = loaded;
19682        }
19683        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19684        let spec = crate::protocol::SpawnSpec {
19685            target: crate::protocol::SpawnTarget {
19686                node_id: shared.node_id.clone(),
19687                workspace_id: WorkspaceId::new("primary").unwrap(),
19688                worktree_id: None,
19689            },
19690            profile_id: defaults.profile_id.clone(),
19691            expected_profile_revision: defaults.revision.clone(),
19692            overrides: crate::protocol::SpawnOverrides {
19693                network_allowlist: Some(allowlist.clone()),
19694                ..crate::protocol::SpawnOverrides::default()
19695            },
19696            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19697            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19698                "station-knobs-catalog-json",
19699            )
19700            .unwrap(),
19701            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19702        };
19703        let resolved = spec.resolve(&defaults).unwrap();
19704        let receipt = shared
19705            .resolve_environment_profile(&resolved)
19706            .expect("permits-only entry ok for claude")
19707            .expect("env profile present");
19708        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
19709        let inventory = shared
19710            .snapshot()
19711            .launch_inventory
19712            .expect("launch inventory present");
19713        let ids = inventory.network_allowlists.expect("ids surface");
19714        assert_eq!(
19715            ids,
19716            vec![
19717                crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap(),
19718                crate::protocol::SpawnNetworkAllowlistId::new("lab-egress").unwrap(),
19719            ]
19720        );
19721    }
19722
19723
19724}
19725
19726
19727#[cfg(test)]
19728mod workspace_environment_collision_tests {
19729    use super::*;
19730    use crate::session_environment::{
19731        NodeSessionMaterializationProfile, NodeSessionPathBinding, NodeSessionPathClass,
19732    };
19733    use gate4agent_catalog::EnvMutation;
19734    use gate4agent_runtime_native::{
19735        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
19736        NativeLaunchProfileId,
19737    };
19738    use std::ffi::OsString;
19739    use std::sync::Arc;
19740
19741    struct EmptyEnvironmentResolver;
19742
19743    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19744        fn resolve_child_environment(
19745            &self,
19746        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19747            Ok(vec![EnvMutation {
19748                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19749                value: None,
19750            }])
19751        }
19752    }
19753
19754    fn agent(value: &str) -> AgentId {
19755        AgentId::new(value).unwrap()
19756    }
19757
19758    /// Two Claude ProviderHome profiles (`CLAUDE_CONFIG_DIR` A vs B) on one node.
19759    fn claude_config_dir_collision_fixture() -> (
19760        NodeShared,
19761        ResolvedEnvironmentProfileReceipt,
19762        ResolvedEnvironmentProfileReceipt,
19763    ) {
19764        let catalog = active_registry().unwrap();
19765        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19766        let control = runtime.native_launch_profile_control();
19767        let workspace = WorkspaceConfig::new(
19768            WorkspaceId::new("primary").unwrap(),
19769            std::env::current_dir().unwrap(),
19770        )
19771        .unwrap();
19772        let shared = NodeShared::new_with_incarnation(
19773            handle,
19774            "fixture-token".to_owned(),
19775            NodeId::new("node-env-collision-test").unwrap(),
19776            NodeIncarnationId::from_bytes([3; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19777            vec![workspace],
19778            vec![agent("claude")],
19779            ProviderRuntimeStatuses::default(),
19780            None,
19781            Vec::new(),
19782            Vec::new(),
19783            SpawnProfileRegistry::default(),
19784            Some(control),
19785            None,
19786            None,
19787            None,
19788            Vec::new(),
19789            Vec::new(),
19790            Vec::new(),
19791            Vec::new(),
19792            None,
19793            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19794        );
19795
19796        let mut receipts = Vec::new();
19797        for (suffix, native_suffix) in [("a", "a-pty"), ("b", "b-pty")] {
19798            let profile_id =
19799                SpawnEnvironmentProfileId::new(format!("claude-config-dir-{suffix}")).unwrap();
19800            let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(format!(
19801                "claude-config-dir-{suffix}-r1"
19802            ))
19803            .unwrap();
19804            let materialization = NodeSessionMaterializationProfile::new(
19805                Vec::new(),
19806                vec![NodeSessionPathBinding::new(
19807                    "CLAUDE_CONFIG_DIR",
19808                    NodeSessionPathClass::ProviderHome,
19809                )
19810                .unwrap()],
19811                Vec::new(),
19812            )
19813            .unwrap();
19814            let node_profile = NodeEnvironmentProfile::new_with_materialization(
19815                profile_id.clone(),
19816                profile_revision.clone(),
19817                agent("claude"),
19818                [NativeLaunchProfile::new(
19819                    NativeLaunchProfileId::new(format!("claude-config-dir-{native_suffix}"))
19820                        .unwrap(),
19821                    agent("claude"),
19822                    TransportKind::Pty,
19823                    vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19824                    Arc::new(EmptyEnvironmentResolver),
19825                )
19826                .unwrap()],
19827                Some(materialization),
19828            )
19829            .unwrap();
19830            let (binding, native_profiles, materialization) = node_profile.into_parts();
19831            for native_profile in native_profiles {
19832                runtime.upsert_native_launch_profile(native_profile).unwrap();
19833            }
19834            shared
19835                .environment_profiles
19836                .write()
19837                .unwrap_or_else(|poisoned| poisoned.into_inner())
19838                .insert(binding.id.clone(), binding);
19839            shared
19840                .environment_materialization_profiles
19841                .write()
19842                .unwrap_or_else(|poisoned| poisoned.into_inner())
19843                .insert(profile_id.clone(), materialization.unwrap());
19844            receipts.push(ResolvedEnvironmentProfileReceipt {
19845                profile_id,
19846                profile_revision,
19847                network_allowlist: None,
19848                browser_profile_id: None,
19849            });
19850        }
19851        let profile_b = receipts.pop().unwrap();
19852        let profile_a = receipts.pop().unwrap();
19853        (shared, profile_a, profile_b)
19854    }
19855
19856    #[test]
19857    fn refuse_distinct_claude_config_dir_profiles_on_same_workspace_cwd() {
19858        let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
19859        let primary = WorkspaceId::new("primary").unwrap();
19860        let worktree = WorkspaceId::new("worktree-b").unwrap();
19861        let address = SessionAddress {
19862            workspace_id: primary.clone(),
19863            session: SessionKey {
19864                instance_id: AgentInstanceId(41),
19865                generation: SessionGeneration(1),
19866            },
19867        };
19868        shared.bind_session_with_policy(
19869            &address,
19870            ProviderRuntimePolicy::raw_pty(),
19871            Some(profile_a.clone()),
19872        );
19873
19874        let refused = shared
19875            .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_b))
19876            .unwrap_err();
19877        assert_eq!(refused.code, NodeFailureCode::WorkspaceBusy);
19878
19879        // Some vs None on the same cwd is also a collision.
19880        let refused_none = shared
19881            .ensure_workspace_environment_profile_compatible(&primary, None)
19882            .unwrap_err();
19883        assert_eq!(refused_none.code, NodeFailureCode::WorkspaceBusy);
19884
19885        // Same profile on the same workspace remains allowed.
19886        shared
19887            .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_a))
19888            .unwrap();
19889
19890        // Distinct workspace id (managed-worktree parallel path) allows profile B.
19891        shared
19892            .ensure_workspace_environment_profile_compatible(&worktree, Some(&profile_b))
19893            .unwrap();
19894    }
19895
19896    #[tokio::test]
19897    async fn spawn_session_refuses_conflicting_environment_profile_before_mutation() {
19898        let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
19899        let primary = WorkspaceId::new("primary").unwrap();
19900        let address = SessionAddress {
19901            workspace_id: primary.clone(),
19902            session: SessionKey {
19903                instance_id: AgentInstanceId(41),
19904                generation: SessionGeneration(1),
19905            },
19906        };
19907        shared.bind_session_with_policy(
19908            &address,
19909            ProviderRuntimePolicy::raw_pty(),
19910            Some(profile_a),
19911        );
19912
19913        let failure = shared
19914            .spawn_session_with_deadline(
19915                primary,
19916                agent("claude"),
19917                SessionMode::Pty,
19918                gate4agent_types::TerminalSize {
19919                    rows: 24,
19920                    columns: 80,
19921                },
19922                None,
19923                Some(profile_b),
19924                None,
19925                None,
19926                None,
19927                None,
19928                SpawnRecordPolicy::ProviderIdentityOnly,
19929                Some(Instant::now() + Duration::from_secs(30)),
19930                &[],
19931                None,
19932                ApprovalLevel::default(),
19933                None,
19934                None,
19935            )
19936            .await
19937            .unwrap_err();
19938        assert_eq!(failure.code, NodeFailureCode::WorkspaceBusy);
19939        // Guard runs before instance id mint / bind.
19940        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
19941        assert!(shared.handle.snapshot().sessions.is_empty());
19942    }
19943}
19944
19945#[cfg(test)]
19946mod spawn_characterization_tests {
19947    use super::*;
19948    use crate::session_environment::{
19949        NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
19950        NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
19951        NodeSessionPathClass,
19952    };
19953    use gate4agent_catalog::EnvMutation;
19954    use gate4agent_runtime_native::{
19955        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
19956        NativeLaunchProfile, NativeLaunchProfileId,
19957    };
19958    use std::ffi::OsString;
19959    use std::sync::Arc;
19960
19961    struct EmptyEnvironmentResolver;
19962
19963    struct FixtureSecretResolver {
19964        deny: Arc<AtomicBool>,
19965    }
19966
19967    impl NodeSecretResolver for FixtureSecretResolver {
19968        fn resolve(
19969            &self,
19970            _reference: &NodeSecretReference,
19971        ) -> Result<NodeSecretValue, NodeSecretResolveError> {
19972            if self.deny.load(Ordering::Acquire) {
19973                Err(NodeSecretResolveError::Denied)
19974            } else {
19975                NodeSecretValue::text("fixture-secret")
19976                    .map_err(|_| NodeSecretResolveError::Unavailable)
19977            }
19978        }
19979    }
19980
19981    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19982        fn resolve_child_environment(
19983            &self,
19984        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19985            Ok(vec![EnvMutation {
19986                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19987                value: None,
19988            }])
19989        }
19990    }
19991
19992    fn agent(value: &str) -> AgentId {
19993        AgentId::new(value).unwrap()
19994    }
19995
19996    fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
19997        let unique = std::time::SystemTime::now()
19998            .duration_since(std::time::UNIX_EPOCH)
19999            .unwrap()
20000            .as_nanos();
20001        std::env::temp_dir().join(format!(
20002            "gate4agent-node-{label}-{}-{unique}",
20003            std::process::id(),
20004        ))
20005    }
20006
20007    fn materialization_test_shared(
20008        deny_secret: bool,
20009    ) -> (
20010        NodeShared,
20011        NativeRuntime,
20012        ResolvedEnvironmentProfileReceipt,
20013        PathBuf,
20014        Arc<AtomicBool>,
20015    ) {
20016        let workspace = WorkspaceConfig::new(
20017            WorkspaceId::new("primary").unwrap(),
20018            std::env::current_dir().unwrap(),
20019        )
20020        .unwrap();
20021        materialization_test_shared_with_workspace(deny_secret, workspace)
20022    }
20023
20024    fn materialization_test_shared_with_workspace(
20025        deny_secret: bool,
20026        workspace: WorkspaceConfig,
20027    ) -> (
20028        NodeShared,
20029        NativeRuntime,
20030        ResolvedEnvironmentProfileReceipt,
20031        PathBuf,
20032        Arc<AtomicBool>,
20033    ) {
20034        let root = temporary_workspace_root(if deny_secret {
20035            "materialization-denied"
20036        } else {
20037            "materialization-ready"
20038        });
20039        std::fs::create_dir_all(&root).unwrap();
20040        let materialization_root = root.join("session-environments");
20041        let deny = Arc::new(AtomicBool::new(deny_secret));
20042        let resolver = Arc::new(FixtureSecretResolver {
20043            deny: Arc::clone(&deny),
20044        });
20045        let materializer = SessionEnvironmentMaterializer::new(
20046            materialization_root,
20047            resolver,
20048        )
20049        .unwrap();
20050        let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
20051        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20052            "materialized-claude-r1",
20053        )
20054        .unwrap();
20055        let materialization = NodeSessionMaterializationProfile::new(
20056            vec![NodeSessionEnvironmentMutation::SetSecret {
20057                key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
20058                reference: NodeSecretReference::new("fixture-token").unwrap(),
20059            }],
20060            vec![NodeSessionPathBinding::new(
20061                "GATE4AGENT_PROVIDER_HOME",
20062                NodeSessionPathClass::ProviderHome,
20063            )
20064            .unwrap()],
20065            vec![NodeSessionFile::secret(
20066                NodeSessionPathClass::Config,
20067                "auth/token",
20068                NodeSecretReference::new("fixture-token").unwrap(),
20069            )
20070            .unwrap()],
20071        )
20072        .unwrap();
20073        let catalog = active_registry().unwrap();
20074        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20075        let control = runtime.native_launch_profile_control();
20076        let node_profile = NodeEnvironmentProfile::new_with_materialization(
20077            profile_id.clone(),
20078            profile_revision.clone(),
20079            agent("claude"),
20080            [NativeLaunchProfile::new(
20081                NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
20082                agent("claude"),
20083                TransportKind::Pty,
20084                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20085                Arc::new(EmptyEnvironmentResolver),
20086            )
20087            .unwrap()],
20088            Some(materialization),
20089        )
20090        .unwrap();
20091        let (binding, native_profiles, materialization) = node_profile.into_parts();
20092        for native_profile in native_profiles {
20093            runtime.upsert_native_launch_profile(native_profile).unwrap();
20094        }
20095        let shared = NodeShared::new_with_incarnation(
20096            handle,
20097            "fixture-token".to_owned(),
20098            NodeId::new("node-materialization-test").unwrap(),
20099            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20100            vec![workspace],
20101            vec![agent("claude")],
20102            ProviderRuntimeStatuses::default(),
20103            None,
20104            Vec::new(),
20105            Vec::new(),
20106            SpawnProfileRegistry::default(),
20107            Some(control),
20108            None,
20109            Some(materializer),
20110            None,
20111            Vec::new(),
20112            Vec::new(),
20113            Vec::new(),
20114            Vec::new(),
20115            None,
20116            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20117        );
20118        shared
20119            .environment_profiles
20120            .write()
20121            .unwrap_or_else(|poisoned| poisoned.into_inner())
20122            .insert(binding.id.clone(), binding);
20123        shared
20124            .environment_materialization_profiles
20125            .write()
20126            .unwrap_or_else(|poisoned| poisoned.into_inner())
20127            .insert(profile_id.clone(), materialization.unwrap());
20128        (
20129            shared,
20130            runtime,
20131            ResolvedEnvironmentProfileReceipt {
20132                profile_id,
20133                profile_revision,
20134                network_allowlist: None,
20135                browser_profile_id: None,
20136            },
20137            root,
20138            deny,
20139        )
20140    }
20141
20142
20143    // ---- Characterization of the spawn preparation block (pre-SpawnPreparer) ----
20144
20145    async fn characterization_spawn(
20146        shared: &NodeShared,
20147        mode: SessionMode,
20148        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
20149        required: &[ProviderRuntimeCapability],
20150        harness_mcp: Option<&PreparedHarnessMcpSpawn>,
20151    ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
20152        shared
20153            .spawn_session_with_deadline(
20154                WorkspaceId::new("primary").unwrap(),
20155                agent("claude"),
20156                mode,
20157                gate4agent_types::TerminalSize { rows: 24, columns: 80 },
20158                None,
20159                environment_profile,
20160                None,
20161                None,
20162                None,
20163                Some(ProviderRuntimePolicy::raw_pty()),
20164                SpawnRecordPolicy::ProviderIdentityOnly,
20165                Some(Instant::now() + Duration::from_secs(30)),
20166                required,
20167                harness_mcp,
20168                ApprovalLevel::default(),
20169                None,
20170                None,
20171            )
20172            .await
20173    }
20174
20175    fn assert_no_session_state(shared: &NodeShared) {
20176        assert!(shared.handle.snapshot().sessions.is_empty());
20177        assert!(shared
20178            .session_bindings
20179            .lock()
20180            .unwrap_or_else(|poisoned| poisoned.into_inner())
20181            .is_empty());
20182        assert!(shared
20183            .materializations
20184            .lock()
20185            .unwrap_or_else(|poisoned| poisoned.into_inner())
20186            .is_empty());
20187    }
20188
20189    #[tokio::test]
20190    async fn spawn_unadmitted_required_capability_refuses_before_id_mint() {
20191        let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
20192        let error = characterization_spawn(
20193            &shared,
20194            SessionMode::Pty,
20195            None,
20196            &[ProviderRuntimeCapability::SemanticReadiness],
20197            None,
20198        )
20199        .await
20200        .unwrap_err();
20201        assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
20202        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
20203        assert_no_session_state(&shared);
20204        drop(shared);
20205        drop(runtime);
20206        std::fs::remove_dir_all(root).unwrap();
20207    }
20208
20209    #[tokio::test]
20210    async fn spawn_denied_environment_materialization_fails_after_id_mint_with_no_state() {
20211        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
20212        let error = characterization_spawn(&shared, SessionMode::Pty, Some(receipt), &[], None)
20213            .await
20214            .unwrap_err();
20215        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
20216        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 2);
20217        assert_no_session_state(&shared);
20218        drop(shared);
20219        drop(runtime);
20220        std::fs::remove_dir_all(root).unwrap();
20221    }
20222
20223    #[test]
20224    fn remove_binding_returns_binding_and_is_idempotent_for_environment_session() {
20225        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20226        let address = SessionAddress {
20227            workspace_id: WorkspaceId::new("primary").unwrap(),
20228            session: SessionKey {
20229                instance_id: AgentInstanceId(77),
20230                generation: SessionGeneration(1),
20231            },
20232        };
20233        shared.bind_session_with_policy(&address, ProviderRuntimePolicy::raw_pty(), Some(receipt));
20234        let removed = shared.remove_binding(&address).expect("binding removed");
20235        assert!(removed.environment_profile.is_some());
20236        assert!(shared.remove_binding(&address).is_none());
20237        assert_no_session_state(&shared);
20238        drop(shared);
20239        drop(runtime);
20240        std::fs::remove_dir_all(root).unwrap();
20241    }
20242
20243    async fn characterization_prepared_harness(provider: &str) -> PreparedHarnessMcpSpawn {
20244        use crate::harness_mcp_proxy::{HarnessMcpProxyRegistry, ReviewedHarnessMcpProgram};
20245        use crate::protocol::{HarnessMcpActivationDigest, HarnessMcpLaunchV1, HarnessMcpReservationId};
20246        let helper = ReviewedHarnessMcpProgram::review(std::env::current_exe().unwrap())
20247            .expect("review current test executable as fixture helper");
20248        let registry = HarnessMcpProxyRegistry::new(helper);
20249        let spec = crate::protocol::SpawnSpec {
20250            target: crate::protocol::SpawnTarget {
20251                node_id: NodeId::new("node-characterization").unwrap(),
20252                workspace_id: WorkspaceId::new("primary").unwrap(),
20253                worktree_id: None,
20254            },
20255            profile_id: crate::protocol::SpawnProfileId::new("claude").unwrap(),
20256            expected_profile_revision: crate::protocol::SpawnProfileRevision::new("r1").unwrap(),
20257            overrides: crate::protocol::SpawnOverrides::default(),
20258            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
20259            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("characterization-harness").unwrap(),
20260            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
20261        };
20262        let reservation = HarnessMcpReservationId::new(format!("hmcpres_{}", "c".repeat(24))).unwrap();
20263        let digest = HarnessMcpActivationDigest::new(format!("sha256:{}", "c".repeat(64))).unwrap();
20264        let launch = HarnessMcpLaunchV1 {
20265            server_name: "fixture-harness".to_owned(),
20266            args: vec!["--session-proxy".to_owned()],
20267            endpoint_env: "FIXTURE_SESSION_ENDPOINT".to_owned(),
20268            token_env: "FIXTURE_SESSION_TOKEN".to_owned(),
20269            program_env: "FIXTURE_MCP_PROGRAM".to_owned(),
20270            trace: None,
20271            scrub_env: Vec::new(),
20272        };
20273        let expires = unix_time_ms().saturating_add(30_000);
20274        registry.arm(reservation.clone(), digest.clone(), spec.clone(), launch, expires).await.unwrap();
20275        registry
20276            .prepare_spawn(&reservation, &digest, &spec, expires, agent(provider))
20277            .unwrap()
20278    }
20279
20280    #[tokio::test]
20281    async fn spawn_harness_overlay_refuses_inline_and_provider_mismatch_with_no_state() {
20282        let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
20283        let prepared = characterization_prepared_harness("claude").await;
20284        let error = characterization_spawn(&shared, SessionMode::Inline, None, &[], Some(&prepared))
20285            .await
20286            .unwrap_err();
20287        // Inline is stopped by runtime admission before the harness check runs.
20288        assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
20289        let mismatched = characterization_prepared_harness("codex").await;
20290        let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&mismatched))
20291            .await
20292            .unwrap_err();
20293        assert_eq!(error.code, NodeFailureCode::BindingMismatch);
20294        assert_no_session_state(&shared);
20295        drop(shared);
20296        drop(runtime);
20297        std::fs::remove_dir_all(root).unwrap();
20298    }
20299
20300    #[tokio::test]
20301    async fn spawn_harness_overlay_without_launch_control_is_unavailable_with_no_state() {
20302        let (mut shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
20303        shared.native_launch_profile_control = None;
20304        let prepared = characterization_prepared_harness("claude").await;
20305        let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&prepared))
20306            .await
20307            .unwrap_err();
20308        assert_eq!(error.code, NodeFailureCode::HarnessMcpUnavailable);
20309        assert_no_session_state(&shared);
20310        drop(shared);
20311        drop(runtime);
20312        std::fs::remove_dir_all(root).unwrap();
20313    }
20314}
20315
20316#[cfg(all(test, windows))]
20317mod tests {
20318    use super::*;
20319    use gate4agent_types::ProviderActivity;
20320    use crate::session_environment::{
20321        NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
20322        NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
20323        NodeSessionPathClass,
20324    };
20325    use gate4agent_catalog::EnvMutation;
20326    use gate4agent_runtime_native::{
20327        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
20328        NativeLaunchProfile, NativeLaunchProfileId,
20329    };
20330    use std::ffi::OsString;
20331    use std::sync::Arc;
20332
20333    struct EmptyEnvironmentResolver;
20334
20335    struct FixtureSecretResolver {
20336        deny: Arc<AtomicBool>,
20337    }
20338
20339    impl NodeSecretResolver for FixtureSecretResolver {
20340        fn resolve(
20341            &self,
20342            _reference: &NodeSecretReference,
20343        ) -> Result<NodeSecretValue, NodeSecretResolveError> {
20344            if self.deny.load(Ordering::Acquire) {
20345                Err(NodeSecretResolveError::Denied)
20346            } else {
20347                NodeSecretValue::text("fixture-secret")
20348                    .map_err(|_| NodeSecretResolveError::Unavailable)
20349            }
20350        }
20351    }
20352
20353    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
20354        fn resolve_child_environment(
20355            &self,
20356        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
20357            Ok(vec![EnvMutation {
20358                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
20359                value: None,
20360            }])
20361        }
20362    }
20363
20364    fn agent(value: &str) -> AgentId {
20365        AgentId::new(value).unwrap()
20366    }
20367
20368    fn terminal_test_shared_with_mode(mode: WorktreeServiceMode) -> NodeShared {
20369        let catalog = active_registry().unwrap();
20370        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20371        let workspace = WorkspaceConfig::new(
20372            WorkspaceId::new("primary").unwrap(),
20373            std::env::current_dir().unwrap(),
20374        )
20375        .unwrap()
20376        .with_worktree_service_mode(mode);
20377        NodeShared::new(
20378            handle,
20379            "fixture-token".to_owned(),
20380            NodeId::new("node-terminal-test").unwrap(),
20381            vec![workspace],
20382            vec![agent("claude")],
20383        )
20384    }
20385
20386    fn terminal_test_shared() -> NodeShared {
20387        terminal_test_shared_with_mode(WorktreeServiceMode::Manual)
20388    }
20389
20390    #[cfg(feature = "fixture")]
20391    #[test]
20392    fn managed_worktree_v2_failure_probe_keeps_only_the_latest_code() {
20393        let probe = SpawnManagedWorktreeV2FailureProbe::default();
20394        assert_eq!(probe.latest_code(), None);
20395
20396        probe.record(Some(NodeFailureCode::ManagedWorktreeOwnershipConflict));
20397        assert_eq!(
20398            probe.latest_code(),
20399            Some(NodeFailureCode::ManagedWorktreeOwnershipConflict),
20400        );
20401
20402        probe.record(Some(NodeFailureCode::SpawnDeadlineExceeded));
20403        assert_eq!(
20404            probe.latest_code(),
20405            Some(NodeFailureCode::SpawnDeadlineExceeded),
20406        );
20407
20408        probe.record(None);
20409        assert_eq!(probe.latest_code(), None);
20410    }
20411
20412    #[test]
20413    fn launch_snapshot_is_authoritative_bounded_and_path_free() {
20414        let source = temporary_workspace_root("launch-inventory-source");
20415        let allocation = temporary_workspace_root("launch-inventory-allocation");
20416        std::fs::create_dir_all(&source).unwrap();
20417        std::fs::create_dir_all(&allocation).unwrap();
20418        let workspace_id = WorkspaceId::new("primary").unwrap();
20419        let mut workspace = WorkspaceConfig::new(workspace_id.clone(), &source)
20420            .unwrap()
20421            .with_worktree_service_mode(WorktreeServiceMode::Managed);
20422        for index in 0..MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE {
20423            workspace = workspace
20424                .with_managed_worktree_profile(
20425                    ManagedWorktreeProfile::new(
20426                        WorktreeProfileId::new(format!("profile-{index}")).unwrap(),
20427                        crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
20428                        &allocation,
20429                        "gate4agent",
20430                        "HEAD",
20431                        ManagedWorktreeRetention::RemoveWhenReleased,
20432                    )
20433                    .unwrap(),
20434                )
20435                .unwrap();
20436        }
20437        let overflow = workspace.clone().with_managed_worktree_profile(
20438            ManagedWorktreeProfile::new(
20439                WorktreeProfileId::new("overflow").unwrap(),
20440                crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
20441                &allocation,
20442                "gate4agent",
20443                "HEAD",
20444                ManagedWorktreeRetention::Retain,
20445            )
20446            .unwrap(),
20447        );
20448        assert!(matches!(
20449            overflow,
20450            Err(NodeServerError::ManagedWorktreeProfileCapacity {
20451                max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
20452                ..
20453            }),
20454        ));
20455
20456        let catalog = active_registry().unwrap();
20457        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20458        let shared = NodeShared::new(
20459            handle,
20460            "fixture-token".to_owned(),
20461            NodeId::new("node-launch-inventory").unwrap(),
20462            vec![workspace],
20463            vec![agent("claude")],
20464        );
20465        let snapshot = shared.snapshot();
20466        let launch = snapshot.launch_inventory.as_ref().unwrap();
20467        assert_eq!(launch.spawn_profiles.as_ref().unwrap().len(), 1);
20468        assert!(launch.bundles.as_ref().unwrap().is_empty());
20469        let profiles = snapshot.workspaces[0]
20470            .managed_worktree_profiles
20471            .as_ref()
20472            .unwrap();
20473        assert_eq!(
20474            snapshot.workspaces[0].worktree_service_mode,
20475            Some(WorktreeServiceMode::Managed),
20476        );
20477        assert_eq!(
20478            profiles.profiles.len(),
20479            MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
20480        );
20481        let public_inventory = serde_json::to_string(&(launch, profiles)).unwrap();
20482        for forbidden in ["allocation_root", "branch_prefix", "base", "prompt", "environment", "home"] {
20483            assert!(!public_inventory.contains(forbidden), "launch inventory leaked {forbidden}");
20484        }
20485        let legacy = snapshot_for_wire(
20486            &shared,
20487            true,
20488            true,
20489            false,
20490            false,
20491            true,
20492            false,
20493            true,
20494            false,
20495            true,
20496        );
20497        assert!(legacy.launch_inventory.is_none());
20498        assert!(legacy.workspaces[0].managed_worktree_profiles.is_none());
20499
20500        drop(shared);
20501        std::fs::remove_dir_all(source).unwrap();
20502        std::fs::remove_dir_all(allocation).unwrap();
20503    }
20504
20505    #[test]
20506    fn agent_progress_projection_omits_sensitive_provider_payloads_and_is_capability_gated() {
20507        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20508            capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
20509        }));
20510        let source = gate4agent_types::ProviderSource {
20511            family: AdapterFamily::PtySemantic,
20512            binding: AdapterBinding::new(
20513                gate4agent_types::AdapterId::new("claude").unwrap(),
20514                "fixture/v1",
20515                gate4agent_types::AdapterVerification::SyntheticFixture,
20516            )
20517            .unwrap(),
20518        };
20519        let provider = ProviderSnapshot {
20520            sequence: 17,
20521            session: Some(ProviderSessionIdentity {
20522                key: ProviderSessionKey::SessionId,
20523                id: "PRIVATE_PROVIDER_SESSION".to_owned(),
20524                transcript_path: Some(r"C:\PRIVATE_PROVIDER_PATH".to_owned()),
20525            }),
20526            model: Some("PRIVATE_MODEL".to_owned()),
20527            tools: vec!["PRIVATE_TOOL_INVENTORY".to_owned()],
20528            completed_turns: 3,
20529            usage: gate4agent_types::TokenUsage {
20530                input_tokens: 10,
20531                output_tokens: 20,
20532                cache_read_tokens: 30,
20533                cache_write_tokens: 40,
20534                reasoning_tokens: 50,
20535                context_window: Some(200_000),
20536            },
20537            lead_activity: ProviderActivity::WaitingForInput,
20538            activity: ProviderActivity::WaitingForInput,
20539            current_prompt: Some("PRIVATE_CURRENT_PROMPT".to_owned()),
20540            active_tools: (0..9)
20541                .map(|index| gate4agent_types::ActiveProviderTool {
20542                    id: format!("private-tool-id-{index}"),
20543                    name: if index == 0 {
20544                        format!("  Read\u{0000}{}", "x".repeat(80))
20545                    } else {
20546                        format!("Tool-{index}")
20547                    },
20548                    input_json: "PRIVATE_TOOL_INPUT_JSON".to_owned(),
20549                })
20550                .collect(),
20551            interactions: vec![gate4agent_types::ProviderInteraction {
20552                id: gate4agent_types::ProviderInteractionId(17),
20553                source: source.clone(),
20554                provider_request_id: Some("PRIVATE_PROVIDER_REQUEST".to_owned()),
20555                interaction_kind: ProviderInteractionKind::Approval,
20556                tool_name: " Approve\u{0007}Tool ".to_owned(),
20557                prompt: "PRIVATE_INTERACTION_PROMPT".to_owned(),
20558                agent_id: Some("PRIVATE_INTERACTION_AGENT".to_owned()),
20559                resume_lead_activity: Some(ProviderActivity::Working),
20560                status: ProviderInteractionStatus::Pending,
20561            }],
20562            subagents: vec![gate4agent_types::ProviderSubagent {
20563                source: source.clone(),
20564                provider_agent_id: "PRIVATE_SUBAGENT_ID".to_owned(),
20565                agent_type: Some("PRIVATE_SUBAGENT_TYPE".to_owned()),
20566                description: Some("PRIVATE_SUBAGENT_DESCRIPTION".to_owned()),
20567            }],
20568            sources: vec![gate4agent_types::ProviderSourceCursor {
20569                source,
20570                sequence: 17,
20571                gap_count: 2,
20572                stale: true,
20573            }],
20574            last_event: Some(ProviderEvent::Error {
20575                message: "PRIVATE_PROVIDER_ERROR".to_owned(),
20576            }),
20577            gap_count: 2,
20578            stale: true,
20579        };
20580        let address = terminal_address(3);
20581        let entry = agent_progress_from_provider_snapshot(address.clone(), &provider).unwrap();
20582        assert_eq!(entry.address, address);
20583        assert_eq!(entry.progress.provider_sequence, 17);
20584        assert_eq!(entry.progress.active_tool_count, 9);
20585        assert_eq!(
20586            entry.progress.active_tool_labels.len(),
20587            MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS,
20588        );
20589        assert_eq!(entry.progress.subagent_count, 1);
20590        assert_eq!(entry.progress.last_event_kind, Some(AgentProgressEventKindV1::Error));
20591        assert!(entry.progress.truncated);
20592        let encoded = serde_json::to_string(&entry).unwrap();
20593        for forbidden in [
20594            "PRIVATE_PROVIDER_SESSION",
20595            "PRIVATE_PROVIDER_PATH",
20596            "PRIVATE_MODEL",
20597            "PRIVATE_TOOL_INVENTORY",
20598            "PRIVATE_CURRENT_PROMPT",
20599            "PRIVATE_TOOL_INPUT_JSON",
20600            "PRIVATE_PROVIDER_REQUEST",
20601            "PRIVATE_INTERACTION_PROMPT",
20602            "PRIVATE_INTERACTION_AGENT",
20603            "PRIVATE_SUBAGENT_ID",
20604            "PRIVATE_SUBAGENT_TYPE",
20605            "PRIVATE_SUBAGENT_DESCRIPTION",
20606            "PRIVATE_PROVIDER_ERROR",
20607            "input_json",
20608            "current_prompt",
20609            "session_id",
20610        ] {
20611            assert!(!encoded.contains(forbidden), "agent progress leaked {forbidden}");
20612        }
20613
20614        let snapshot = NodeSnapshot {
20615            node_id: NodeId::new("node-progress-test").unwrap(),
20616            enabled_providers: vec![agent("claude")],
20617            provider_runtime_statuses: ProviderRuntimeStatuses::default(),
20618            workspaces: Vec::new(),
20619            session_records: Vec::new(),
20620            managed_worktrees: Vec::new(),
20621            launch_inventory: None,
20622            agent_progress: vec![entry],
20623        };
20624        let mut legacy_reply = ResponseEnvelope {
20625            request_id: 1,
20626            result: Ok(NodeResponse::Snapshot {
20627                event_sequence: 0,
20628                controller: None,
20629                snapshot: snapshot.clone(),
20630            }),
20631        };
20632        project_response_without_agent_progress(&mut legacy_reply);
20633        let Ok(NodeResponse::Snapshot { snapshot: legacy, .. }) = legacy_reply.result else {
20634            panic!("agent progress projection changed response kind");
20635        };
20636        assert!(legacy.agent_progress.is_empty());
20637        assert_eq!(snapshot.agent_progress.len(), 1);
20638    }
20639
20640    fn terminal_address(generation: u64) -> SessionAddress {
20641        SessionAddress {
20642            workspace_id: WorkspaceId::new("primary").unwrap(),
20643            session: SessionKey {
20644                instance_id: AgentInstanceId(41),
20645                generation: SessionGeneration(generation),
20646            },
20647        }
20648    }
20649
20650    fn spawn_spec_fixture() -> SpawnSpec {
20651        SpawnSpec {
20652            target: crate::protocol::SpawnTarget {
20653                node_id: NodeId::new("node-terminal-test").unwrap(),
20654                workspace_id: WorkspaceId::new("primary").unwrap(),
20655                worktree_id: None,
20656            },
20657            profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
20658            expected_profile_revision: crate::protocol::SpawnProfileRevision::new("builtin-v1")
20659                .unwrap(),
20660            overrides: crate::protocol::SpawnOverrides::default(),
20661            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
20662            idempotency_key: SpawnIdempotencyKey::new("spawn-spec-fixture").unwrap(),
20663            required_capabilities: SpawnRequiredCapabilities::default(),
20664        }
20665    }
20666
20667    fn environment_profile_test_shared(
20668    ) -> (NodeShared, NativeRuntime, ResolvedEnvironmentProfileReceipt) {
20669        let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
20670        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20671            "local-claude-r1",
20672        )
20673        .unwrap();
20674        let spawn_profiles = SpawnProfileRegistry::new([
20675            crate::protocol::SpawnProfileDefaults {
20676                profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
20677                revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
20678                provider: agent("claude"),
20679                mode: SessionMode::Pty,
20680                terminal_size: gate4agent_types::TerminalSize {
20681                    rows: 24,
20682                    columns: 80,
20683                },
20684                prompt: None,
20685                bundle_id: None,
20686                context_id: None,
20687                environment_profile_id: Some(profile_id.clone()),
20688            },
20689        ])
20690        .unwrap();
20691        let catalog = active_registry().unwrap();
20692        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20693        let control = runtime.native_launch_profile_control();
20694        let node_profile = NodeEnvironmentProfile::new(
20695            profile_id.clone(),
20696            profile_revision.clone(),
20697            agent("claude"),
20698            [NativeLaunchProfile::new(
20699                NativeLaunchProfileId::new("local-claude-pty").unwrap(),
20700                agent("claude"),
20701                TransportKind::Pty,
20702                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20703                Arc::new(EmptyEnvironmentResolver),
20704            )
20705            .unwrap()],
20706        )
20707        .unwrap();
20708        let (binding, native_profiles, _materialization) = node_profile.into_parts();
20709        for native_profile in native_profiles {
20710            runtime.upsert_native_launch_profile(native_profile).unwrap();
20711        }
20712        let workspace = WorkspaceConfig::new(
20713            WorkspaceId::new("primary").unwrap(),
20714            std::env::current_dir().unwrap(),
20715        )
20716        .unwrap();
20717        let shared = NodeShared::new_with_incarnation(
20718            handle,
20719            "fixture-token".to_owned(),
20720            NodeId::new("node-terminal-test").unwrap(),
20721            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20722            vec![workspace],
20723            vec![agent("claude")],
20724            ProviderRuntimeStatuses::default(),
20725            None,
20726            Vec::new(),
20727            Vec::new(),
20728            spawn_profiles,
20729            Some(control),
20730            None,
20731            None,
20732            None,
20733            Vec::new(),
20734            Vec::new(),
20735            Vec::new(),
20736            Vec::new(),
20737            None,
20738            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20739        );
20740        shared
20741            .environment_profiles
20742            .write()
20743            .unwrap_or_else(|poisoned| poisoned.into_inner())
20744            .insert(binding.id.clone(), binding);
20745        (
20746            shared,
20747            runtime,
20748            ResolvedEnvironmentProfileReceipt {
20749                profile_id,
20750                profile_revision,
20751                network_allowlist: None,
20752                browser_profile_id: None,
20753            },
20754        )
20755    }
20756
20757    fn materialization_test_shared(
20758        deny_secret: bool,
20759    ) -> (
20760        NodeShared,
20761        NativeRuntime,
20762        ResolvedEnvironmentProfileReceipt,
20763        PathBuf,
20764        Arc<AtomicBool>,
20765    ) {
20766        let workspace = WorkspaceConfig::new(
20767            WorkspaceId::new("primary").unwrap(),
20768            std::env::current_dir().unwrap(),
20769        )
20770        .unwrap();
20771        materialization_test_shared_with_workspace(deny_secret, workspace)
20772    }
20773
20774    fn materialization_test_shared_with_workspace(
20775        deny_secret: bool,
20776        workspace: WorkspaceConfig,
20777    ) -> (
20778        NodeShared,
20779        NativeRuntime,
20780        ResolvedEnvironmentProfileReceipt,
20781        PathBuf,
20782        Arc<AtomicBool>,
20783    ) {
20784        let root = temporary_workspace_root(if deny_secret {
20785            "materialization-denied"
20786        } else {
20787            "materialization-ready"
20788        });
20789        std::fs::create_dir_all(&root).unwrap();
20790        let materialization_root = root.join("session-environments");
20791        let deny = Arc::new(AtomicBool::new(deny_secret));
20792        let resolver = Arc::new(FixtureSecretResolver {
20793            deny: Arc::clone(&deny),
20794        });
20795        let materializer = SessionEnvironmentMaterializer::new(
20796            materialization_root,
20797            resolver,
20798        )
20799        .unwrap();
20800        let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
20801        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20802            "materialized-claude-r1",
20803        )
20804        .unwrap();
20805        let materialization = NodeSessionMaterializationProfile::new(
20806            vec![NodeSessionEnvironmentMutation::SetSecret {
20807                key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
20808                reference: NodeSecretReference::new("fixture-token").unwrap(),
20809            }],
20810            vec![NodeSessionPathBinding::new(
20811                "GATE4AGENT_PROVIDER_HOME",
20812                NodeSessionPathClass::ProviderHome,
20813            )
20814            .unwrap()],
20815            vec![NodeSessionFile::secret(
20816                NodeSessionPathClass::Config,
20817                "auth/token",
20818                NodeSecretReference::new("fixture-token").unwrap(),
20819            )
20820            .unwrap()],
20821        )
20822        .unwrap();
20823        let catalog = active_registry().unwrap();
20824        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20825        let control = runtime.native_launch_profile_control();
20826        let node_profile = NodeEnvironmentProfile::new_with_materialization(
20827            profile_id.clone(),
20828            profile_revision.clone(),
20829            agent("claude"),
20830            [NativeLaunchProfile::new(
20831                NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
20832                agent("claude"),
20833                TransportKind::Pty,
20834                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20835                Arc::new(EmptyEnvironmentResolver),
20836            )
20837            .unwrap()],
20838            Some(materialization),
20839        )
20840        .unwrap();
20841        let (binding, native_profiles, materialization) = node_profile.into_parts();
20842        for native_profile in native_profiles {
20843            runtime.upsert_native_launch_profile(native_profile).unwrap();
20844        }
20845        let shared = NodeShared::new_with_incarnation(
20846            handle,
20847            "fixture-token".to_owned(),
20848            NodeId::new("node-materialization-test").unwrap(),
20849            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20850            vec![workspace],
20851            vec![agent("claude")],
20852            ProviderRuntimeStatuses::default(),
20853            None,
20854            Vec::new(),
20855            Vec::new(),
20856            SpawnProfileRegistry::default(),
20857            Some(control),
20858            None,
20859            Some(materializer),
20860            None,
20861            Vec::new(),
20862            Vec::new(),
20863            Vec::new(),
20864            Vec::new(),
20865            None,
20866            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20867        );
20868        shared
20869            .environment_profiles
20870            .write()
20871            .unwrap_or_else(|poisoned| poisoned.into_inner())
20872            .insert(binding.id.clone(), binding);
20873        shared
20874            .environment_materialization_profiles
20875            .write()
20876            .unwrap_or_else(|poisoned| poisoned.into_inner())
20877            .insert(profile_id.clone(), materialization.unwrap());
20878        (
20879            shared,
20880            runtime,
20881            ResolvedEnvironmentProfileReceipt {
20882                profile_id,
20883                profile_revision,
20884                network_allowlist: None,
20885                browser_profile_id: None,
20886            },
20887            root,
20888            deny,
20889        )
20890    }
20891
20892    #[cfg(feature = "fixture")]
20893    #[derive(Clone, Copy)]
20894    enum CodexHomeProfile {
20895        Missing,
20896        WrongClass,
20897        Exact,
20898    }
20899
20900    #[cfg(feature = "fixture")]
20901    fn codex_bundle_test_shared(
20902        home_profile: CodexHomeProfile,
20903    ) -> (
20904        NodeShared,
20905        NativeRuntime,
20906        ResolvedEnvironmentProfileReceipt,
20907        NodeBundle,
20908        PathBuf,
20909    ) {
20910        const ROOT_MANIFEST: &[u8] = br#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"review-tools"}"#;
20911        const CLAUDE_MANIFEST: &[u8] =
20912            br#"{"name":"review-tools","version":"1.0.0","description":"Review helpers"}"#;
20913        const SKILL: &[u8] = b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n";
20914        const DIGEST: &str =
20915            "sha256:941f20868a6ef49b4329bf1bb1368515763f5b64d8279b05cd9700966083d707";
20916
20917        let (shared, mut runtime, _claude_receipt, root, _deny) =
20918            materialization_test_shared(false);
20919        let profile_id = SpawnEnvironmentProfileId::new(match home_profile {
20920            CodexHomeProfile::Missing => "codex-home-missing",
20921            CodexHomeProfile::WrongClass => "codex-home-wrong",
20922            CodexHomeProfile::Exact => "codex-home-exact",
20923        })
20924        .unwrap();
20925        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20926            "codex-home-r1",
20927        )
20928        .unwrap();
20929        let materialization = match home_profile {
20930            CodexHomeProfile::Missing => None,
20931            CodexHomeProfile::WrongClass | CodexHomeProfile::Exact => {
20932                Some(NodeSessionMaterializationProfile::new(
20933                    Vec::new(),
20934                    vec![NodeSessionPathBinding::new(
20935                        "CODEX_HOME",
20936                        if matches!(home_profile, CodexHomeProfile::Exact) {
20937                            NodeSessionPathClass::ProviderHome
20938                        } else {
20939                            NodeSessionPathClass::Config
20940                        },
20941                    )
20942                    .unwrap()],
20943                    Vec::new(),
20944                )
20945                .unwrap())
20946            }
20947        };
20948        let node_profile = NodeEnvironmentProfile::new_with_materialization(
20949            profile_id.clone(),
20950            profile_revision.clone(),
20951            agent("codex"),
20952            [NativeLaunchProfile::new(
20953                NativeLaunchProfileId::new(format!("{profile_id}-pty")).unwrap(),
20954                agent("codex"),
20955                TransportKind::Pty,
20956                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20957                Arc::new(EmptyEnvironmentResolver),
20958            )
20959            .unwrap()],
20960            materialization,
20961        )
20962        .unwrap();
20963        let (binding, native_profiles, materialization) = node_profile.into_parts();
20964        for native_profile in native_profiles {
20965            runtime.upsert_native_launch_profile(native_profile).unwrap();
20966        }
20967        shared
20968            .environment_profiles
20969            .write()
20970            .unwrap_or_else(|poisoned| poisoned.into_inner())
20971            .insert(binding.id.clone(), binding);
20972        if let Some(materialization) = materialization {
20973            shared
20974                .environment_materialization_profiles
20975                .write()
20976                .unwrap_or_else(|poisoned| poisoned.into_inner())
20977                .insert(profile_id.clone(), materialization);
20978        }
20979
20980        let bundle_source = root.join(format!("bundle-source-{profile_id}"));
20981        std::fs::create_dir_all(bundle_source.join(".claude-plugin")).unwrap();
20982        std::fs::create_dir_all(bundle_source.join("skills/review-code")).unwrap();
20983        std::fs::write(bundle_source.join("plugin.json"), ROOT_MANIFEST).unwrap();
20984        std::fs::write(
20985            bundle_source.join(".claude-plugin/plugin.json"),
20986            CLAUDE_MANIFEST,
20987        )
20988        .unwrap();
20989        std::fs::write(bundle_source.join("skills/review-code/SKILL.md"), SKILL).unwrap();
20990        crate::bundle_catalog::protect_bundle_source_tree_fixture(&bundle_source).unwrap();
20991        let bundle = NodeBundle::new(
20992            crate::protocol::SpawnBundleId::new("review-tools").unwrap(),
20993            crate::protocol::SpawnBundleRevision::new("review-tools-r1").unwrap(),
20994            crate::protocol::SpawnBundleDigest::new(DIGEST).unwrap(),
20995            &bundle_source,
20996        )
20997        .unwrap();
20998        *shared
20999            .bundle_catalog
21000            .write()
21001            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21002            BundleCatalog::new([bundle.clone()]).unwrap();
21003        (
21004            shared,
21005            runtime,
21006            ResolvedEnvironmentProfileReceipt {
21007                profile_id,
21008                profile_revision,
21009                network_allowlist: None,
21010                browser_profile_id: None,
21011            },
21012            bundle,
21013            root,
21014        )
21015    }
21016
21017    #[cfg(feature = "fixture")]
21018    #[test]
21019    fn codex_bundle_preflight_rejects_missing_or_wrong_home_before_materialization() {
21020        for home_profile in [CodexHomeProfile::Missing, CodexHomeProfile::WrongClass] {
21021            let (shared, runtime, receipt, bundle, root) =
21022                codex_bundle_test_shared(home_profile);
21023            let error = shared
21024                .bundle_layout(
21025                    &agent("codex"),
21026                    SessionMode::Pty,
21027                    Some(&receipt),
21028                    &bundle,
21029                )
21030                .unwrap_err();
21031            assert_eq!(error.code, NodeFailureCode::BundleBindingMismatch);
21032            assert!(shared
21033                .materializations
21034                .lock()
21035                .unwrap_or_else(|poisoned| poisoned.into_inner())
21036                .is_empty());
21037            let materialization_root = root.join("session-environments");
21038            assert!(!std::fs::read_dir(&materialization_root)
21039                .unwrap()
21040                .filter_map(Result::ok)
21041                .any(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())));
21042            drop(shared);
21043            drop(runtime);
21044            let _ = std::fs::remove_dir_all(root);
21045        }
21046    }
21047
21048    #[cfg(feature = "fixture")]
21049    #[test]
21050    fn codex_bundle_exact_home_profile_materializes_only_the_skill_tree() {
21051        let (shared, runtime, receipt, bundle, root) =
21052            codex_bundle_test_shared(CodexHomeProfile::Exact);
21053        let address = SessionAddress {
21054            workspace_id: WorkspaceId::new("primary").unwrap(),
21055            session: SessionKey {
21056                instance_id: AgentInstanceId(310),
21057                generation: SessionGeneration(1),
21058            },
21059        };
21060        let (overlay, guard) = shared
21061            .prepare_session_materialization(
21062                &address,
21063                &agent("codex"),
21064                SessionMode::Pty,
21065                Some(&receipt),
21066                Some(&bundle.receipt()),
21067                None,
21068                None,
21069            )
21070            .unwrap()
21071            .unwrap();
21072        assert!(matches!(overlay, Some(PreparedNativeLaunchOverlay::Instance(_))));
21073        let ownership = shared
21074            .materializations
21075            .lock()
21076            .unwrap_or_else(|poisoned| poisoned.into_inner())
21077            .get(guard.id().unwrap())
21078            .unwrap()
21079            .clone();
21080        let (profile, _, _) = shared
21081            .materialization_profile(
21082                &agent("codex"),
21083                SessionMode::Pty,
21084                Some(&receipt),
21085                Some(&bundle.receipt()),
21086                None,
21087            )
21088            .unwrap()
21089            .unwrap();
21090        let environment = shared
21091            .session_environment_materializer
21092            .as_ref()
21093            .unwrap()
21094            .resolve_environment(&ownership, &profile)
21095            .unwrap();
21096        assert!(environment.iter().any(|mutation| {
21097            mutation.key == OsString::from("CODEX_HOME")
21098                && mutation.value.as_deref() == Some(ownership.provider_home().as_os_str())
21099        }));
21100        assert_eq!(
21101            std::fs::read(ownership.provider_home().join("skills/review-code/SKILL.md"))
21102                .unwrap(),
21103            b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n",
21104        );
21105        assert!(std::fs::read_dir(ownership.bundle_root())
21106            .unwrap()
21107            .next()
21108            .is_none());
21109        drop(guard);
21110        drop(shared);
21111        drop(runtime);
21112        let _ = std::fs::remove_dir_all(root);
21113    }
21114
21115    #[cfg(feature = "fixture")]
21116    #[test]
21117    fn codex_cold_record_resume_revalidates_the_isolated_skill_tree() {
21118        let (shared, runtime, receipt, bundle, root) =
21119            codex_bundle_test_shared(CodexHomeProfile::Exact);
21120        let bundle_receipt = bundle.receipt();
21121        let address = SessionAddress {
21122            workspace_id: WorkspaceId::new("primary").unwrap(),
21123            session: SessionKey {
21124                instance_id: AgentInstanceId(311),
21125                generation: SessionGeneration(1),
21126            },
21127        };
21128        let (_overlay, guard) = shared
21129            .prepare_session_materialization(
21130                &address,
21131                &agent("codex"),
21132                SessionMode::Pty,
21133                Some(&receipt),
21134                Some(&bundle_receipt),
21135                None,
21136                None,
21137            )
21138            .unwrap()
21139            .unwrap();
21140        let materialization_id = guard.id().unwrap().clone();
21141        let skill_path = shared
21142            .materializations
21143            .lock()
21144            .unwrap_or_else(|poisoned| poisoned.into_inner())
21145            .get(&materialization_id)
21146            .unwrap()
21147            .provider_home()
21148            .join("skills/review-code/SKILL.md");
21149        let record_id = shared
21150            .bind_spawn_session_with_materialization(
21151                &address,
21152                agent("codex"),
21153                SessionMode::Pty,
21154                ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap(),
21155                SpawnRecordPolicy::ProviderIdentityOnly,
21156                Some(receipt.clone()),
21157                Some(bundle_receipt.clone()),
21158                None,
21159                Some(materialization_id.clone()),
21160            )
21161            .unwrap()
21162            .unwrap();
21163        guard.retain();
21164        std::fs::write(skill_path, b"changed-after-record-bind").unwrap();
21165
21166        let error = match shared.resolve_record_materialization(
21167                &record_id,
21168                &agent("codex"),
21169                SessionMode::Pty,
21170                Some(&receipt),
21171                Some(&bundle_receipt),
21172                None,
21173            ) {
21174                Ok(_) => panic!("changed Codex skill tree unexpectedly revalidated"),
21175                Err(error) => error,
21176            };
21177        assert_eq!(error.code, NodeFailureCode::BundleMaterializationFailed);
21178        assert_eq!(
21179            shared
21180                .materializations
21181                .lock()
21182                .unwrap_or_else(|poisoned| poisoned.into_inner())
21183                .get(&materialization_id)
21184                .unwrap()
21185                .state(),
21186            MaterializationState::RecoveryRequired,
21187        );
21188        drop(shared);
21189        drop(runtime);
21190        let _ = std::fs::remove_dir_all(root);
21191    }
21192
21193    fn bind_record_owned_materialization_fixture(
21194        shared: &NodeShared,
21195        receipt: &ResolvedEnvironmentProfileReceipt,
21196        instance_id: u64,
21197    ) -> (SessionAddress, SessionRecordId, MaterializationId, PathBuf) {
21198        let address = SessionAddress {
21199            workspace_id: WorkspaceId::new("primary").unwrap(),
21200            session: SessionKey {
21201                instance_id: AgentInstanceId(instance_id),
21202                generation: SessionGeneration(1),
21203            },
21204        };
21205        let (overlay, materialization_guard) = shared
21206            .prepare_session_materialization(
21207                &address,
21208                &agent("claude"),
21209                SessionMode::Pty,
21210                Some(receipt),
21211                None,
21212                None,
21213                None,
21214            )
21215            .unwrap()
21216            .unwrap();
21217        let materialization_id = materialization_guard.id().unwrap().clone();
21218        let materialization_root = shared
21219            .materializations
21220            .lock()
21221            .unwrap_or_else(|poisoned| poisoned.into_inner())
21222            .get(&materialization_id)
21223            .unwrap()
21224            .root()
21225            .to_path_buf();
21226        let selection = shared
21227            .select_environment_profile(
21228                address.session.instance_id,
21229                &agent("claude"),
21230                SessionMode::Pty,
21231                Some(receipt),
21232            )
21233            .unwrap()
21234            .unwrap();
21235        assert!(shared
21236            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21237            .unwrap()
21238            .is_none());
21239        let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
21240        let record_id = shared
21241            .bind_spawn_session_with_materialization(
21242                &address,
21243                agent("claude"),
21244                SessionMode::Pty,
21245                policy,
21246                SpawnRecordPolicy::ProviderIdentityOnly,
21247                Some(receipt.clone()),
21248                None,
21249                None,
21250                Some(materialization_id.clone()),
21251            )
21252            .unwrap()
21253            .unwrap();
21254        selection.retain();
21255        materialization_guard.retain();
21256        (address, record_id, materialization_id, materialization_root)
21257    }
21258
21259    #[test]
21260    fn child_environment_profile_capability_is_state_aware_and_legacy_safe() {
21261        let (shared, _runtime, environment_profile) = environment_profile_test_shared();
21262        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21263            capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
21264        }));
21265        let support = node_compatibility_support_for_manifest(&[], &[]).unwrap();
21266        assert_eq!(support.state_schema.versions.maximum(), NODE_STATE_SCHEMA_V10);
21267        let mut spec = spawn_spec_fixture();
21268        spec.expected_profile_revision =
21269            crate::protocol::SpawnProfileRevision::new("test-r1").unwrap();
21270        assert!(matches!(
21271            &spec.overrides.environment_profile_id,
21272            crate::protocol::SpawnOverride::Inherit,
21273        ));
21274        assert!(!NodeRequest::SpawnSpec { spec: spec.clone() }
21275            .requires_child_environment_profile_capability());
21276        assert!(request_requires_child_environment_profile(
21277            &shared,
21278            &NodeRequest::SpawnSpec { spec: spec.clone() },
21279        ));
21280
21281        let address = terminal_address(1);
21282        shared.bind_session_with_policy(
21283            &address,
21284            ProviderRuntimePolicy::raw_pty(),
21285            Some(environment_profile.clone()),
21286        );
21287        assert!(request_requires_child_environment_profile(
21288            &shared,
21289            &NodeRequest::Input {
21290                session: address.clone(),
21291                text: "x".to_owned(),
21292            },
21293        ));
21294
21295        let mut record = record("claude", "profile-record");
21296        record.active_session = Some(address.clone());
21297        record.environment_profile = Some(environment_profile.clone());
21298        let record_id = record.record_id.clone();
21299        shared.insert_record(record.clone()).unwrap();
21300        assert!(request_requires_child_environment_profile(
21301            &shared,
21302            &NodeRequest::ResumeSessionRecord {
21303                record_id,
21304                terminal_size: gate4agent_types::TerminalSize {
21305                    rows: 24,
21306                    columns: 80,
21307                },
21308                initial_prompt: None,
21309            },
21310        ));
21311
21312        let snapshot = snapshot_for_wire(
21313            &shared, false, true, true, true, false, false, false, false, true,
21314        );
21315        assert_eq!(snapshot.session_records.len(), 1);
21316        assert!(snapshot.session_records[0].environment_profile.is_none());
21317        let event = project_event_without_child_environment_profile(NodeEventEnvelope {
21318            sequence: 1,
21319            event: NodeEvent::SessionRecordUpserted { record },
21320        });
21321        let NodeEvent::SessionRecordUpserted { record } = event.event else {
21322            panic!("record projection changed the event kind");
21323        };
21324        assert!(record.environment_profile.is_none());
21325
21326        let resolved = shared.resolve_spawn_spec(&spec).unwrap();
21327        let mut reply = ResponseEnvelope {
21328            request_id: 1,
21329            result: Ok(NodeResponse::SpawnSpecAccepted {
21330                receipt: resolved.receipt_with_environment(
21331                    shared.incarnation_id,
21332                    address,
21333                    Some(environment_profile),
21334                ),
21335            }),
21336        };
21337        project_response_without_child_environment_profile(&mut reply);
21338        assert_eq!(
21339            reply.result.unwrap_err().code,
21340            NodeFailureCode::UnsupportedCapability,
21341        );
21342    }
21343
21344    #[test]
21345    fn spawn_profile_environment_authority_is_exact_and_legacy_safe() {
21346        let (shared, _runtime, environment_profile) = environment_profile_test_shared();
21347        let snapshot = shared.snapshot();
21348        let profiles = snapshot
21349            .launch_inventory
21350            .as_ref()
21351            .and_then(|inventory| inventory.spawn_profiles.as_ref())
21352            .unwrap();
21353        assert_eq!(profiles.len(), 1);
21354        assert_eq!(profiles[0].environment_profile, Some(environment_profile));
21355        assert!(snapshot.requires_child_environment_profile_capability());
21356
21357        let mut legacy = snapshot.clone();
21358        clear_snapshot_child_environment_profiles(&mut legacy);
21359        assert!(legacy
21360            .launch_inventory
21361            .as_ref()
21362            .unwrap()
21363            .spawn_profiles
21364            .as_ref()
21365            .unwrap()[0]
21366            .environment_profile
21367            .is_none());
21368        assert!(!legacy.requires_child_environment_profile_capability());
21369
21370        let profile = shared.spawn_profiles.iter().next().unwrap().clone();
21371        let bindings = shared
21372            .environment_profiles
21373            .read()
21374            .unwrap_or_else(|poisoned| poisoned.into_inner())
21375            .clone();
21376        let mut provider_mismatch = bindings.clone();
21377        provider_mismatch
21378            .values_mut()
21379            .next()
21380            .unwrap()
21381            .provider = agent("codex");
21382        assert!(resolved_spawn_profile_summary(&profile, &provider_mismatch).is_none());
21383        let mut mode_mismatch = profile;
21384        mode_mismatch.mode = SessionMode::Inline;
21385        assert!(resolved_spawn_profile_summary(&mode_mismatch, &bindings).is_none());
21386
21387        shared
21388            .environment_profiles
21389            .write()
21390            .unwrap_or_else(|poisoned| poisoned.into_inner())
21391            .clear();
21392        assert!(shared
21393            .snapshot()
21394            .launch_inventory
21395            .unwrap()
21396            .spawn_profiles
21397            .unwrap()
21398            .is_empty());
21399    }
21400
21401    #[test]
21402    fn session_bundle_capability_is_state_aware_and_legacy_safe() {
21403        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21404            capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
21405        }));
21406        let shared = terminal_test_shared();
21407        let bundle = ResolvedBundleReceipt {
21408            id: crate::protocol::SpawnBundleId::new("review-bundle").unwrap(),
21409            revision: crate::protocol::SpawnBundleRevision::new("r1").unwrap(),
21410            digest: crate::protocol::SpawnBundleDigest::new(format!(
21411                "sha256:{}",
21412                "0".repeat(64),
21413            ))
21414            .unwrap(),
21415        };
21416        let mut bundled = record("claude", "bundle-record");
21417        bundled.bundle = Some(bundle.clone());
21418        let record_id = bundled.record_id.clone();
21419        shared.insert_record(bundled.clone()).unwrap();
21420        assert!(request_requires_session_bundle(
21421            &shared,
21422            &NodeRequest::ForgetSessionRecord {
21423                record_id: record_id.clone(),
21424            },
21425        ));
21426
21427        let snapshot = snapshot_for_wire(
21428            &shared, false, true, true, true, true, false, false, false, true,
21429        );
21430        assert!(snapshot.session_records[0].bundle.is_none());
21431        let launch = snapshot.launch_inventory.as_ref().unwrap();
21432        assert!(launch.spawn_profiles.is_some());
21433        assert!(launch.bundles.is_none());
21434        let event = project_event_without_session_bundle(NodeEventEnvelope {
21435            sequence: 1,
21436            event: NodeEvent::SessionRecordUpserted { record: bundled },
21437        });
21438        let NodeEvent::SessionRecordUpserted { record } = event.event else {
21439            panic!("record projection changed the event kind");
21440        };
21441        assert!(record.bundle.is_none());
21442
21443        let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
21444        let mut reply = ResponseEnvelope {
21445            request_id: 1,
21446            result: Ok(NodeResponse::SpawnSpecAccepted {
21447                receipt: resolved.receipt_with_materialization(
21448                    shared.incarnation_id,
21449                    terminal_address(17),
21450                    None,
21451                    Some(bundle),
21452                    None,
21453                ),
21454            }),
21455        };
21456        project_response_without_session_bundle(&mut reply);
21457        assert_eq!(
21458            reply.result.unwrap_err().code,
21459            NodeFailureCode::UnsupportedCapability,
21460        );
21461    }
21462
21463    #[test]
21464    fn history_context_pack_capability_redacts_loaded_messages_and_legacy_metadata() {
21465        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21466            capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
21467        }));
21468        let context_id = SpawnContextId::new("context-review").unwrap();
21469        let context = ResolvedContextPackReceipt {
21470            id: context_id.clone(),
21471            digest: crate::protocol::SpawnContextDigest::new(format!(
21472                "sha256:{}",
21473                "c".repeat(64),
21474            ))
21475            .unwrap(),
21476            lineage: ContextPackLineageReceipt {
21477                source_node_id: NodeId::new("node-source").unwrap(),
21478                source_session: terminal_address(1),
21479                source_provider: agent("kimi"),
21480            },
21481            source_message_count: 2,
21482            retained_message_count: 2,
21483            byte_len: 256,
21484            truncated: false,
21485        };
21486        assert!(context.is_valid());
21487        let mut bound_record = record("codex", "context-record");
21488        bound_record.context_id = Some(context_id);
21489        bound_record.context = Some(context.clone());
21490        let session = gate4agent_types::SessionSnapshot {
21491            instance_id: AgentInstanceId(91),
21492            agent_id: agent("kimi"),
21493            transport: TransportKind::Pty,
21494            generation: SessionGeneration(3),
21495            status: SessionStatus::Running,
21496            pending_operation: None,
21497            pending_input: None,
21498            process_id: Some(9001),
21499            terminal_size: None,
21500            terminal_frame: None,
21501            terminal_stale: None,
21502            session_options: None,
21503            capabilities: gate4agent_types::CapabilitySnapshot::default(),
21504            history: gate4agent_types::HistorySnapshot {
21505                pending: None,
21506                candidates: vec![HistoryCandidateSummary {
21507                    id: "candidate-opaque".to_owned(),
21508                    session_id_hint: "session-hint".to_owned(),
21509                    modified_at_unix_ms: Some(1),
21510                }],
21511                loaded_candidate_id: Some("candidate-opaque".to_owned()),
21512                loaded: Some(HistorySessionRecord {
21513                    session_id: "private-vendor-session".to_owned(),
21514                    title: Some("private title".to_owned()),
21515                    cwd: Some(r"C:\private\history-root".to_owned()),
21516                    model: Some("private-model".to_owned()),
21517                    message_count: 2,
21518                    completed_turn_count: None,
21519                    total_tokens: 19,
21520                    messages: vec![
21521                        gate4agent_types::HistoryMessageRecord {
21522                            role: gate4agent_types::HistoryMessageRole::User,
21523                            text: "F7_PRIVATE_HISTORY_MESSAGE".to_owned(),
21524                        },
21525                        gate4agent_types::HistoryMessageRecord {
21526                            role: gate4agent_types::HistoryMessageRole::Assistant,
21527                            text: "private answer".to_owned(),
21528                        },
21529                    ],
21530                }),
21531                last_error: None,
21532            },
21533            resume: gate4agent_types::ResumeSnapshot::default(),
21534            foreground: gate4agent_types::ForegroundSnapshot::default(),
21535            provider: gate4agent_types::ProviderSnapshot::default(),
21536            screen_state: gate4agent_types::PtyScreenState::default(),
21537        };
21538        let original = NodeSnapshot {
21539            node_id: NodeId::new("node-terminal-test").unwrap(),
21540            enabled_providers: vec![agent("kimi"), agent("codex")],
21541            provider_runtime_statuses: ProviderRuntimeStatuses::default(),
21542            workspaces: vec![WorkspaceSnapshot {
21543                workspace_id: WorkspaceId::new("primary").unwrap(),
21544                canonical_root: opaque_windows_path(
21545                    std::env::current_dir().unwrap().to_string_lossy().into_owned(),
21546                ),
21547                sessions: vec![session],
21548                worktree_service_mode: None,
21549                managed_worktree_profiles: None,
21550            }],
21551            session_records: vec![bound_record],
21552            managed_worktrees: Vec::new(),
21553            launch_inventory: None,
21554            agent_progress: Vec::new(),
21555        };
21556
21557        let mut negotiated = original.clone();
21558        project_snapshot_history_for_wire(&mut negotiated, true);
21559        assert_eq!(negotiated.workspaces[0].sessions[0].history.candidates.len(), 1);
21560        assert!(negotiated.workspaces[0].sessions[0].history.loaded.is_none());
21561        assert_eq!(negotiated.session_records[0].context.as_ref(), Some(&context));
21562        assert!(!serde_json::to_string(&negotiated)
21563            .unwrap()
21564            .contains("F7_PRIVATE_HISTORY_MESSAGE"));
21565
21566        let mut legacy = original;
21567        project_snapshot_history_for_wire(&mut legacy, false);
21568        clear_snapshot_context_packs(&mut legacy);
21569        assert_eq!(
21570            legacy.workspaces[0].sessions[0].history,
21571            gate4agent_types::HistorySnapshot::default(),
21572        );
21573        assert!(legacy.session_records[0].context_id.is_none());
21574        assert!(legacy.session_records[0].context.is_none());
21575        let encoded = serde_json::to_string(&legacy).unwrap();
21576        assert!(!encoded.contains("F7_PRIVATE_HISTORY_MESSAGE"));
21577        assert!(!encoded.contains("context-review"));
21578
21579        let mut reply = ResponseEnvelope {
21580            request_id: 7,
21581            result: Ok(NodeResponse::ContextPackExported { context }),
21582        };
21583        project_response_without_context_pack(&mut reply);
21584        assert_eq!(
21585            reply.result.unwrap_err().code,
21586            NodeFailureCode::UnsupportedCapability,
21587        );
21588    }
21589
21590    #[test]
21591    fn forgetting_unbound_context_pack_evicts_replay_but_retained_record_is_busy() {
21592        let shared = terminal_test_shared();
21593        let history = HistorySessionRecord {
21594            session_id: "fixture-history".to_owned(),
21595            title: None,
21596            cwd: None,
21597            model: None,
21598            message_count: 1,
21599            completed_turn_count: None,
21600            total_tokens: 0,
21601            messages: vec![gate4agent_types::HistoryMessageRecord {
21602                role: gate4agent_types::HistoryMessageRole::User,
21603                text: "bounded handoff".to_owned(),
21604            }],
21605        };
21606        let pack = NodeContextPack::export(
21607            ContextPackLineageReceipt {
21608                source_node_id: shared.node_id.clone(),
21609                source_session: terminal_address(1),
21610                source_provider: agent("claude"),
21611            },
21612            &history,
21613        )
21614        .unwrap();
21615        let context = shared
21616            .context_catalog
21617            .write()
21618            .unwrap_or_else(|poisoned| poisoned.into_inner())
21619            .insert(pack)
21620            .unwrap();
21621        let mut retained = record("codex", "context-retained-record");
21622        retained.context_id = Some(context.id.clone());
21623        retained.context = Some(context.clone());
21624        let record_id = retained.record_id.clone();
21625        shared.insert_record(retained).unwrap();
21626        assert_eq!(
21627            shared.forget_context_pack(&context.id).unwrap_err().code,
21628            NodeFailureCode::ContextPackBusy,
21629        );
21630        assert!(shared
21631            .context_catalog
21632            .read()
21633            .unwrap_or_else(|poisoned| poisoned.into_inner())
21634            .get(&context.id)
21635            .is_some());
21636        shared
21637            .session_records
21638            .lock()
21639            .unwrap_or_else(|poisoned| poisoned.into_inner())
21640            .records
21641            .remove(&record_id);
21642
21643        let mut spec = spawn_spec_fixture();
21644        spec.overrides.context_id = crate::protocol::SpawnOverride::Set {
21645            value: context.id.clone(),
21646        };
21647        let resolved = shared.resolve_spawn_spec(&spec).unwrap();
21648        let receipt = resolved.receipt_with_materialization(
21649            shared.incarnation_id,
21650            terminal_address(2),
21651            None,
21652            None,
21653            Some(context.clone()),
21654        );
21655        assert!(receipt.context_binding_is_valid());
21656        shared.remember_spawn_spec(spec.clone(), Ok(receipt), Instant::now());
21657
21658        shared.forget_context_pack(&context.id).unwrap();
21659        assert!(shared
21660            .context_catalog
21661            .read()
21662            .unwrap_or_else(|poisoned| poisoned.into_inner())
21663            .get(&context.id)
21664            .is_none());
21665        assert!(shared
21666            .replay_spawn_spec(&spec, Instant::now())
21667            .unwrap()
21668            .is_none());
21669    }
21670
21671    #[test]
21672    fn environment_profile_selection_is_owned_by_the_exact_session_binding() {
21673        let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
21674        let address = terminal_address(1);
21675        let selection = shared
21676            .select_environment_profile(
21677                address.session.instance_id,
21678                &agent("claude"),
21679                SessionMode::Pty,
21680                Some(&environment_profile),
21681            )
21682            .unwrap()
21683            .unwrap();
21684        shared.bind_session_with_policy(
21685            &address,
21686            ProviderRuntimePolicy::raw_pty(),
21687            Some(environment_profile.clone()),
21688        );
21689        selection.retain();
21690        let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
21691        assert!(matches!(
21692            runtime.remove_native_launch_profile(&native_id),
21693            Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
21694        ));
21695        assert!(shared.remove_binding(&address).is_some());
21696        assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
21697
21698        let mismatched = ResolvedEnvironmentProfileReceipt {
21699            profile_id: environment_profile.profile_id,
21700            profile_revision: crate::protocol::SpawnEnvironmentProfileRevision::new(
21701                "local-claude-r2",
21702            )
21703            .unwrap(),
21704            network_allowlist: None,
21705            browser_profile_id: None,
21706        };
21707        let error = match shared.select_environment_profile(
21708                AgentInstanceId(42),
21709                &agent("claude"),
21710                SessionMode::Pty,
21711                Some(&mismatched),
21712            ) {
21713            Ok(_) => panic!("mismatched environment profile revision was selected"),
21714            Err(error) => error,
21715        };
21716        assert_eq!(
21717            error.code,
21718            NodeFailureCode::EnvironmentProfileBindingMismatch,
21719        );
21720        assert!(!shared
21721            .session_bindings
21722            .lock()
21723            .unwrap_or_else(|poisoned| poisoned.into_inner())
21724            .contains_key(&AgentInstanceId(42)));
21725    }
21726
21727    #[test]
21728    fn combined_environment_and_bundle_overlay_clears_with_its_binding() {
21729        let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
21730        let address = terminal_address(1);
21731        let selection = shared
21732            .select_environment_profile(
21733                address.session.instance_id,
21734                &agent("claude"),
21735                SessionMode::Pty,
21736                Some(&environment_profile),
21737            )
21738            .unwrap()
21739            .unwrap();
21740        let overlay = NativeInstanceLaunchOverlay::new(
21741            agent("claude"),
21742            TransportKind::Pty,
21743            vec![EnvMutation {
21744                key: OsString::from("GATE4AGENT_SESSION_TOKEN"),
21745                value: Some(OsString::from("fixture-secret")),
21746            }],
21747            vec![
21748                OsString::from("--plugin-dir"),
21749                std::env::current_dir().unwrap().into_os_string(),
21750            ],
21751        )
21752        .unwrap();
21753        let instance_overlay = shared
21754            .install_prepared_launch_overlay(
21755                address.session.instance_id,
21756                PreparedNativeLaunchOverlay::Instance(overlay),
21757            )
21758            .unwrap()
21759            .unwrap();
21760        let bundle = ResolvedBundleReceipt {
21761            id: crate::protocol::SpawnBundleId::new("combined-bundle").unwrap(),
21762            revision: crate::protocol::SpawnBundleRevision::new("combined-r1").unwrap(),
21763            digest: crate::protocol::SpawnBundleDigest::new(format!(
21764                "sha256:{}",
21765                "0".repeat(64),
21766            ))
21767            .unwrap(),
21768        };
21769        shared.bind_session_with_materialization(
21770            &address,
21771            ProviderRuntimePolicy::raw_pty(),
21772            Some(environment_profile),
21773            Some(bundle),
21774            None,
21775            None,
21776        );
21777        selection.retain();
21778        instance_overlay.retain();
21779
21780        let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
21781        assert!(matches!(
21782            runtime.remove_native_launch_profile(&native_id),
21783            Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
21784        ));
21785        assert!(shared.remove_binding(&address).is_some());
21786        assert!(!shared
21787            .native_launch_profile_control
21788            .as_ref()
21789            .unwrap()
21790            .clear_native_instance_launch_overlay(address.session.instance_id));
21791        assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
21792    }
21793
21794    #[test]
21795    fn session_environment_materialization_failure_is_pre_child_and_durable() {
21796        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
21797        let address = SessionAddress {
21798            workspace_id: WorkspaceId::new("primary").unwrap(),
21799            session: SessionKey {
21800                instance_id: AgentInstanceId(201),
21801                generation: SessionGeneration(1),
21802            },
21803        };
21804        let error = match shared.prepare_session_materialization(
21805            &address,
21806            &agent("claude"),
21807            SessionMode::Pty,
21808            Some(&receipt),
21809            None,
21810            None,
21811            None,
21812        ) {
21813            Ok(_) => panic!("denied local reference unexpectedly materialized"),
21814            Err(error) => error,
21815        };
21816        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21817        assert!(shared.handle.snapshot().sessions.is_empty());
21818        assert!(shared
21819            .session_bindings
21820            .lock()
21821            .unwrap_or_else(|poisoned| poisoned.into_inner())
21822            .is_empty());
21823        assert!(shared
21824            .materializations
21825            .lock()
21826            .unwrap_or_else(|poisoned| poisoned.into_inner())
21827            .is_empty());
21828        drop(shared);
21829        drop(runtime);
21830        std::fs::remove_dir_all(root).unwrap();
21831    }
21832
21833    #[test]
21834    fn session_environment_binding_retains_on_stop_and_cleans_after_remove_reap() {
21835        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21836        let address = SessionAddress {
21837            workspace_id: WorkspaceId::new("primary").unwrap(),
21838            session: SessionKey {
21839                instance_id: AgentInstanceId(202),
21840                generation: SessionGeneration(1),
21841            },
21842        };
21843        let (overlay, materialization_guard) = shared
21844            .prepare_session_materialization(
21845                &address,
21846                &agent("claude"),
21847                SessionMode::Pty,
21848                Some(&receipt),
21849                None,
21850                None,
21851                None,
21852            )
21853            .unwrap()
21854            .unwrap();
21855        let materialization_id = materialization_guard.id().unwrap().clone();
21856        let materialization_root = shared
21857            .materializations
21858            .lock()
21859            .unwrap_or_else(|poisoned| poisoned.into_inner())
21860            .get(&materialization_id)
21861            .unwrap()
21862            .root()
21863            .to_path_buf();
21864        let selection = shared
21865            .select_environment_profile(
21866                address.session.instance_id,
21867                &agent("claude"),
21868                SessionMode::Pty,
21869                Some(&receipt),
21870            )
21871            .unwrap()
21872            .unwrap();
21873        assert!(shared
21874            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21875            .unwrap()
21876            .is_none());
21877        shared.bind_session_with_materialization(
21878            &address,
21879            ProviderRuntimePolicy::raw_pty(),
21880            Some(receipt),
21881            None,
21882            None,
21883            Some(materialization_id),
21884        );
21885        selection.retain();
21886        materialization_guard.retain();
21887
21888        assert!(materialization_root.is_dir());
21889        assert!(shared
21890            .session_bindings
21891            .lock()
21892            .unwrap_or_else(|poisoned| poisoned.into_inner())
21893            .contains_key(&address.session.instance_id));
21894
21895        let removed = shared.remove_binding(&address).unwrap();
21896        shared
21897            .cleanup_session_owned_materialization(&address, &removed)
21898            .unwrap();
21899        assert!(!materialization_root.exists());
21900        assert!(shared
21901            .materializations
21902            .lock()
21903            .unwrap_or_else(|poisoned| poisoned.into_inner())
21904            .is_empty());
21905        drop(shared);
21906        drop(runtime);
21907        std::fs::remove_dir_all(root).unwrap();
21908    }
21909
21910    #[tokio::test]
21911    async fn record_owned_session_environment_reuses_on_resume_and_forget_cleans() {
21912        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21913        let address = SessionAddress {
21914            workspace_id: WorkspaceId::new("primary").unwrap(),
21915            session: SessionKey {
21916                instance_id: AgentInstanceId(203),
21917                generation: SessionGeneration(1),
21918            },
21919        };
21920        let (overlay, materialization_guard) = shared
21921            .prepare_session_materialization(
21922                &address,
21923                &agent("claude"),
21924                SessionMode::Pty,
21925                Some(&receipt),
21926                None,
21927                None,
21928                None,
21929            )
21930            .unwrap()
21931            .unwrap();
21932        let materialization_id = materialization_guard.id().unwrap().clone();
21933        let materialization_root = shared
21934            .materializations
21935            .lock()
21936            .unwrap_or_else(|poisoned| poisoned.into_inner())
21937            .get(&materialization_id)
21938            .unwrap()
21939            .root()
21940            .to_path_buf();
21941        let selection = shared
21942            .select_environment_profile(
21943                address.session.instance_id,
21944                &agent("claude"),
21945                SessionMode::Pty,
21946                Some(&receipt),
21947            )
21948            .unwrap()
21949            .unwrap();
21950        assert!(shared
21951            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21952            .unwrap()
21953            .is_none());
21954        let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
21955        let record_id = shared
21956            .bind_spawn_session_with_materialization(
21957                &address,
21958                agent("claude"),
21959                SessionMode::Pty,
21960                policy,
21961                SpawnRecordPolicy::ProviderIdentityOnly,
21962                Some(receipt.clone()),
21963                None,
21964                None,
21965                Some(materialization_id.clone()),
21966            )
21967            .unwrap()
21968            .unwrap();
21969        selection.retain();
21970        materialization_guard.retain();
21971        assert_eq!(
21972            shared
21973                .materializations
21974                .lock()
21975                .unwrap_or_else(|poisoned| poisoned.into_inner())
21976                .get(&materialization_id)
21977                .unwrap()
21978                .owner(),
21979            &MaterializationOwner::Record {
21980                record_id: record_id.clone(),
21981            },
21982        );
21983
21984        let removed = shared.remove_binding(&address).unwrap();
21985        shared
21986            .cleanup_session_owned_materialization(&address, &removed)
21987            .unwrap();
21988        assert!(materialization_root.is_dir());
21989        assert!(shared
21990            .resolve_record_materialization(
21991                &record_id,
21992                &agent("claude"),
21993                SessionMode::Pty,
21994                Some(&receipt),
21995                None,
21996                None,
21997            )
21998            .unwrap()
21999            .is_some());
22000        {
22001            let mut records = shared
22002                .session_records
22003                .lock()
22004                .unwrap_or_else(|poisoned| poisoned.into_inner());
22005            let record = records.records.get_mut(&record_id).unwrap();
22006            record.active_session = None;
22007            record.state = ManagedSessionState::Unavailable;
22008        }
22009        shared.forget_session_record(&record_id).await.unwrap();
22010        assert!(!materialization_root.exists());
22011        assert!(shared
22012            .materializations
22013            .lock()
22014            .unwrap_or_else(|poisoned| poisoned.into_inner())
22015            .is_empty());
22016        drop(shared);
22017        drop(runtime);
22018        std::fs::remove_dir_all(root).unwrap();
22019    }
22020
22021    #[test]
22022    fn record_materialization_revalidation_failure_persists_recovery_required() {
22023        let (mut shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
22024        let state_path = root.join("node-state-v6.json");
22025        shared.state_path = Some(state_path.clone());
22026        let (_address, _record_id, materialization_id, materialization_root) =
22027            bind_record_owned_materialization_fixture(&shared, &receipt, 205);
22028        std::fs::write(
22029            materialization_root.join(".gate4agent-materialization-owner"),
22030            b"tampered",
22031        )
22032        .unwrap();
22033
22034        let error = match shared.resolve_record_materialization(
22035            &_record_id,
22036            &agent("claude"),
22037            SessionMode::Pty,
22038            Some(&receipt),
22039            None,
22040            None,
22041        ) {
22042            Ok(_) => panic!("tampered owner marker unexpectedly revalidated"),
22043            Err(error) => error,
22044        };
22045        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
22046        assert_eq!(
22047            shared
22048                .materializations
22049                .lock()
22050                .unwrap_or_else(|poisoned| poisoned.into_inner())
22051                .get(&materialization_id)
22052                .unwrap()
22053                .state(),
22054            MaterializationState::RecoveryRequired,
22055        );
22056        let loaded = session_registry::load(Some(&state_path), &shared.node_id).unwrap();
22057        assert_eq!(loaded.materializations.len(), 1);
22058        assert_eq!(
22059            loaded.materializations[0].state(),
22060            MaterializationState::RecoveryRequired,
22061        );
22062        drop(shared);
22063        drop(runtime);
22064        std::fs::remove_dir_all(root).unwrap();
22065    }
22066
22067    #[test]
22068    fn transient_record_materialization_resolution_failure_remains_ready() {
22069        let (shared, runtime, receipt, root, deny) = materialization_test_shared(false);
22070        let (_address, record_id, materialization_id, _materialization_root) =
22071            bind_record_owned_materialization_fixture(&shared, &receipt, 206);
22072        deny.store(true, Ordering::Release);
22073
22074        let error = match shared.resolve_record_materialization(
22075            &record_id,
22076            &agent("claude"),
22077            SessionMode::Pty,
22078            Some(&receipt),
22079            None,
22080            None,
22081        ) {
22082            Ok(_) => panic!("denied transient resolver unexpectedly succeeded"),
22083            Err(error) => error,
22084        };
22085        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
22086        assert_eq!(
22087            shared
22088                .materializations
22089                .lock()
22090                .unwrap_or_else(|poisoned| poisoned.into_inner())
22091                .get(&materialization_id)
22092                .unwrap()
22093                .state(),
22094            MaterializationState::Ready,
22095        );
22096        drop(shared);
22097        drop(runtime);
22098        std::fs::remove_dir_all(root).unwrap();
22099    }
22100
22101    #[test]
22102    fn startup_missing_record_materialization_is_not_resumable() {
22103        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
22104        let record_id = SessionRecordId::new("sr-missing-materialization").unwrap();
22105        shared
22106            .insert_record(ManagedSessionRecord {
22107                record_id: record_id.clone(),
22108                display_name: "missing materialization".to_owned(),
22109                provider: agent("claude"),
22110                mode: SessionMode::Pty,
22111                state: ManagedSessionState::Dormant,
22112                workspace_id: WorkspaceId::new("primary").unwrap(),
22113                canonical_root: opaque_windows_path(
22114                    std::env::current_dir().unwrap().to_string_lossy().into_owned(),
22115                ),
22116                provider_session: Some(gate4agent_types::ProviderSessionIdentity {
22117                    key: gate4agent_types::ProviderSessionKey::SessionId,
22118                    id: "provider-session-missing-materialization".to_owned(),
22119                    transcript_path: None,
22120                }),
22121                active_session: None,
22122                environment_profile: Some(receipt),
22123                bundle: None,
22124                context_id: None,
22125                context: None,
22126                exported_context: None,
22127                task_binding: None,
22128                created_at_unix_ms: 1,
22129                updated_at_unix_ms: 1,
22130                last_error: None,
22131            })
22132            .unwrap();
22133
22134        shared.reconcile_materializations();
22135        let record = shared.record(&record_id).unwrap();
22136        assert_eq!(record.state, ManagedSessionState::Unavailable);
22137        assert!(record.provider_session.is_none());
22138        assert_eq!(
22139            record.last_error.as_deref(),
22140            Some("environment-profile-unavailable"),
22141        );
22142        drop(shared);
22143        drop(runtime);
22144        std::fs::remove_dir_all(root).unwrap();
22145    }
22146
22147    #[tokio::test]
22148    async fn managed_worktree_cleanup_waits_for_session_environment_cleanup() {
22149        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
22150        let lease = install_managed_lease(&shared);
22151        let address = SessionAddress {
22152            workspace_id: WorkspaceId::new("primary").unwrap(),
22153            session: SessionKey {
22154                instance_id: AgentInstanceId(204),
22155                generation: SessionGeneration(1),
22156            },
22157        };
22158        let (overlay, materialization_guard) = shared
22159            .prepare_session_materialization(
22160                &address,
22161                &agent("claude"),
22162                SessionMode::Pty,
22163                Some(&receipt),
22164                None,
22165                None,
22166                Some(lease.lease_id.clone()),
22167            )
22168            .unwrap()
22169            .unwrap();
22170        let materialization_id = materialization_guard.id().unwrap().clone();
22171        let materialization_root = shared
22172            .materializations
22173            .lock()
22174            .unwrap_or_else(|poisoned| poisoned.into_inner())
22175            .get(&materialization_id)
22176            .unwrap()
22177            .root()
22178            .to_path_buf();
22179        let selection = shared
22180            .select_environment_profile(
22181                address.session.instance_id,
22182                &agent("claude"),
22183                SessionMode::Pty,
22184                Some(&receipt),
22185            )
22186            .unwrap()
22187            .unwrap();
22188        assert!(shared
22189            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
22190            .unwrap()
22191            .is_none());
22192        shared.bind_session_with_materialization(
22193            &address,
22194            ProviderRuntimePolicy::raw_pty(),
22195            Some(receipt),
22196            None,
22197            None,
22198            Some(materialization_id),
22199        );
22200        selection.retain();
22201        materialization_guard.retain();
22202
22203        let blocked = shared
22204            .cleanup_managed_worktree(&lease.lease_id, false)
22205            .await
22206            .unwrap_err();
22207        assert_eq!(blocked.code, NodeFailureCode::ManagedWorktreeRecoveryRequired);
22208        assert!(materialization_root.is_dir());
22209        let removed = shared.remove_binding(&address).unwrap();
22210        shared
22211            .cleanup_session_owned_materialization(&address, &removed)
22212            .unwrap();
22213        assert!(!materialization_root.exists());
22214        drop(shared);
22215        drop(runtime);
22216        std::fs::remove_dir_all(root).unwrap();
22217    }
22218
22219    #[tokio::test]
22220    async fn raw_managed_record_is_inventory_only_and_does_not_block_session_cleanup() {
22221        let git_root = temporary_workspace_root("raw-managed-inventory");
22222        let source = git_root.join("source");
22223        let allocation = git_root.join("allocation");
22224        std::fs::create_dir_all(&source).unwrap();
22225        std::fs::create_dir_all(&allocation).unwrap();
22226        run_test_git(&source, &["init"]);
22227        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
22228        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
22229        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
22230        run_test_git(&source, &["add", "seed.txt"]);
22231        run_test_git(&source, &["commit", "-m", "seed"]);
22232        let profile = ManagedWorktreeProfile::new(
22233            WorktreeProfileId::new("default").unwrap(),
22234            crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
22235            &allocation,
22236            "gate4agent",
22237            "HEAD",
22238            ManagedWorktreeRetention::RemoveWhenReleased,
22239        )
22240        .unwrap();
22241        let workspace = WorkspaceConfig::new(
22242            WorkspaceId::new("primary").unwrap(),
22243            &source,
22244        )
22245        .unwrap()
22246        .with_worktree_service_mode(WorktreeServiceMode::Managed)
22247        .with_managed_worktree_profile(profile.clone())
22248        .unwrap();
22249        let (shared, mut runtime, receipt, materialization_fixture_root, _deny) =
22250            materialization_test_shared_with_workspace(false, workspace);
22251        let target = PathBuf::from(profile.allocation_root()).join("mw-raw-inventory");
22252        let source_text = source.to_string_lossy().into_owned();
22253        let target_text = target.to_string_lossy().into_owned();
22254        let created = create_git_worktree(
22255            &source_text,
22256            &target_text,
22257            "gate4agent/mw-raw-inventory",
22258            Some("HEAD"),
22259        )
22260        .await
22261        .unwrap();
22262        let lease = ManagedWorktreeLeaseRecord {
22263            lease_id: ManagedWorktreeLeaseId::new("mw-raw-inventory").unwrap(),
22264            source_workspace_id: WorkspaceId::new("primary").unwrap(),
22265            workspace_id: WorkspaceId::new("managed-raw-inventory").unwrap(),
22266            profile_id: profile.profile_id().clone(),
22267            profile_revision: profile.revision().clone(),
22268            target_root: created.path.clone(),
22269            branch: "gate4agent/mw-raw-inventory".to_owned(),
22270            base_commit: created.head.clone(),
22271            expected_head: Some(created.head.clone()),
22272            retention: ManagedWorktreeRetention::RemoveWhenReleased,
22273            state: ManagedWorktreeLeaseState::Ready,
22274            session_holders: Vec::new(),
22275            record_holders: Vec::new(),
22276            cleanup_failure: None,
22277            created_at_unix_ms: 1,
22278            updated_at_unix_ms: 1,
22279        };
22280        shared
22281            .workspaces
22282            .write()
22283            .unwrap_or_else(|poisoned| poisoned.into_inner())
22284            .insert(lease.workspace_id.clone(), created.path.clone());
22285        *shared
22286            .managed_worktrees
22287            .lock()
22288            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22289            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
22290        let address = SessionAddress {
22291            workspace_id: lease.workspace_id.clone(),
22292            session: SessionKey {
22293                instance_id: AgentInstanceId(205),
22294                generation: SessionGeneration(1),
22295            },
22296        };
22297        let (overlay, materialization_guard) = shared
22298            .prepare_session_materialization(
22299                &address,
22300                &agent("claude"),
22301                SessionMode::Pty,
22302                Some(&receipt),
22303                None,
22304                None,
22305                Some(lease.lease_id.clone()),
22306            )
22307            .unwrap()
22308            .unwrap();
22309        let materialization_id = materialization_guard.id().unwrap().clone();
22310        let materialization_root = shared
22311            .materializations
22312            .lock()
22313            .unwrap_or_else(|poisoned| poisoned.into_inner())
22314            .get(&materialization_id)
22315            .unwrap()
22316            .root()
22317            .to_path_buf();
22318        let selection = shared
22319            .select_environment_profile(
22320                address.session.instance_id,
22321                &agent("claude"),
22322                SessionMode::Pty,
22323                Some(&receipt),
22324            )
22325            .unwrap()
22326            .unwrap();
22327        assert!(shared
22328            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
22329            .unwrap()
22330            .is_none());
22331        let record_id = shared
22332            .bind_spawn_session_with_materialization(
22333                &address,
22334                agent("claude"),
22335                SessionMode::Pty,
22336                ProviderRuntimePolicy::raw_pty(),
22337                SpawnRecordPolicy::Always,
22338                Some(receipt.clone()),
22339                None,
22340                None,
22341                Some(materialization_id.clone()),
22342            )
22343            .unwrap()
22344            .unwrap();
22345        selection.retain();
22346        materialization_guard.retain();
22347        let record = shared.record(&record_id).unwrap();
22348        assert_eq!(record.state, ManagedSessionState::Live);
22349        assert!(record.provider_session.is_none());
22350        assert!(record.environment_profile.is_none());
22351        assert!(record.bundle.is_none());
22352        assert!(record.context_id.is_none());
22353        assert!(record.context.is_none());
22354        assert_eq!(record.active_session.as_ref(), Some(&address));
22355        assert_eq!(
22356            shared
22357                .materializations
22358                .lock()
22359                .unwrap_or_else(|poisoned| poisoned.into_inner())
22360                .get(&materialization_id)
22361                .unwrap()
22362                .owner(),
22363            &MaterializationOwner::Session {
22364                incarnation_id: shared.incarnation_id,
22365                instance_id: address.session.instance_id,
22366                generation: address.session.generation,
22367            },
22368        );
22369        {
22370            let mut bindings = shared
22371                .session_bindings
22372                .lock()
22373                .unwrap_or_else(|poisoned| poisoned.into_inner());
22374            bindings
22375                .get_mut(&address.session.instance_id)
22376                .unwrap()
22377                .managed_worktree_lease_id = Some(lease.lease_id.clone());
22378        }
22379        let lease_snapshot = shared
22380            .managed_worktrees
22381            .lock()
22382            .unwrap_or_else(|poisoned| poisoned.into_inner())
22383            .bind_session(
22384                &lease.lease_id,
22385                ManagedWorktreeSessionHolder {
22386                    incarnation_id: shared.incarnation_id,
22387                    instance_id: address.session.instance_id,
22388                    generation: address.session.generation,
22389                },
22390                None,
22391                unix_time_ms(),
22392            )
22393            .unwrap();
22394        assert_eq!(lease_snapshot.active_session_count, 1);
22395        assert_eq!(lease_snapshot.managed_record_count, 0);
22396        shared.persist_state().unwrap();
22397        assert!(shared
22398            .managed_worktrees
22399            .lock()
22400            .unwrap_or_else(|poisoned| poisoned.into_inner())
22401            .get(&lease.lease_id)
22402            .unwrap()
22403            .record_holders
22404            .is_empty());
22405        shared
22406            .handle
22407            .dispatch(shared.prepare_command(ControlCommand::Register {
22408                instance_id: address.session.instance_id,
22409                agent_id: agent("claude"),
22410                transport: TransportKind::Pty,
22411            }))
22412            .unwrap();
22413        runtime.tick().await;
22414        shared.reconcile_managed_record(
22415            &record_id,
22416            &address,
22417            &ControlEvent {
22418                sequence: 1,
22419                command_id: None,
22420                instance_id: address.session.instance_id,
22421                generation: address.session.generation,
22422                event: ControlEventKind::Removed,
22423            },
22424            false,
22425            false,
22426        );
22427        let remove = shared.remove_session(&address);
22428        let drive_runtime = async {
22429            runtime.tick().await;
22430        };
22431        let (removed, ()) = tokio::join!(remove, drive_runtime);
22432        removed.unwrap();
22433
22434        assert!(!materialization_root.exists());
22435        assert!(!Path::new(&created.path).exists());
22436        assert_eq!(
22437            shared
22438                .managed_worktrees
22439                .lock()
22440                .unwrap_or_else(|poisoned| poisoned.into_inner())
22441                .get(&lease.lease_id)
22442                .unwrap()
22443                .state,
22444            ManagedWorktreeLeaseState::Removed,
22445        );
22446        let renamed = shared
22447            .rename_session_record(&record_id, "raw managed inventory".to_owned())
22448            .unwrap();
22449        assert_eq!(renamed.record_id, record_id);
22450        assert_eq!(renamed.display_name, "raw managed inventory");
22451        assert_eq!(renamed.state, ManagedSessionState::Unavailable);
22452        assert!(renamed.active_session.is_none());
22453
22454        drop(shared);
22455        drop(runtime);
22456        std::fs::remove_dir_all(materialization_fixture_root).unwrap();
22457        std::fs::remove_dir_all(git_root).unwrap();
22458    }
22459
22460    fn install_managed_lease(shared: &NodeShared) -> ManagedWorktreeLeaseRecord {
22461        let root = std::env::current_dir().unwrap()
22462            .join("managed-test-target")
22463            .to_string_lossy()
22464            .into_owned();
22465        let lease = ManagedWorktreeLeaseRecord {
22466            lease_id: ManagedWorktreeLeaseId::new("mw-test").unwrap(),
22467            source_workspace_id: WorkspaceId::new("primary").unwrap(),
22468            workspace_id: WorkspaceId::new("managed-test").unwrap(),
22469            profile_id: WorktreeProfileId::new("default").unwrap(),
22470            profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
22471            target_root: root.clone(),
22472            branch: "gate4agent/mw-test".to_owned(),
22473            base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
22474            expected_head: Some("0123456789abcdef0123456789abcdef01234567".to_owned()),
22475            retention: ManagedWorktreeRetention::RemoveWhenReleased,
22476            state: ManagedWorktreeLeaseState::Ready,
22477            session_holders: Vec::new(),
22478            record_holders: Vec::new(),
22479            cleanup_failure: None,
22480            created_at_unix_ms: 1,
22481            updated_at_unix_ms: 1,
22482        };
22483        shared.workspaces.write()
22484            .unwrap_or_else(|poisoned| poisoned.into_inner())
22485            .insert(lease.workspace_id.clone(), root);
22486        *shared.managed_worktrees.lock()
22487            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22488            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
22489        lease
22490    }
22491
22492    #[cfg(feature = "fixture")]
22493    #[test]
22494    fn monitoring_fixture_managed_spawn_retains_effective_record_holder_and_converges_live() {
22495        let mut shared = terminal_test_shared();
22496        shared.fixture_semantic_hook_policy = true;
22497        let lease = install_managed_lease(&shared);
22498        let address = SessionAddress {
22499            workspace_id: lease.workspace_id.clone(),
22500            session: SessionKey {
22501                instance_id: AgentInstanceId(206),
22502                generation: SessionGeneration(1),
22503            },
22504        };
22505        let pre_admission_policy = ProviderRuntimePolicy::raw_pty();
22506        let effective_runtime_policy =
22507            shared.effective_spawn_runtime_policy(pre_admission_policy);
22508        assert_ne!(effective_runtime_policy, pre_admission_policy);
22509        assert!(effective_runtime_policy.provider_session_identity);
22510
22511        let record_id = shared
22512            .bind_spawn_session(
22513                &address,
22514                agent("claude"),
22515                SessionMode::Pty,
22516                effective_runtime_policy,
22517                SpawnRecordPolicy::Always,
22518                None,
22519            )
22520            .unwrap()
22521            .unwrap();
22522        assert_eq!(
22523            shared.record(&record_id).unwrap().state,
22524            ManagedSessionState::IdentityPending,
22525        );
22526        let (record_holder, raw_inventory_record_id) = {
22527            let mut bindings = shared
22528                .session_bindings
22529                .lock()
22530                .unwrap_or_else(|poisoned| poisoned.into_inner());
22531            let binding = bindings.get_mut(&address.session.instance_id).unwrap();
22532            binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
22533            managed_spawn_record_ownership(binding, effective_runtime_policy)
22534        };
22535        assert_eq!(record_holder.as_ref(), Some(&record_id));
22536        assert!(raw_inventory_record_id.is_none());
22537        let snapshot = shared
22538            .managed_worktrees
22539            .lock()
22540            .unwrap_or_else(|poisoned| poisoned.into_inner())
22541            .bind_session(
22542                &lease.lease_id,
22543                ManagedWorktreeSessionHolder {
22544                    incarnation_id: shared.incarnation_id,
22545                    instance_id: address.session.instance_id,
22546                    generation: address.session.generation,
22547                },
22548                record_holder,
22549                unix_time_ms(),
22550            )
22551            .unwrap();
22552        assert_eq!(snapshot.managed_record_count, 1);
22553        assert_eq!(
22554            shared
22555                .managed_worktrees
22556                .lock()
22557                .unwrap_or_else(|poisoned| poisoned.into_inner())
22558                .get(&lease.lease_id)
22559                .unwrap()
22560                .record_holders,
22561            vec![record_id.clone()],
22562        );
22563
22564        let identity = ProviderSessionIdentity {
22565            key: ProviderSessionKey::SessionId,
22566            id: "monitoring-fixture-managed-session".to_owned(),
22567            transcript_path: None,
22568        };
22569        shared.publish_control(ControlEvent {
22570            sequence: 1,
22571            command_id: None,
22572            instance_id: address.session.instance_id,
22573            generation: address.session.generation,
22574            event: ControlEventKind::ProviderEvent {
22575                sequence: 1,
22576                source: gate4agent_types::ProviderSource {
22577                    family: AdapterFamily::Hook,
22578                    binding: AdapterBinding::new(
22579                        gate4agent_types::AdapterId::new("claude-code").unwrap(),
22580                        "fixture/v1",
22581                        gate4agent_types::AdapterVerification::SyntheticFixture,
22582                    )
22583                    .unwrap(),
22584                },
22585                source_sequence: 1,
22586                event: ProviderEvent::SessionIdentityObserved {
22587                    identity: identity.clone(),
22588                },
22589            },
22590        });
22591
22592        let live = shared.record(&record_id).unwrap();
22593        assert_eq!(live.state, ManagedSessionState::Live);
22594        assert_eq!(live.provider_session.as_ref(), Some(&identity));
22595        assert_eq!(live.active_session.as_ref(), Some(&address));
22596        assert_eq!(
22597            shared
22598                .managed_worktrees
22599                .lock()
22600                .unwrap_or_else(|poisoned| poisoned.into_inner())
22601                .get(&lease.lease_id)
22602                .unwrap()
22603                .record_holders,
22604            vec![record_id],
22605        );
22606    }
22607
22608    fn managed_v2_test_shared(
22609        allocation_root: &Path,
22610        state_path: Option<PathBuf>,
22611        profile_revision: &str,
22612    ) -> NodeShared {
22613        let profile = ManagedWorktreeProfile::new(
22614            WorktreeProfileId::new("default").unwrap(),
22615            WorktreeProfileRevision::new(profile_revision).unwrap(),
22616            allocation_root,
22617            "gate4agent",
22618            "HEAD",
22619            ManagedWorktreeRetention::RemoveWhenReleased,
22620        )
22621        .unwrap();
22622        let workspace = WorkspaceConfig::new(
22623            WorkspaceId::new("primary").unwrap(),
22624            std::env::current_dir().unwrap(),
22625        )
22626        .unwrap()
22627        .with_worktree_service_mode(WorktreeServiceMode::Managed)
22628        .with_managed_worktree_profile(profile)
22629        .unwrap();
22630        let catalog = active_registry().unwrap();
22631        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22632        let mut shared = NodeShared::new(
22633            handle,
22634            "fixture-token".to_owned(),
22635            NodeId::new("node-terminal-test").unwrap(),
22636            vec![workspace],
22637            vec![agent("claude")],
22638        );
22639        shared.state_path = state_path;
22640        shared
22641    }
22642
22643    fn managed_v2_request(idempotency_key: &str) -> ManagedWorktreeSpawnRequestV2 {
22644        let mut spawn_spec = spawn_spec_fixture();
22645        spawn_spec.idempotency_key = SpawnIdempotencyKey::new(idempotency_key).unwrap();
22646        ManagedWorktreeSpawnRequestV2 {
22647            spawn_spec,
22648            worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
22649            expected_profile_revision: WorktreeProfileRevision::new("v1").unwrap(),
22650        }
22651    }
22652
22653    #[test]
22654    fn managed_spawn_receipt_rebinds_allocated_worktree_and_roundtrips_wire() {
22655        let shared = terminal_test_shared();
22656        let mut lease = install_managed_lease(&shared);
22657        let session = SessionAddress {
22658            workspace_id: lease.workspace_id.clone(),
22659            session: SessionKey {
22660                instance_id: AgentInstanceId(73),
22661                generation: SessionGeneration(4),
22662            },
22663        };
22664        lease.state = ManagedWorktreeLeaseState::InUse;
22665        lease.session_holders.push(ManagedWorktreeSessionHolder {
22666            incarnation_id: shared.incarnation_id,
22667            instance_id: session.session.instance_id,
22668            generation: session.session.generation,
22669        });
22670        let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
22671        assert!(resolved.target.worktree_id.is_none());
22672
22673        let receipt = managed_spawn_receipt(
22674            &resolved,
22675            shared.incarnation_id,
22676            session.clone(),
22677            lease.snapshot(),
22678            None,
22679            None,
22680            None,
22681        );
22682        assert!(resolved.target.worktree_id.is_none());
22683        assert_eq!(receipt.spawn.target.workspace_id, lease.source_workspace_id);
22684        assert_eq!(receipt.spawn.target.worktree_id.as_ref(), Some(&lease.workspace_id));
22685        assert_eq!(receipt.spawn.session, session);
22686        assert_eq!(receipt.lease.workspace_id, lease.workspace_id);
22687        let encoded = serde_json::to_vec(&receipt).unwrap();
22688        let decoded: ManagedWorktreeSpawnReceipt = serde_json::from_slice(&encoded).unwrap();
22689        assert_eq!(decoded, receipt);
22690    }
22691
22692    #[tokio::test]
22693    async fn managed_spawn_v2_rejects_profile_revision_before_allocation() {
22694        let root = temporary_workspace_root("managed-v2-revision");
22695        let allocation = root.join("allocation");
22696        std::fs::create_dir_all(&allocation).unwrap();
22697        let shared = managed_v2_test_shared(
22698            &allocation,
22699            Some(root.join("node-state.json")),
22700            "v1",
22701        );
22702        let mut request = managed_v2_request("managed-v2-revision");
22703        request.expected_profile_revision = WorktreeProfileRevision::new("v2").unwrap();
22704
22705        let failure = shared.spawn_managed_worktree_v2(request).await.unwrap_err();
22706
22707        assert_eq!(
22708            failure.code,
22709            NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
22710        );
22711        assert!(shared
22712            .managed_worktrees
22713            .lock()
22714            .unwrap_or_else(|poisoned| poisoned.into_inner())
22715            .records()
22716            .is_empty());
22717        assert!(shared
22718            .managed_spawn_replays
22719            .lock()
22720            .unwrap_or_else(|poisoned| poisoned.into_inner())
22721            .is_empty());
22722        std::fs::remove_dir_all(root).unwrap();
22723    }
22724
22725    #[test]
22726    fn managed_spawn_v2_concurrent_same_key_has_one_durable_reservation() {
22727        let root = temporary_workspace_root("managed-v2-concurrent");
22728        let allocation = root.join("allocation");
22729        std::fs::create_dir_all(&allocation).unwrap();
22730        let shared = Arc::new(managed_v2_test_shared(
22731            &allocation,
22732            Some(root.join("node-state.json")),
22733            "v1",
22734        ));
22735        let request = managed_v2_request("managed-v2-concurrent");
22736        let barrier = Arc::new(std::sync::Barrier::new(3));
22737        let mut joins = Vec::new();
22738        for _ in 0..2 {
22739            let shared = Arc::clone(&shared);
22740            let request = request.clone();
22741            let barrier = Arc::clone(&barrier);
22742            joins.push(std::thread::spawn(move || {
22743                let digest = managed_spawn_request_digest_v2(&request).unwrap();
22744                barrier.wait();
22745                shared.acquire_durable_managed_spawn_v2(&request, digest)
22746            }));
22747        }
22748        barrier.wait();
22749        let results = joins
22750            .into_iter()
22751            .map(|join| join.join().unwrap())
22752            .collect::<Vec<_>>();
22753        assert_eq!(
22754            results
22755                .iter()
22756                .filter(|result| {
22757                    matches!(result, Ok(DurableManagedSpawnReplayDecision::Reserved))
22758                })
22759                .count(),
22760            1,
22761        );
22762        assert_eq!(
22763            results
22764                .iter()
22765                .filter(|result| {
22766                    matches!(result, Err(error) if error.code == NodeFailureCode::BackendBusy)
22767                })
22768                .count(),
22769            1,
22770        );
22771        assert_eq!(
22772            shared
22773                .managed_spawn_replays
22774                .lock()
22775                .unwrap_or_else(|poisoned| poisoned.into_inner())
22776                .len(),
22777            1,
22778        );
22779        assert!(shared
22780            .managed_worktrees
22781            .lock()
22782            .unwrap_or_else(|poisoned| poisoned.into_inner())
22783            .records()
22784            .is_empty());
22785        drop(shared);
22786        std::fs::remove_dir_all(root).unwrap();
22787    }
22788
22789    #[tokio::test]
22790    async fn managed_spawn_v2_linked_pending_reopen_fails_closed_without_reallocation() {
22791        let root = temporary_workspace_root("managed-v2-linked-pending");
22792        let allocation = root.join("allocation");
22793        std::fs::create_dir_all(&allocation).unwrap();
22794        let state_path = root.join("node-state.json");
22795        let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
22796        let lease = install_managed_lease(&shared);
22797        let request = managed_v2_request("managed-v2-linked-pending");
22798        assert!(matches!(
22799            shared
22800                .acquire_durable_managed_spawn_v2(
22801                    &request,
22802                    managed_spawn_request_digest_v2(&request).unwrap(),
22803                )
22804                .unwrap(),
22805            DurableManagedSpawnReplayDecision::Reserved,
22806        ));
22807        {
22808            let _transaction = shared
22809                .state_transaction
22810                .lock()
22811                .unwrap_or_else(|poisoned| poisoned.into_inner());
22812            shared
22813                .link_durable_managed_spawn_v2_lease_locked(
22814                    &request.spawn_spec.idempotency_key,
22815                    &lease.lease_id,
22816                )
22817                .unwrap();
22818            shared.persist_state_locked().unwrap();
22819        }
22820        drop(shared);
22821
22822        let mut loaded = session_registry::load(
22823            Some(&state_path),
22824            &NodeId::new("node-terminal-test").unwrap(),
22825        )
22826        .unwrap();
22827        let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22828        *reopened
22829            .managed_worktrees
22830            .lock()
22831            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22832            ManagedWorktreeRegistry::from_records(
22833                std::mem::take(&mut loaded.managed_worktrees),
22834                std::mem::take(&mut loaded.managed_worktree_tombstones),
22835            )
22836            .unwrap();
22837        *reopened
22838            .managed_spawn_replays
22839            .lock()
22840            .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22841            .managed_spawn_replays
22842            .into_iter()
22843            .map(|record| (record.idempotency_key.clone(), record))
22844            .collect();
22845        let lease_count = reopened
22846            .managed_worktrees
22847            .lock()
22848            .unwrap_or_else(|poisoned| poisoned.into_inner())
22849            .records()
22850            .len();
22851
22852        let failure = reopened
22853            .spawn_managed_worktree_v2(request)
22854            .await
22855            .unwrap_err();
22856
22857        assert_eq!(
22858            failure.code,
22859            NodeFailureCode::ManagedWorktreeRecoveryRequired,
22860        );
22861        assert_eq!(
22862            reopened
22863                .managed_worktrees
22864                .lock()
22865                .unwrap_or_else(|poisoned| poisoned.into_inner())
22866                .records()
22867                .len(),
22868            lease_count,
22869        );
22870        std::fs::remove_dir_all(root).unwrap();
22871    }
22872
22873    #[tokio::test]
22874    async fn managed_spawn_v2_replays_exact_receipt_after_durable_reopen_and_conflicts() {
22875        let root = temporary_workspace_root("managed-v2-reopen");
22876        let allocation = root.join("allocation");
22877        std::fs::create_dir_all(&allocation).unwrap();
22878        let state_path = root.join("node-state.json");
22879        let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
22880        let lease = install_managed_lease(&shared);
22881        let request = managed_v2_request("managed-v2-reopen");
22882        let session = SessionAddress {
22883            workspace_id: lease.workspace_id.clone(),
22884            session: SessionKey {
22885                instance_id: AgentInstanceId(91),
22886                generation: SessionGeneration(1),
22887            },
22888        };
22889        let snapshot = {
22890            let mut registry = shared
22891                .managed_worktrees
22892                .lock()
22893                .unwrap_or_else(|poisoned| poisoned.into_inner());
22894            let current = registry.get_mut(&lease.lease_id).unwrap();
22895            current.state = ManagedWorktreeLeaseState::InUse;
22896            current.session_holders.push(ManagedWorktreeSessionHolder {
22897                incarnation_id: shared.incarnation_id,
22898                instance_id: session.session.instance_id,
22899                generation: session.session.generation,
22900            });
22901            current.snapshot()
22902        };
22903        let resolved = shared.resolve_spawn_spec(&request.spawn_spec).unwrap();
22904        let receipt = managed_spawn_receipt(
22905            &resolved,
22906            shared.incarnation_id,
22907            session,
22908            snapshot,
22909            None,
22910            None,
22911            None,
22912        );
22913        assert!(matches!(
22914            shared
22915                .acquire_durable_managed_spawn_v2(
22916                    &request,
22917                    managed_spawn_request_digest_v2(&request).unwrap(),
22918                )
22919                .unwrap(),
22920            DurableManagedSpawnReplayDecision::Reserved,
22921        ));
22922        {
22923            let _transaction = shared
22924                .state_transaction
22925                .lock()
22926                .unwrap_or_else(|poisoned| poisoned.into_inner());
22927            shared
22928                .link_durable_managed_spawn_v2_lease_locked(
22929                    &request.spawn_spec.idempotency_key,
22930                    &lease.lease_id,
22931                )
22932                .unwrap();
22933            shared.persist_state_locked().unwrap();
22934        }
22935        shared
22936            .commit_durable_managed_spawn_v2(&request, receipt.clone())
22937            .unwrap();
22938        let legacy_same_key = ManagedWorktreeSpawnRequest {
22939            spawn_spec: request.spawn_spec.clone(),
22940            worktree_profile_id: request.worktree_profile_id.clone(),
22941        };
22942        shared.remember_managed_spawn_attempt(
22943            Some(&request.spawn_spec.idempotency_key),
22944            legacy_same_key.clone(),
22945            Ok(receipt.clone()),
22946            Instant::now(),
22947        );
22948        assert!(shared
22949            .replay_managed_spawn(&legacy_same_key, Instant::now())
22950            .unwrap()
22951            .is_none());
22952        drop(shared);
22953
22954        let mut loaded = session_registry::load(
22955            Some(&state_path),
22956            &NodeId::new("node-terminal-test").unwrap(),
22957        )
22958        .unwrap();
22959        assert_eq!(loaded.managed_spawn_replays.len(), 1);
22960        let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22961        *reopened
22962            .managed_worktrees
22963            .lock()
22964            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22965            ManagedWorktreeRegistry::from_records(
22966                std::mem::take(&mut loaded.managed_worktrees),
22967                std::mem::take(&mut loaded.managed_worktree_tombstones),
22968            )
22969            .unwrap();
22970        *reopened
22971            .managed_spawn_replays
22972            .lock()
22973            .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22974            .managed_spawn_replays
22975            .into_iter()
22976            .map(|record| (record.idempotency_key.clone(), record))
22977            .collect();
22978        let lease_count_before = reopened
22979            .managed_worktrees
22980            .lock()
22981            .unwrap_or_else(|poisoned| poisoned.into_inner())
22982            .records()
22983            .len();
22984
22985        assert_eq!(
22986            reopened
22987                .spawn_managed_worktree_v2(request.clone())
22988                .await
22989                .unwrap(),
22990            receipt,
22991        );
22992        assert_eq!(
22993            reopened
22994                .managed_worktrees
22995                .lock()
22996                .unwrap_or_else(|poisoned| poisoned.into_inner())
22997                .records()
22998                .len(),
22999            lease_count_before,
23000        );
23001        let mut changed = request;
23002        changed.spawn_spec.deadline_ms = crate::protocol::SpawnDeadlineMs::new(29_000).unwrap();
23003        let failure = reopened
23004            .spawn_managed_worktree_v2(changed)
23005            .await
23006            .unwrap_err();
23007        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
23008        assert_eq!(
23009            reopened
23010                .managed_worktrees
23011                .lock()
23012                .unwrap_or_else(|poisoned| poisoned.into_inner())
23013                .records()
23014                .len(),
23015            lease_count_before,
23016        );
23017        let mut new_key = managed_v2_request("managed-v2-new-after-profile-change");
23018        new_key.expected_profile_revision = WorktreeProfileRevision::new("v1").unwrap();
23019        let failure = reopened
23020            .spawn_managed_worktree_v2(new_key)
23021            .await
23022            .unwrap_err();
23023        assert_eq!(
23024            failure.code,
23025            NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
23026        );
23027        assert_eq!(
23028            reopened
23029                .managed_worktrees
23030                .lock()
23031                .unwrap_or_else(|poisoned| poisoned.into_inner())
23032                .records()
23033                .len(),
23034            lease_count_before,
23035        );
23036        assert_eq!(
23037            reopened
23038                .managed_spawn_replays
23039                .lock()
23040                .unwrap_or_else(|poisoned| poisoned.into_inner())
23041                .len(),
23042            1,
23043        );
23044        std::fs::remove_dir_all(root).unwrap();
23045    }
23046
23047    #[test]
23048    fn legacy_managed_spawn_idempotency_remains_transient_and_exact() {
23049        let shared = terminal_test_shared();
23050        let request = ManagedWorktreeSpawnRequest {
23051            spawn_spec: spawn_spec_fixture(),
23052            worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
23053        };
23054        let expected = failure(NodeFailureCode::BackendBusy, "legacy fixture failure");
23055        shared.remember_managed_spawn(
23056            request.clone(),
23057            Err(expected.clone()),
23058            Instant::now(),
23059        );
23060
23061        assert_eq!(
23062            shared
23063                .replay_managed_spawn(&request, Instant::now())
23064                .unwrap()
23065                .unwrap(),
23066            Err(expected),
23067        );
23068        assert!(shared
23069            .managed_spawn_replays
23070            .lock()
23071            .unwrap_or_else(|poisoned| poisoned.into_inner())
23072            .is_empty());
23073    }
23074
23075    #[tokio::test]
23076    async fn ordinary_spawn_and_spawn_spec_cannot_bypass_managed_lease_holder_fence() {
23077        let shared = terminal_test_shared();
23078        let lease = install_managed_lease(&shared);
23079        let raw = shared.spawn_session(
23080            lease.workspace_id.clone(),
23081            agent("claude"),
23082            SessionMode::Pty,
23083            gate4agent_types::TerminalSize { rows: 24, columns: 80 },
23084            None,
23085        ).await.unwrap_err();
23086        assert_eq!(raw.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
23087        let mut spec = spawn_spec_fixture();
23088        spec.target.workspace_id = lease.workspace_id.clone();
23089        let specified = shared.spawn_from_spec(spec).await.unwrap_err();
23090        assert_eq!(specified.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
23091        let mut selected = spawn_spec_fixture();
23092        selected.idempotency_key = SpawnIdempotencyKey::new("managed-selected-bypass").unwrap();
23093        selected.target.worktree_id = Some(lease.workspace_id);
23094        let selected = shared.spawn_from_spec(selected).await.unwrap_err();
23095        assert_eq!(selected.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
23096        assert!(shared.handle.snapshot().sessions.is_empty());
23097    }
23098
23099    #[test]
23100    fn legacy_projection_omits_managed_snapshot_events_and_path_bearing_diagnostics() {
23101        let shared = terminal_test_shared();
23102        let lease = install_managed_lease(&shared);
23103        let snapshot = snapshot_for_wire(
23104            &shared, true, true, true, false, true, true, false, false, true,
23105        );
23106        assert!(snapshot.managed_worktrees.is_empty());
23107        assert!(snapshot.workspaces.iter().all(|workspace| {
23108            workspace.managed_worktree_profiles.is_none()
23109        }));
23110        let mut reply = ResponseEnvelope {
23111            request_id: 1,
23112            result: Ok(NodeResponse::Resync {
23113                event_sequence: 1,
23114                oldest_available_sequence: 1,
23115                snapshot: shared.snapshot(),
23116                events: vec![NodeEventEnvelope {
23117                    sequence: 1,
23118                    event: NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() },
23119                }],
23120            }),
23121        };
23122        project_response_without_managed_worktrees(&mut reply);
23123        let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
23124            panic!("projection changed the response kind");
23125        };
23126        assert!(snapshot.managed_worktrees.is_empty());
23127        assert!(events.is_empty());
23128        let mut git = git_snapshot_for_parser();
23129        git.branch = Some(lease.branch.clone());
23130        git.managed_worktree = Some(crate::protocol::ManagedWorktreeGitScope {
23131            lease_id: lease.lease_id.clone(),
23132            source_workspace_id: lease.source_workspace_id.clone(),
23133            branch: lease.branch.clone(),
23134            base_commit: GitObjectId::new(lease.base_commit.clone()).unwrap(),
23135            active_session_count: 1,
23136            managed_record_count: 0,
23137        });
23138        let mut inspection_reply = ResponseEnvelope {
23139            request_id: 2,
23140            result: Ok(NodeResponse::WorkspaceInspected {
23141                inspection: WorkspaceInspection {
23142                    workspace_id: lease.workspace_id.clone(),
23143                    entries: Vec::new(),
23144                    tree_truncated: false,
23145                    git,
23146                    truncation: None,
23147                },
23148            }),
23149        };
23150        project_response_without_managed_worktrees(&mut inspection_reply);
23151        let Ok(NodeResponse::WorkspaceInspected { inspection }) = inspection_reply.result else {
23152            panic!("projection changed the inspection response kind");
23153        };
23154        assert!(inspection.git.managed_worktree.is_none());
23155        let failure = managed_git_worktree_failure(GitWorktreeError {
23156            kind: GitWorktreeErrorKind::Failed,
23157            message: r"git failed at C:\private\managed-target".to_owned(),
23158        });
23159        assert!(!failure.message.contains("private"));
23160        assert!(!failure.message.contains("managed-target"));
23161    }
23162
23163    #[tokio::test]
23164    async fn restart_reconciliation_retains_exact_owned_worktree_and_explicit_cleanup_removes_it() {
23165        let root = temporary_workspace_root("managed-restart");
23166        let source = root.join("source");
23167        let allocation = root.join("allocation");
23168        std::fs::create_dir_all(&source).unwrap();
23169        std::fs::create_dir_all(&allocation).unwrap();
23170        run_test_git(&source, &["init"]);
23171        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
23172        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
23173        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
23174        run_test_git(&source, &["add", "seed.txt"]);
23175        run_test_git(&source, &["commit", "-m", "seed"]);
23176        let profile = ManagedWorktreeProfile::new(
23177            WorktreeProfileId::new("default").unwrap(),
23178            crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
23179            &allocation,
23180            "gate4agent",
23181            "HEAD",
23182            ManagedWorktreeRetention::Retain,
23183        ).unwrap();
23184        let workspace = WorkspaceConfig::new(
23185            WorkspaceId::new("primary").unwrap(),
23186            &source,
23187        ).unwrap()
23188            .with_worktree_service_mode(WorktreeServiceMode::Managed)
23189            .with_managed_worktree_profile(profile.clone()).unwrap();
23190        let target = PathBuf::from(profile.allocation_root()).join("mw-restart");
23191        let source_text = workspace.canonical_root().to_owned();
23192        let target_text = target.to_string_lossy().into_owned();
23193        let created = create_git_worktree(
23194            &source_text,
23195            &target_text,
23196            "gate4agent/mw-restart",
23197            Some("HEAD"),
23198        ).await.unwrap();
23199        let lease = ManagedWorktreeLeaseRecord {
23200            lease_id: ManagedWorktreeLeaseId::new("mw-restart").unwrap(),
23201            source_workspace_id: WorkspaceId::new("primary").unwrap(),
23202            workspace_id: WorkspaceId::new("managed-restart").unwrap(),
23203            profile_id: profile.profile_id().clone(),
23204            profile_revision: profile.revision().clone(),
23205            target_root: created.path.clone(),
23206            branch: "gate4agent/mw-restart".to_owned(),
23207            base_commit: created.head.clone(),
23208            expected_head: None,
23209            retention: ManagedWorktreeRetention::Retain,
23210            state: ManagedWorktreeLeaseState::Allocating,
23211            session_holders: Vec::new(),
23212            record_holders: Vec::new(),
23213            cleanup_failure: None,
23214            created_at_unix_ms: 1,
23215            updated_at_unix_ms: 1,
23216        };
23217        let catalog = active_registry().unwrap();
23218        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23219        let shared = NodeShared::new_with_incarnation(
23220            handle,
23221            "fixture-token".to_owned(),
23222            NodeId::new("node-managed-restart").unwrap(),
23223            NodeIncarnationId::from_bytes([7; crate::protocol::NODE_INCARNATION_ID_BYTES]),
23224            vec![workspace],
23225            vec![agent("claude")],
23226            ProviderRuntimeStatuses::default(),
23227            None,
23228            Vec::new(),
23229            Vec::new(),
23230            SpawnProfileRegistry::default(),
23231            None,
23232            None,
23233            None,
23234            None,
23235            Vec::new(),
23236            vec![lease.clone()],
23237            Vec::new(),
23238            Vec::new(),
23239            None,
23240            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
23241        );
23242        shared.reconcile_managed_worktrees().await;
23243        let retained = shared.managed_worktrees.lock()
23244            .unwrap_or_else(|poisoned| poisoned.into_inner())
23245            .get(&lease.lease_id).unwrap().clone();
23246        assert_eq!(retained.state, ManagedWorktreeLeaseState::Retained);
23247        assert!(shared.workspaces.read()
23248            .unwrap_or_else(|poisoned| poisoned.into_inner())
23249            .contains_key(&lease.workspace_id));
23250        let removed = shared.cleanup_managed_worktree(&lease.lease_id, true).await.unwrap();
23251        assert_eq!(removed.state, ManagedWorktreeLeaseState::Removed);
23252        assert!(!Path::new(&created.path).exists());
23253        std::fs::remove_dir_all(root).unwrap();
23254    }
23255
23256    fn run_test_git(root: &Path, arguments: &[&str]) {
23257        let output = std::process::Command::new("git")
23258            .arg("-C").arg(root).args(arguments).output().unwrap();
23259        assert!(
23260            output.status.success(),
23261            "git {:?} failed: {}",
23262            arguments,
23263            String::from_utf8_lossy(&output.stderr),
23264        );
23265    }
23266
23267    #[tokio::test]
23268    async fn spawn_spec_validation_fails_before_session_mutation() {
23269        let shared = terminal_test_shared();
23270        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23271            capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
23272        }));
23273        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23274            capability.as_str() == NODE_SPAWN_PROFILE_REVISION_CAPABILITY
23275        }));
23276        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23277            capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
23278        }));
23279
23280        let mut target_mismatch = spawn_spec_fixture();
23281        target_mismatch.target.node_id = NodeId::new("other-node").unwrap();
23282        let failure = shared.spawn_from_spec(target_mismatch).await.unwrap_err();
23283        assert_eq!(failure.code, NodeFailureCode::SpawnTargetMismatch);
23284
23285        let mut unknown_profile = spawn_spec_fixture();
23286        unknown_profile.idempotency_key = SpawnIdempotencyKey::new("unknown-profile").unwrap();
23287        unknown_profile.profile_id = crate::protocol::SpawnProfileId::new("missing").unwrap();
23288        let failure = shared.spawn_from_spec(unknown_profile).await.unwrap_err();
23289        assert_eq!(failure.code, NodeFailureCode::UnknownSpawnProfile);
23290
23291        let mut revision_mismatch = spawn_spec_fixture();
23292        revision_mismatch.idempotency_key =
23293            SpawnIdempotencyKey::new("profile-revision-mismatch").unwrap();
23294        revision_mismatch.expected_profile_revision =
23295            crate::protocol::SpawnProfileRevision::new("test-r2").unwrap();
23296        let failure = shared.spawn_from_spec(revision_mismatch).await.unwrap_err();
23297        assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
23298
23299        let mut unsupported_materializer = spawn_spec_fixture();
23300        unsupported_materializer.idempotency_key =
23301            SpawnIdempotencyKey::new("unsupported-materializer").unwrap();
23302        unsupported_materializer.overrides.bundle_id = crate::protocol::SpawnOverride::Set {
23303            value: crate::protocol::SpawnBundleId::new("bundle-v1").unwrap(),
23304        };
23305        let failure = shared
23306            .spawn_from_spec(unsupported_materializer)
23307            .await
23308            .unwrap_err();
23309        assert_eq!(failure.code, NodeFailureCode::UnknownBundle);
23310
23311        let failure = shared
23312            .spawn_session_with_deadline(
23313                WorkspaceId::new("primary").unwrap(),
23314                agent("claude"),
23315                SessionMode::Pty,
23316                gate4agent_types::TerminalSize {
23317                    rows: 24,
23318                    columns: 80,
23319                },
23320                None,
23321                None,
23322                None,
23323                None,
23324                None,
23325                None,
23326                SpawnRecordPolicy::ProviderIdentityOnly,
23327                Some(Instant::now()),
23328                &[],
23329                None,
23330                ApprovalLevel::default(),
23331                None,
23332                None,
23333            )
23334            .await
23335            .unwrap_err();
23336        assert_eq!(failure.code, NodeFailureCode::SpawnDeadlineExceeded);
23337        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
23338        assert!(shared.handle.snapshot().sessions.is_empty());
23339    }
23340
23341    #[test]
23342    fn worktree_selection_requires_its_separate_negotiated_capability() {
23343        let mut spec = spawn_spec_fixture();
23344        spec.target.worktree_id = Some(WorkspaceId::new("selected-worktree").unwrap());
23345        let request = NodeRequest::SpawnSpec { spec };
23346        let spawn_spec_capability =
23347            CapabilityId::new(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY).unwrap();
23348        assert!(request_uses_unnegotiated_capability(
23349            &request,
23350            &[spawn_spec_capability.clone()],
23351        ));
23352        let worktree_capability =
23353            CapabilityId::new(NODE_WORKTREE_SELECTION_CAPABILITY).unwrap();
23354        assert!(request_uses_unnegotiated_capability(
23355            &request,
23356            &[spawn_spec_capability.clone(), worktree_capability.clone()],
23357        ));
23358        let profile_revision_capability =
23359            CapabilityId::new(NODE_SPAWN_PROFILE_REVISION_CAPABILITY).unwrap();
23360        assert!(request_uses_unnegotiated_capability(
23361            &request,
23362            &[
23363                spawn_spec_capability.clone(),
23364                worktree_capability.clone(),
23365                profile_revision_capability.clone(),
23366            ],
23367        ));
23368        // Default `SpawnOverrides` leaves `bundle_id`/`context_id` at
23369        // `Inherit`, not `Clear` — the profile's own defaults might still
23370        // materialize a bundle or a context pack, so both capabilities stay
23371        // conservatively required until negotiated (see
23372        // `requires_session_bundle_materialization_capability` /
23373        // `requires_history_context_pack_capability`).
23374        let bundle_materialization_capability =
23375            CapabilityId::new(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY).unwrap();
23376        assert!(request_uses_unnegotiated_capability(
23377            &request,
23378            &[
23379                spawn_spec_capability.clone(),
23380                worktree_capability.clone(),
23381                profile_revision_capability.clone(),
23382                bundle_materialization_capability.clone(),
23383            ],
23384        ));
23385        let history_context_pack_capability =
23386            CapabilityId::new(NODE_HISTORY_CONTEXT_PACK_CAPABILITY).unwrap();
23387        assert!(!request_uses_unnegotiated_capability(
23388            &request,
23389            &[
23390                spawn_spec_capability,
23391                worktree_capability,
23392                profile_revision_capability,
23393                bundle_materialization_capability,
23394                history_context_pack_capability,
23395            ],
23396        ));
23397    }
23398
23399    #[tokio::test]
23400    async fn spawn_spec_idempotency_replays_receipt_and_conflicts_before_session_mutation() {
23401        let mut shared = terminal_test_shared();
23402        let spec = spawn_spec_fixture();
23403        let receipt = shared
23404            .resolve_spawn_spec(&spec)
23405            .unwrap()
23406            .receipt(shared.incarnation_id, terminal_address(1));
23407        shared.remember_spawn_spec(spec.clone(), Ok(receipt.clone()), Instant::now());
23408
23409        let mut revised = shared.spawn_profiles.get(&spec.profile_id).unwrap().clone();
23410        revised.revision = crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
23411        shared.spawn_profiles = SpawnProfileRegistry::new([revised]).unwrap();
23412
23413        assert_eq!(shared.spawn_from_spec(spec.clone()).await.unwrap(), receipt);
23414
23415        let mut revision_conflicting = spec.clone();
23416        revision_conflicting.expected_profile_revision =
23417            crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
23418        let failure = shared.spawn_from_spec(revision_conflicting).await.unwrap_err();
23419        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
23420
23421        let mut stale_cache_miss = spec.clone();
23422        stale_cache_miss.idempotency_key =
23423            SpawnIdempotencyKey::new("stale-profile-cache-miss").unwrap();
23424        let failure = shared.spawn_from_spec(stale_cache_miss).await.unwrap_err();
23425        assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
23426
23427        let mut conflicting = spec;
23428        conflicting.deadline_ms = crate::protocol::SpawnDeadlineMs::new(31_000).unwrap();
23429        let failure = shared.spawn_from_spec(conflicting).await.unwrap_err();
23430        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
23431        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
23432        assert!(shared.handle.snapshot().sessions.is_empty());
23433    }
23434
23435    #[tokio::test]
23436    async fn off_worktree_service_rejects_selection_and_mutation_before_git() {
23437        let shared = terminal_test_shared_with_mode(WorktreeServiceMode::Off);
23438        let mut spec = spawn_spec_fixture();
23439        spec.target.worktree_id = Some(WorkspaceId::new("unregistered-worktree").unwrap());
23440        let failure = shared.spawn_from_spec(spec).await.unwrap_err();
23441        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
23442
23443        let failure = shared
23444            .create_worktree(
23445                WorkspaceId::new("primary").unwrap(),
23446                WorkspaceId::new("new-worktree").unwrap(),
23447                "not-an-absolute-path".to_owned(),
23448                "invalid branch".to_owned(),
23449                None,
23450            )
23451            .await
23452            .unwrap_err();
23453        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
23454
23455        let failure = shared
23456            .remove_worktree(
23457                WorkspaceId::new("primary").unwrap(),
23458                "not-an-absolute-path".to_owned(),
23459            )
23460            .await
23461            .unwrap_err();
23462        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
23463        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
23464        assert!(shared.handle.snapshot().sessions.is_empty());
23465    }
23466
23467    #[tokio::test]
23468    async fn manual_worktree_registration_failure_removes_the_created_worktree() {
23469        let root = temporary_workspace_root("manual-worktree-compensation");
23470        let source = root.join("source");
23471        let target = root.join("target");
23472        let invalid_state_parent = root.join("state-parent-is-a-file");
23473        let invalid_state_path = invalid_state_parent.join("state.json");
23474        std::fs::create_dir_all(&source).unwrap();
23475        std::fs::write(&invalid_state_parent, "not a directory").unwrap();
23476        run_test_git(&source, &["init"]);
23477        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
23478        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
23479        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
23480        run_test_git(&source, &["add", "seed.txt"]);
23481        run_test_git(&source, &["commit", "-m", "seed"]);
23482
23483        let catalog = active_registry().unwrap();
23484        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23485        let workspace = WorkspaceConfig::new(
23486            WorkspaceId::new("primary").unwrap(),
23487            &source,
23488        ).unwrap();
23489        let mut shared = NodeShared::new(
23490            handle,
23491            "fixture-token".to_owned(),
23492            NodeId::new("node-worktree-compensation").unwrap(),
23493            vec![workspace],
23494            vec![agent("claude")],
23495        );
23496        shared.state_path = Some(invalid_state_path);
23497
23498        let error = shared.create_worktree(
23499            WorkspaceId::new("primary").unwrap(),
23500            WorkspaceId::new("compensated").unwrap(),
23501            target.to_string_lossy().into_owned(),
23502            "gate4agent/compensated".to_owned(),
23503            Some("HEAD".to_owned()),
23504        ).await.unwrap_err();
23505
23506        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
23507        assert!(!target.exists());
23508        assert!(list_git_worktrees(source.to_str().unwrap()).await.unwrap()
23509            .iter()
23510            .all(|worktree| !worktree_paths_equal(&worktree.path, &target.to_string_lossy())));
23511        std::fs::remove_dir_all(root).unwrap();
23512    }
23513
23514    #[test]
23515    fn selected_worktree_requires_authoritative_non_main_non_bare_entry() {
23516        fn worktree(path: &str, is_main: bool, is_bare: bool) -> NativeGitWorktreeSnapshot {
23517            NativeGitWorktreeSnapshot {
23518                path: path.to_owned(),
23519                head: "0123456789abcdef".to_owned(),
23520                branch: Some("refs/heads/test".to_owned()),
23521                is_bare,
23522                is_main,
23523                locked: false,
23524                lock_reason: None,
23525                prunable: false,
23526                prunable_reason: None,
23527                workspace_id: None,
23528            }
23529        }
23530
23531        let source = r"C:\repo";
23532        let selected = r"C:\repo-worktree";
23533        let listed = vec![
23534            worktree(source, true, false),
23535            worktree(selected, false, false),
23536        ];
23537        assert!(validate_selected_worktree(source, selected, &listed).is_ok());
23538
23539        let main_failure = validate_selected_worktree(source, source, &listed).unwrap_err();
23540        assert_eq!(main_failure.code, NodeFailureCode::WorktreeProtected);
23541
23542        let bare = vec![
23543            worktree(source, true, false),
23544            worktree(selected, false, true),
23545        ];
23546        let bare_failure = validate_selected_worktree(source, selected, &bare).unwrap_err();
23547        assert_eq!(bare_failure.code, NodeFailureCode::WorktreeProtected);
23548
23549        let foreign_failure =
23550            validate_selected_worktree(source, r"C:\foreign", &listed).unwrap_err();
23551        assert_eq!(foreign_failure.code, NodeFailureCode::WorktreeProtected);
23552    }
23553
23554    fn terminal_frame(sequence: u64) -> TerminalFrame {
23555        TerminalFrame {
23556            sequence,
23557            size: gate4agent_types::TerminalSize {
23558                rows: 24,
23559                columns: 80,
23560            },
23561            cursor_row: 2,
23562            cursor_column: 3,
23563            contents: format!("frame-{sequence}"),
23564            formatted: vec![1, 2, 3],
23565            scrollback_formatted: Vec::new(),
23566            alternate_screen: false,
23567            mouse_protocol_enabled: false,
23568            mouse_protocol_encoding: Default::default(),
23569            produced_at_unix_ms: 0,
23570            screen_state: gate4agent_types::PtyScreenState::default(),
23571            bracketed_paste: None,
23572        }
23573    }
23574
23575    #[test]
23576    fn terminal_frame_watermark_coalesces_and_resets_for_exact_session_address() {
23577        let shared = terminal_test_shared();
23578        let mut receiver = shared.terminal_event_tx.subscribe();
23579        let original = terminal_address(1);
23580
23581        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
23582        let first = receiver.try_recv().unwrap();
23583        assert_eq!(first.len(), 1);
23584        assert!(matches!(
23585            &first[0].event,
23586            NodeEvent::TerminalFrame { address, frame }
23587                if address == &original && frame.sequence == 7
23588        ));
23589
23590        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
23591        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(6))]);
23592        assert!(matches!(
23593            receiver.try_recv(),
23594            Err(broadcast::error::TryRecvError::Empty)
23595        ));
23596
23597        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(8))]);
23598        let advanced = receiver.try_recv().unwrap();
23599        assert_eq!(advanced[0].sequence, 2);
23600
23601        let resumed = terminal_address(2);
23602        shared.publish_terminal_frame_candidates(vec![(resumed.clone(), terminal_frame(1))]);
23603        let reset = receiver.try_recv().unwrap();
23604        assert_eq!(reset[0].sequence, 3);
23605        assert!(matches!(
23606            &reset[0].event,
23607            NodeEvent::TerminalFrame { address, frame }
23608                if address == &resumed && frame.sequence == 1
23609        ));
23610        shared.clear_terminal_frame_watermark(&resumed);
23611        assert!(!shared
23612            .terminal_frame_watermarks
23613            .lock()
23614            .unwrap_or_else(|poisoned| poisoned.into_inner())
23615            .contains_key(&resumed.session.instance_id));
23616    }
23617
23618    #[test]
23619    fn terminal_frame_batches_replace_without_entering_node_event_history() {
23620        let shared = terminal_test_shared();
23621        let mut receiver = shared.terminal_event_tx.subscribe();
23622        let address = terminal_address(1);
23623        let count = NODE_EVENT_HISTORY_MAX as u64 + 17;
23624
23625        for sequence in 1..=count {
23626            shared.publish_terminal_frame_candidates(vec![(
23627                address.clone(),
23628                terminal_frame(sequence),
23629            )]);
23630        }
23631
23632        let history = shared
23633            .history
23634            .lock()
23635            .unwrap_or_else(|poisoned| poisoned.into_inner());
23636        assert!(history.events.is_empty());
23637        assert_eq!(history.last_sequence, count);
23638        assert_eq!(history.replay_floor_sequence, 1);
23639        drop(history);
23640        let NodeResponse::Resync {
23641            event_sequence,
23642            oldest_available_sequence,
23643            events,
23644            ..
23645        } = shared.resync(0) else {
23646            unreachable!("resync helper returned another response")
23647        };
23648        assert_eq!(event_sequence, count);
23649        assert_eq!(oldest_available_sequence, 1);
23650        assert!(events.is_empty());
23651        assert!(matches!(
23652            resync_required_event(&shared).event,
23653            NodeEvent::ResyncRequired {
23654                oldest_available_sequence: 1,
23655            }
23656        ));
23657        assert!(matches!(
23658            receiver.try_recv(),
23659            Err(broadcast::error::TryRecvError::Lagged(skipped)) if skipped == count - 1
23660        ));
23661        let latest = receiver.try_recv().unwrap();
23662        assert_eq!(latest.len(), 1);
23663        assert_eq!(latest[0].sequence, count);
23664    }
23665
23666    #[test]
23667    fn real_durable_eviction_advances_authoritative_replay_floor() {
23668        let shared = terminal_test_shared();
23669        for _ in 0..=NODE_EVENT_HISTORY_MAX {
23670            shared.publish(NodeEvent::ControllerChanged { controller: None });
23671        }
23672
23673        let NodeResponse::Resync {
23674            event_sequence,
23675            oldest_available_sequence,
23676            events,
23677            ..
23678        } = shared.resync(0) else {
23679            unreachable!("resync helper returned another response")
23680        };
23681        assert_eq!(event_sequence, NODE_EVENT_HISTORY_MAX as u64 + 1);
23682        assert_eq!(oldest_available_sequence, 2);
23683        assert_eq!(events.first().map(|event| event.sequence), Some(2));
23684        assert_eq!(events.len(), NODE_EVENT_HISTORY_MAX);
23685        assert!(matches!(
23686            resync_required_event(&shared).event,
23687            NodeEvent::ResyncRequired {
23688                oldest_available_sequence: 2,
23689            }
23690        ));
23691    }
23692
23693    #[test]
23694    fn legacy_terminal_frame_connection_has_no_live_subscription() {
23695        let shared = terminal_test_shared();
23696        let without_capability = terminal_event_subscription(&shared, false);
23697        let with_capability = terminal_event_subscription(&shared, true);
23698        assert!(without_capability.is_none());
23699        assert!(with_capability.is_some());
23700        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23701            capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
23702        }));
23703    }
23704
23705    #[test]
23706    fn continuous_terminal_output_leaves_request_service_after_bounded_event_burst() {
23707        let mut pending = Vec::new();
23708        let mut event_sequence = 0_u64;
23709        for frame_sequence in 1..=4 {
23710            for instance in 1..=(NODE_CONNECTION_EVENT_BURST_MAX as u64 * 2) {
23711                event_sequence += 1;
23712                let mut address = terminal_address(1);
23713                address.session.instance_id = AgentInstanceId(instance);
23714                queue_connection_event(
23715                    &mut pending,
23716                    NodeEventEnvelope {
23717                        sequence: event_sequence,
23718                        event: NodeEvent::TerminalFrame {
23719                            address,
23720                            frame: terminal_frame(frame_sequence),
23721                        },
23722                    },
23723                    0,
23724                );
23725            }
23726        }
23727        assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX * 2);
23728        pending.sort_unstable_by_key(|event| event.sequence);
23729
23730        let mut uninterrupted = pending.clone();
23731        let first_burst = connection_event_burst_len(uninterrupted.len());
23732        uninterrupted.drain(..first_burst);
23733        let second_burst = connection_event_burst_len(uninterrupted.len());
23734        uninterrupted.drain(..second_burst);
23735        assert!(uninterrupted.is_empty());
23736
23737        let (frame_tx, mut frame_rx) =
23738            mpsc::channel::<Result<ClientFrame, FrameError>>(1);
23739        frame_tx
23740            .try_send(Ok(ClientFrame::Request(RequestEnvelope {
23741                request_id: 77,
23742                request: NodeRequest::Snapshot,
23743            })))
23744            .unwrap();
23745        let burst_len = connection_event_burst_len(pending.len());
23746        assert_eq!(burst_len, NODE_CONNECTION_EVENT_BURST_MAX);
23747        pending.drain(..burst_len);
23748        assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX);
23749        assert!(matches!(
23750            frame_rx.try_recv(),
23751            Ok(Ok(ClientFrame::Request(RequestEnvelope {
23752                request_id: 77,
23753                request: NodeRequest::Snapshot,
23754            })))
23755        ));
23756    }
23757
23758    #[test]
23759    fn terminal_lag_watermark_discards_retained_and_replayed_batches() {
23760        let mut pending = Vec::new();
23761        for sequence in [7_u64, 8] {
23762            let mut address = terminal_address(1);
23763            address.session.instance_id = AgentInstanceId(sequence);
23764            queue_connection_event(
23765                &mut pending,
23766                NodeEventEnvelope {
23767                    sequence,
23768                    event: NodeEvent::TerminalFrame {
23769                        address,
23770                        frame: terminal_frame(sequence),
23771                    },
23772                },
23773                0,
23774            );
23775        }
23776        let mut discard_through = 0;
23777        apply_connection_resync_watermark(&mut pending, &mut discard_through, 8);
23778        assert!(pending.is_empty());
23779
23780        let replayed = NodeEventEnvelope {
23781            sequence: 8,
23782            event: NodeEvent::TerminalFrame {
23783                address: terminal_address(1),
23784                frame: terminal_frame(8),
23785            },
23786        };
23787        let advanced = NodeEventEnvelope {
23788            sequence: 9,
23789            event: NodeEvent::TerminalFrame {
23790                address: terminal_address(2),
23791                frame: terminal_frame(1),
23792            },
23793        };
23794        queue_connection_event_batch(
23795            &mut pending,
23796            &[replayed, advanced.clone()],
23797            discard_through,
23798        );
23799        assert_eq!(pending, vec![advanced]);
23800    }
23801
23802    fn record(provider: &str, record_id: &str) -> ManagedSessionRecord {
23803        ManagedSessionRecord {
23804            record_id: SessionRecordId::new(record_id).unwrap(),
23805            display_name: format!("{provider} record"),
23806            provider: agent(provider),
23807            mode: SessionMode::Pty,
23808            state: ManagedSessionState::Unavailable,
23809            workspace_id: WorkspaceId::new("primary").unwrap(),
23810            canonical_root: opaque_windows_path(
23811                std::env::current_dir().unwrap().to_string_lossy().into_owned(),
23812            ),
23813            provider_session: None,
23814            active_session: None,
23815            environment_profile: None,
23816            bundle: None,
23817            context_id: None,
23818            context: None,
23819            exported_context: None,
23820            task_binding: None,
23821            created_at_unix_ms: 1,
23822            updated_at_unix_ms: 1,
23823            last_error: None,
23824        }
23825    }
23826
23827    fn retention_test_record(
23828        workspace_id: &str,
23829        record_id: &str,
23830        state: ManagedSessionState,
23831        updated_at_unix_ms: u64,
23832    ) -> ManagedSessionRecord {
23833        ManagedSessionRecord {
23834            record_id: SessionRecordId::new(record_id).unwrap(),
23835            display_name: format!("{record_id} display"),
23836            provider: agent("claude"),
23837            mode: SessionMode::Pty,
23838            state,
23839            workspace_id: WorkspaceId::new(workspace_id).unwrap(),
23840            canonical_root: opaque_windows_path(
23841                std::env::current_dir().unwrap().to_string_lossy().into_owned(),
23842            ),
23843            provider_session: None,
23844            active_session: None,
23845            environment_profile: None,
23846            bundle: None,
23847            context_id: None,
23848            context: None,
23849            exported_context: None,
23850            task_binding: None,
23851            created_at_unix_ms: updated_at_unix_ms,
23852            updated_at_unix_ms,
23853            last_error: None,
23854        }
23855    }
23856
23857    /// Two workspaces of three `Unavailable` records each, staggered by
23858    /// age, plus one never-touchable decoy per non-`Unavailable` state in
23859    /// `workspace-a` (all as old as that workspace's oldest real record --
23860    /// if the sweep's state gate ever leaked, these would be the first
23861    /// thing it wrongly retired). Ages, oldest to newest:
23862    /// `workspace-a`: a1 (500s), a2 (300s), a3 (100s).
23863    /// `workspace-b`: b1 (400s), b2 (200s), b3 (50s).
23864    fn retention_fixture_records(now: u64) -> Vec<ManagedSessionRecord> {
23865        vec![
23866            retention_test_record("workspace-a", "a1", ManagedSessionState::Unavailable, now - 500_000),
23867            retention_test_record("workspace-a", "a2", ManagedSessionState::Unavailable, now - 300_000),
23868            retention_test_record("workspace-a", "a3", ManagedSessionState::Unavailable, now - 100_000),
23869            retention_test_record("workspace-b", "b1", ManagedSessionState::Unavailable, now - 400_000),
23870            retention_test_record("workspace-b", "b2", ManagedSessionState::Unavailable, now - 200_000),
23871            retention_test_record("workspace-b", "b3", ManagedSessionState::Unavailable, now - 50_000),
23872            retention_test_record("workspace-a", "decoy-live", ManagedSessionState::Live, now - 500_000),
23873            retention_test_record(
23874                "workspace-a",
23875                "decoy-pending",
23876                ManagedSessionState::IdentityPending,
23877                now - 500_000,
23878            ),
23879            retention_test_record(
23880                "workspace-a",
23881                "decoy-dormant",
23882                ManagedSessionState::Dormant,
23883                now - 500_000,
23884            ),
23885        ]
23886    }
23887
23888    fn retention_fixture_shared() -> (NodeShared, NativeRuntime, PathBuf, u64) {
23889        let root = temporary_workspace_root("session-record-retention");
23890        std::fs::create_dir_all(&root).unwrap();
23891        let catalog = active_registry().unwrap();
23892        let (handle, runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23893        let workspace_a =
23894            WorkspaceConfig::new(WorkspaceId::new("workspace-a").unwrap(), &root).unwrap();
23895        let workspace_b =
23896            WorkspaceConfig::new(WorkspaceId::new("workspace-b").unwrap(), &root).unwrap();
23897        let mut shared = NodeShared::new(
23898            handle,
23899            "fixture-token".to_owned(),
23900            NodeId::new("node-retention-test").unwrap(),
23901            vec![workspace_a, workspace_b],
23902            vec![agent("claude")],
23903        );
23904        shared.state_path = Some(root.join("node-state-v10.json"));
23905        let now = unix_time_ms();
23906        for record in retention_fixture_records(now) {
23907            shared.insert_record(record).unwrap();
23908        }
23909        shared
23910            .persist_state_locked()
23911            .expect("fixture-only, empty-history persist against a fresh temp state path");
23912        (shared, runtime, root, now)
23913    }
23914
23915    fn retention_removed_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23916        shared
23917            .history
23918            .lock()
23919            .unwrap_or_else(|poisoned| poisoned.into_inner())
23920            .events
23921            .iter()
23922            .filter_map(|envelope| match &envelope.event {
23923                NodeEvent::SessionRecordRemoved { record_id } => {
23924                    Some(record_id.as_str().to_owned())
23925                }
23926                _ => None,
23927            })
23928            .collect()
23929    }
23930
23931    fn retention_remaining_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23932        shared
23933            .session_records
23934            .lock()
23935            .unwrap_or_else(|poisoned| poisoned.into_inner())
23936            .records
23937            .keys()
23938            .map(|record_id| record_id.as_str().to_owned())
23939            .collect()
23940    }
23941
23942    fn owned(values: &[&str]) -> std::collections::BTreeSet<String> {
23943        values.iter().map(|value| (*value).to_owned()).collect()
23944    }
23945
23946    #[tokio::test]
23947    async fn session_record_retention_keep_n_only_keeps_the_newest_per_workspace() {
23948        let (mut shared, runtime, root, _now) = retention_fixture_shared();
23949        shared.session_record_retention = SessionRecordRetentionConfig {
23950            age_ms: 0,
23951            keep_per_workspace: 1,
23952        };
23953        shared.retire_unavailable_session_records().await;
23954
23955        assert_eq!(
23956            retention_remaining_record_ids(&shared),
23957            owned(&["a3", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23958        );
23959        assert_eq!(
23960            retention_removed_record_ids(&shared),
23961            owned(&["a1", "a2", "b1", "b2"]),
23962        );
23963        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 4);
23964
23965        drop(shared);
23966        drop(runtime);
23967        std::fs::remove_dir_all(root).unwrap();
23968    }
23969
23970    /// Age-only would retire {a1, a2, b1}; keep-N-only would retire
23971    /// {a1, a2, b1, b2}. With both flags set the sweep must retire exactly
23972    /// the intersection {a1, a2, b1} -- `b2` fails the age test (it is only
23973    /// 200s old against a 250s threshold) even though it fails the keep-N
23974    /// test too, proving retirement needs BOTH, not either.
23975    #[tokio::test]
23976    async fn session_record_retention_both_set_retires_only_the_intersection() {
23977        let (mut shared, runtime, root, _now) = retention_fixture_shared();
23978        shared.session_record_retention = SessionRecordRetentionConfig {
23979            age_ms: 250_000,
23980            keep_per_workspace: 1,
23981        };
23982        shared.retire_unavailable_session_records().await;
23983
23984        assert_eq!(
23985            retention_remaining_record_ids(&shared),
23986            owned(&["a3", "b2", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23987        );
23988        assert_eq!(retention_removed_record_ids(&shared), owned(&["a1", "a2", "b1"]));
23989        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 3);
23990
23991        drop(shared);
23992        drop(runtime);
23993        std::fs::remove_dir_all(root).unwrap();
23994    }
23995
23996    #[tokio::test]
23997    async fn session_record_retention_both_disabled_is_a_no_op() {
23998        let (mut shared, runtime, root, _now) = retention_fixture_shared();
23999        shared.session_record_retention = SessionRecordRetentionConfig::default();
24000        shared.retire_unavailable_session_records().await;
24001
24002        assert_eq!(
24003            retention_remaining_record_ids(&shared),
24004            owned(&[
24005                "a1", "a2", "a3", "b1", "b2", "b3",
24006                "decoy-live", "decoy-pending", "decoy-dormant",
24007            ]),
24008        );
24009        assert!(retention_removed_record_ids(&shared).is_empty());
24010        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 0);
24011
24012        drop(shared);
24013        drop(runtime);
24014        std::fs::remove_dir_all(root).unwrap();
24015    }
24016
24017    #[test]
24018    fn session_record_context_export_selects_one_exact_history_identity() {
24019        let candidates = vec![
24020            HistoryCandidateSummary {
24021                id: "candidate-other".to_owned(),
24022                session_id_hint: "provider-other".to_owned(),
24023                modified_at_unix_ms: Some(1),
24024            },
24025            HistoryCandidateSummary {
24026                id: "candidate-exact".to_owned(),
24027                session_id_hint: "provider-exact".to_owned(),
24028                modified_at_unix_ms: Some(2),
24029            },
24030        ];
24031        assert_eq!(
24032            select_history_candidate(&candidates, Some("provider-exact")).unwrap(),
24033            "candidate-exact",
24034        );
24035    }
24036
24037    #[test]
24038    fn session_record_context_export_rejects_missing_and_ambiguous_history_identity() {
24039        let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
24040            id: id.to_owned(),
24041            session_id_hint: hint.to_owned(),
24042            modified_at_unix_ms: None,
24043        };
24044        let missing = select_history_candidate(
24045            &[candidate("candidate-other", "provider-other")],
24046            Some("provider-exact"),
24047        )
24048        .unwrap_err();
24049        assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
24050        let ambiguous = select_history_candidate(
24051            &[
24052                candidate("candidate-a", "provider-exact"),
24053                candidate("candidate-b", "provider-exact"),
24054            ],
24055            Some("provider-exact"),
24056        )
24057        .unwrap_err();
24058        assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
24059    }
24060
24061    /// The PTY-sidecar-observation shape (no fleet provider today, but
24062    /// capability-keyed rather than vendor-locked) never carries a verified
24063    /// `provider_session`; its record-bound export instead falls back to
24064    /// "exactly one discovered candidate" (see
24065    /// `export_context_pack_for_session_record_inner`). This is the same
24066    /// ambiguity refusal as the identity-filtered path, just without an
24067    /// identity to filter by.
24068    #[test]
24069    fn session_record_context_export_selects_sole_history_candidate_without_identity() {
24070        let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
24071            id: id.to_owned(),
24072            session_id_hint: hint.to_owned(),
24073            modified_at_unix_ms: None,
24074        };
24075        assert_eq!(
24076            select_history_candidate(&[candidate("candidate-sole", "sidecar-session")], None)
24077                .unwrap(),
24078            "candidate-sole",
24079        );
24080        let missing = select_history_candidate(&[], None).unwrap_err();
24081        assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
24082        let ambiguous = select_history_candidate(
24083            &[
24084                candidate("candidate-a", "sidecar-session-a"),
24085                candidate("candidate-b", "sidecar-session-b"),
24086            ],
24087            None,
24088        )
24089        .unwrap_err();
24090        assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
24091    }
24092
24093    #[test]
24094    fn session_record_context_export_revalidation_rejects_record_change() {
24095        let session = terminal_address(1);
24096        let identity = ProviderSessionIdentity {
24097            key: ProviderSessionKey::SessionId,
24098            id: "provider-exact".to_owned(),
24099            transcript_path: Some(r"C:\private\provider.jsonl".to_owned()),
24100        };
24101        let mut expected = record("claude", "record-context-export");
24102        expected.state = ManagedSessionState::Live;
24103        expected.provider_session = Some(identity.clone());
24104        expected.active_session = Some(session.clone());
24105        let binding = SessionBinding {
24106            workspace_id: session.workspace_id.clone(),
24107            generation: session.session.generation,
24108            runtime_policy: ProviderRuntimePolicy::raw_pty(),
24109            pending_resume: None,
24110            record_id: Some(expected.record_id.clone()),
24111            managed_worktree_lease_id: None,
24112            environment_profile: None,
24113            bundle: None,
24114            context: None,
24115            materialization_id: None,
24116        };
24117        let current_root = windows_path_text(&expected.canonical_root).to_owned();
24118        assert!(session_record_context_export_binding_is_exact(
24119            &expected,
24120            &expected,
24121            Some(&identity),
24122            &session,
24123            &binding,
24124            &expected.provider,
24125            &current_root,
24126            false,
24127        ));
24128        let mut changed = expected.clone();
24129        changed.updated_at_unix_ms += 1;
24130        assert!(!session_record_context_export_binding_is_exact(
24131            &expected,
24132            &changed,
24133            Some(&identity),
24134            &session,
24135            &binding,
24136            &expected.provider,
24137            &current_root,
24138            false,
24139        ));
24140        // A same-instant timestamp bump alone (state and active_session
24141        // otherwise unchanged) is not the clean-detachment transition
24142        // either, even with the reactive flag on: only the exact Live ->
24143        // Dormant + active_session cleared shape qualifies.
24144        assert!(!session_record_context_export_binding_is_exact(
24145            &expected,
24146            &changed,
24147            Some(&identity),
24148            &session,
24149            &binding,
24150            &expected.provider,
24151            &current_root,
24152            true,
24153        ));
24154        let mut detached = expected.clone();
24155        detached.state = ManagedSessionState::Dormant;
24156        detached.active_session = None;
24157        detached.updated_at_unix_ms += 1;
24158        assert!(!session_record_context_export_binding_is_exact(
24159            &expected,
24160            &detached,
24161            Some(&identity),
24162            &session,
24163            &binding,
24164            &expected.provider,
24165            &current_root,
24166            false,
24167        ));
24168        assert!(session_record_context_export_binding_is_exact(
24169            &expected,
24170            &detached,
24171            Some(&identity),
24172            &session,
24173            &binding,
24174            &expected.provider,
24175            &current_root,
24176            true,
24177        ));
24178    }
24179
24180    /// Mirrors `session_record_context_export_revalidation_rejects_record_change`
24181    /// for a record whose provider never carries a verified identity (the
24182    /// PTY-sidecar-observation shape -- capability-keyed, no fleet provider
24183    /// declares one today): `identity` is `None` throughout, and the
24184    /// exactness check falls back to comparing `provider_session` itself,
24185    /// which must still be `None` on both sides.
24186    #[test]
24187    fn session_record_context_export_binding_is_exact_without_identity() {
24188        let session = terminal_address(1);
24189        let mut expected = record("pty-sidecar-fixture", "record-context-export-no-identity");
24190        expected.state = ManagedSessionState::Live;
24191        expected.active_session = Some(session.clone());
24192        let binding = SessionBinding {
24193            workspace_id: session.workspace_id.clone(),
24194            generation: session.session.generation,
24195            runtime_policy: ProviderRuntimePolicy::raw_pty(),
24196            pending_resume: None,
24197            record_id: Some(expected.record_id.clone()),
24198            managed_worktree_lease_id: None,
24199            environment_profile: None,
24200            bundle: None,
24201            context: None,
24202            materialization_id: None,
24203        };
24204        let current_root = windows_path_text(&expected.canonical_root).to_owned();
24205        assert!(session_record_context_export_binding_is_exact(
24206            &expected,
24207            &expected,
24208            None,
24209            &session,
24210            &binding,
24211            &expected.provider,
24212            &current_root,
24213            false,
24214        ));
24215        // A record that gained an identity between the initial lookup and
24216        // this recheck no longer matches the `None` this shape expects to
24217        // keep seeing -- treated the same as any other mid-export drift.
24218        let mut drifted = expected.clone();
24219        drifted.provider_session = Some(ProviderSessionIdentity {
24220            key: ProviderSessionKey::SessionId,
24221            id: "unexpected-identity".to_owned(),
24222            transcript_path: None,
24223        });
24224        assert!(!session_record_context_export_binding_is_exact(
24225            &expected,
24226            &drifted,
24227            None,
24228            &session,
24229            &binding,
24230            &expected.provider,
24231            &current_root,
24232            false,
24233        ));
24234    }
24235
24236    #[test]
24237    fn session_record_context_export_preflight_requires_live_usable_source() {
24238        let mut source = record("claude", "record-context-export-preflight");
24239        source.state = ManagedSessionState::Live;
24240        assert!(session_record_context_export_source_is_usable(
24241            &source,
24242            &SessionStatus::Running,
24243            false,
24244        ));
24245        assert!(session_record_context_export_source_is_usable(
24246            &source,
24247            &SessionStatus::Exited { exit_code: Some(0) },
24248            false,
24249        ));
24250
24251        source.state = ManagedSessionState::Dormant;
24252        assert!(!session_record_context_export_source_is_usable(
24253            &source,
24254            &SessionStatus::Running,
24255            false,
24256        ));
24257        // The reactive auto-export-at-exit path (allow_clean_detachment) is
24258        // the only caller that may treat a Dormant, cleanly-exited record as
24259        // still usable — the on-demand path above never does.
24260        assert!(session_record_context_export_source_is_usable(
24261            &source,
24262            &SessionStatus::Exited { exit_code: Some(0) },
24263            true,
24264        ));
24265        assert!(!session_record_context_export_source_is_usable(
24266            &source,
24267            &SessionStatus::Exited { exit_code: Some(0) },
24268            false,
24269        ));
24270        source.state = ManagedSessionState::Live;
24271        assert!(!session_record_context_export_source_is_usable(
24272            &source,
24273            &SessionStatus::Starting,
24274            false,
24275        ));
24276    }
24277
24278    #[test]
24279    fn session_record_context_export_final_conflict_leaves_catalog_empty() {
24280        let catalog = active_registry().unwrap();
24281        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24282        let workspace = WorkspaceConfig::new(
24283            WorkspaceId::new("primary").unwrap(),
24284            std::env::current_dir().unwrap(),
24285        )
24286        .unwrap();
24287        let shared = NodeShared::new(
24288            handle,
24289            "fixture-token".to_owned(),
24290            NodeId::new("node-context-export-conflict").unwrap(),
24291            vec![workspace],
24292            vec![agent("claude")],
24293        );
24294        let history = HistorySessionRecord {
24295            session_id: "provider-exact".to_owned(),
24296            title: None,
24297            cwd: None,
24298            model: None,
24299            message_count: 1,
24300            completed_turn_count: None,
24301            total_tokens: 0,
24302            messages: vec![gate4agent_types::HistoryMessageRecord {
24303                role: gate4agent_types::HistoryMessageRole::User,
24304                text: "bounded context".to_owned(),
24305            }],
24306        };
24307        let pack = NodeContextPack::export(
24308            ContextPackLineageReceipt {
24309                source_node_id: shared.node_id.clone(),
24310                source_session: terminal_address(1),
24311                source_provider: agent("claude"),
24312            },
24313            &history,
24314        )
24315        .unwrap();
24316        let context_id = pack.receipt().id.clone();
24317        let session = terminal_address(1);
24318        let identity = ProviderSessionIdentity {
24319            key: ProviderSessionKey::SessionId,
24320            id: history.session_id.clone(),
24321            transcript_path: None,
24322        };
24323        let mut expected = record("claude", "record-context-export-final-conflict");
24324        expected.state = ManagedSessionState::Live;
24325        expected.provider_session = Some(identity.clone());
24326        expected.active_session = Some(session.clone());
24327        let mut changed = expected.clone();
24328        changed.updated_at_unix_ms += 1;
24329        shared.insert_record(changed).unwrap();
24330
24331        let error = shared
24332            .commit_context_pack_for_session_record(
24333                &expected,
24334                Some(&identity),
24335                &session,
24336                "candidate-exact",
24337                &history,
24338                pack,
24339                false,
24340            )
24341            .unwrap_err();
24342
24343        assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
24344        assert!(shared
24345            .context_catalog
24346            .read()
24347            .unwrap_or_else(|poisoned| poisoned.into_inner())
24348            .get(&context_id)
24349            .is_none());
24350    }
24351
24352    #[tokio::test]
24353    async fn session_task_binding_mints_assigns_clears_with_cas_and_persists_v9() {
24354        let root = temporary_workspace_root("session-task-binding");
24355        std::fs::create_dir_all(&root).unwrap();
24356        let state_path = root.join("node-state.json");
24357        let catalog = active_registry().unwrap();
24358        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24359        let workspace = WorkspaceConfig::new(WorkspaceId::new("primary").unwrap(), &root).unwrap();
24360        let node_id = NodeId::new("node-1").unwrap();
24361        let mut shared = NodeShared::new(
24362            handle,
24363            "fixture-token".to_owned(),
24364            node_id.clone(),
24365            vec![workspace],
24366            vec![agent("claude")],
24367        );
24368        shared.state_path = Some(state_path.clone());
24369        let record = record("claude", "sr-task");
24370        let record_id = record.record_id.clone();
24371        shared.insert_record(record).unwrap();
24372        shared.persist_state().unwrap();
24373        shared
24374            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
24375            .unwrap();
24376
24377        let minted = process_request_inner(
24378            &shared,
24379            77,
24380            ClientRole::Operator,
24381            NodeRequest::SetSessionTask {
24382                record_id: record_id.clone(),
24383                expected_revision: 0,
24384                target: SessionTaskTargetV1::New,
24385            },
24386        )
24387        .await
24388        .unwrap();
24389        let NodeResponse::SessionRecordUpdated { record: minted } = minted else {
24390            panic!("session task mutation returned another response");
24391        };
24392        let minted_binding = minted.task_binding.clone().unwrap();
24393        let minted_task_id = minted_binding.task_id.clone().unwrap();
24394        assert_eq!(minted_binding.revision, 1);
24395        assert!(minted_task_id.as_str().starts_with("task-"));
24396        assert_eq!(minted_task_id.as_str().len(), 29);
24397
24398        let event_count = shared.current_sequence();
24399        let repeated = process_request_inner(
24400            &shared,
24401            77,
24402            ClientRole::Operator,
24403            NodeRequest::SetSessionTask {
24404                record_id: record_id.clone(),
24405                expected_revision: 1,
24406                target: SessionTaskTargetV1::Existing {
24407                    task_id: minted_task_id.clone(),
24408                },
24409            },
24410        )
24411        .await
24412        .unwrap();
24413        assert_eq!(repeated, NodeResponse::SessionRecordUpdated { record: minted.clone() });
24414        assert_eq!(shared.current_sequence(), event_count);
24415
24416        let stale = process_request_inner(
24417            &shared,
24418            77,
24419            ClientRole::Operator,
24420            NodeRequest::SetSessionTask {
24421                record_id: record_id.clone(),
24422                expected_revision: 0,
24423                target: SessionTaskTargetV1::Existing {
24424                    task_id: minted_task_id,
24425                },
24426            },
24427        )
24428        .await
24429        .unwrap_err();
24430        assert_eq!(stale.code, NodeFailureCode::SessionRecordConflict);
24431
24432        let assigned_id: TaskId = "task-ffeeddccbbaa998877665544".parse().unwrap();
24433        let assigned = process_request_inner(
24434            &shared,
24435            77,
24436            ClientRole::Operator,
24437            NodeRequest::SetSessionTask {
24438                record_id: record_id.clone(),
24439                expected_revision: 1,
24440                target: SessionTaskTargetV1::Existing {
24441                    task_id: assigned_id.clone(),
24442                },
24443            },
24444        )
24445        .await
24446        .unwrap();
24447        let NodeResponse::SessionRecordUpdated { record: assigned } = assigned else {
24448            panic!("session task assignment returned another response");
24449        };
24450        assert_eq!(assigned.task_binding.as_ref().unwrap().revision, 2);
24451        assert_eq!(assigned.task_binding.as_ref().unwrap().task_id.as_ref(), Some(&assigned_id));
24452
24453        let cleared = process_request_inner(
24454            &shared,
24455            77,
24456            ClientRole::Operator,
24457            NodeRequest::SetSessionTask {
24458                record_id: record_id.clone(),
24459                expected_revision: 2,
24460                target: SessionTaskTargetV1::Clear,
24461            },
24462        )
24463        .await
24464        .unwrap();
24465        let NodeResponse::SessionRecordUpdated { record: cleared } = cleared else {
24466            panic!("session task clear returned another response");
24467        };
24468        assert_eq!(cleared.task_binding.as_ref().unwrap().revision, 3);
24469        assert!(cleared.task_binding.as_ref().unwrap().task_id.is_none());
24470        assert!(cleared.task_binding.as_ref().unwrap().changed_at_unix_ms > 0);
24471        assert_eq!(
24472            cleared.task_binding.as_ref().unwrap().changed_at_unix_ms,
24473            cleared.updated_at_unix_ms,
24474        );
24475
24476        let persisted = std::fs::read(&state_path).unwrap();
24477        assert_eq!(serde_json::from_slice::<serde_json::Value>(&persisted).unwrap()["version"], NODE_STATE_SCHEMA_V10);
24478        let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
24479        assert_eq!(loaded.records[0].task_binding, cleared.task_binding);
24480        std::fs::remove_dir_all(root).unwrap();
24481    }
24482
24483    #[test]
24484    fn session_task_capability_strips_snapshots_events_and_replies() {
24485        let mut bound = record("claude", "sr-task-projection");
24486        bound.updated_at_unix_ms = 2;
24487        bound.task_binding = Some(SessionTaskBindingV1 {
24488            revision: 1,
24489            task_id: Some("task-00112233445566778899aaff".parse().unwrap()),
24490            changed_at_unix_ms: 2,
24491        });
24492        let request = NodeRequest::SetSessionTask {
24493            record_id: bound.record_id.clone(),
24494            expected_revision: 1,
24495            target: SessionTaskTargetV1::Clear,
24496        };
24497        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
24498            capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
24499        }));
24500        assert!(!request_uses_unnegotiated_capability(
24501            &request,
24502            &baseline_capabilities().unwrap(),
24503        ));
24504        assert!(request_uses_unnegotiated_capability(&request, &[]));
24505
24506        let mut snapshot = terminal_test_shared().snapshot();
24507        snapshot.session_records.push(bound.clone());
24508        let mut reply = ResponseEnvelope {
24509            request_id: 1,
24510            result: Ok(NodeResponse::Resync {
24511                event_sequence: 1,
24512                oldest_available_sequence: 1,
24513                snapshot,
24514                events: vec![NodeEventEnvelope {
24515                    sequence: 1,
24516                    event: NodeEvent::SessionRecordUpserted {
24517                        record: bound.clone(),
24518                    },
24519                }],
24520            }),
24521        };
24522        project_response_without_session_task_binding(&mut reply);
24523        let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
24524            panic!("task projection changed the response kind");
24525        };
24526        assert!(snapshot.session_records[0].task_binding.is_none());
24527        let NodeEvent::SessionRecordUpserted { record } = &events[0].event else {
24528            panic!("task projection changed the event kind");
24529        };
24530        assert!(record.task_binding.is_none());
24531
24532        let projected = project_event_without_session_task_binding(NodeEventEnvelope {
24533            sequence: 2,
24534            event: NodeEvent::SessionRecordUpserted { record: bound.clone() },
24535        });
24536        let NodeEvent::SessionRecordUpserted { record } = projected.event else {
24537            panic!("task event projection changed the event kind");
24538        };
24539        assert!(record.task_binding.is_none());
24540
24541        let mut direct = ResponseEnvelope {
24542            request_id: 2,
24543            result: Ok(NodeResponse::SessionRecordUpdated { record: bound }),
24544        };
24545        project_response_without_session_task_binding(&mut direct);
24546        let Ok(NodeResponse::SessionRecordUpdated { record }) = direct.result else {
24547            panic!("task reply projection changed the response kind");
24548        };
24549        assert!(record.task_binding.is_none());
24550    }
24551
24552    #[test]
24553    fn windows_runtime_default_node_endpoint_is_exact_and_valid() {
24554        assert_eq!(DEFAULT_NODE_ENDPOINT, r"\\.\pipe\gate4agent-node");
24555        let workspace = WorkspaceConfig::new(
24556            WorkspaceId::new("primary").unwrap(),
24557            std::env::current_dir().unwrap(),
24558        )
24559        .unwrap();
24560        let config = NodeServerConfig::new(
24561            DEFAULT_NODE_ENDPOINT,
24562            "fixture-token",
24563            NodeId::new("node-1").unwrap(),
24564            [workspace],
24565        )
24566        .unwrap();
24567        assert_eq!(config.endpoint, DEFAULT_NODE_ENDPOINT);
24568    }
24569
24570    #[cfg(all(feature = "fixture", windows))]
24571    #[test]
24572    fn provider_bundle_argv_hold_fixture_is_parent_bounded_and_waits_after_proof() {
24573        let root = temporary_workspace_root("provider-bundle-argv-hold");
24574        let outside = temporary_workspace_root("provider-bundle-argv-hold-outside");
24575        std::fs::create_dir_all(&root).unwrap();
24576        std::fs::create_dir_all(&outside).unwrap();
24577        let proof_path = root.join("provider.proof");
24578        let release_signal = root.join("release.signal");
24579        let config = NodeServerConfig::new(
24580            format!(
24581                r"\\.\pipe\gate4agent-provider-bundle-hold-{}",
24582                std::process::id(),
24583            ),
24584            "fixture-token",
24585            NodeId::new("provider-bundle-hold-node").unwrap(),
24586            [WorkspaceConfig::new(
24587                WorkspaceId::new("primary").unwrap(),
24588                std::env::current_dir().unwrap(),
24589            )
24590            .unwrap()],
24591        )
24592        .unwrap()
24593        .with_spawn_profiles(SpawnProfileRegistry::new([
24594            crate::protocol::SpawnProfileDefaults {
24595                profile_id: crate::protocol::SpawnProfileId::new("kimi-bundle-hold").unwrap(),
24596                revision: crate::protocol::SpawnProfileRevision::new("kimi-bundle-hold-r1")
24597                    .unwrap(),
24598                provider: agent("kimi"),
24599                mode: SessionMode::Pty,
24600                terminal_size: gate4agent_types::TerminalSize {
24601                    rows: 24,
24602                    columns: 80,
24603                },
24604                prompt: None,
24605                bundle_id: None,
24606                context_id: None,
24607                environment_profile_id: None,
24608            },
24609        ])
24610        .unwrap());
24611        let server = NodeServer::new_provider_bundle_argv_hold_fixture(
24612            config,
24613            agent("kimi"),
24614            proof_path.clone(),
24615            release_signal.clone(),
24616        )
24617        .unwrap();
24618        assert_eq!(server.shared.enabled_providers, [agent("kimi")]);
24619
24620        let spec = NodeServer::provider_bundle_argv_fixture_spec(
24621            agent("kimi"),
24622            proof_path.clone(),
24623            Some(release_signal.clone()),
24624        )
24625        .unwrap();
24626        let wrapper = &spec.launch.fixed_args[spec.launch.fixed_args.len() - 3];
24627        let proof_write = wrapper.find("WriteAllLines($proofPath").unwrap();
24628        let release_wait = wrapper
24629            .find("while (-not (Test-Path -LiteralPath $releaseSignal -PathType Leaf))")
24630            .unwrap();
24631        let fixture_ready = wrapper.find("fixture-ready>").unwrap();
24632        assert!(proof_write < release_wait);
24633        assert!(release_wait < fixture_ready);
24634        assert!(wrapper.contains("[DateTime]::UtcNow.AddSeconds(45)"));
24635        assert!(wrapper.contains("Start-Sleep -Milliseconds 20"));
24636        assert_eq!(
24637            &spec.launch.fixed_args[spec.launch.fixed_args.len() - 2..],
24638            &[
24639                proof_path.to_string_lossy().into_owned(),
24640                release_signal.to_string_lossy().into_owned(),
24641            ],
24642        );
24643
24644        let error = NodeServer::provider_bundle_argv_fixture_spec(
24645            agent("kimi"),
24646            proof_path,
24647            Some(outside.join("release.signal")),
24648        )
24649        .unwrap_err();
24650        assert!(matches!(
24651            error,
24652            NodeServerError::Registry(message)
24653                if message == "provider bundle argv release path must share the proof parent"
24654        ));
24655        drop(server);
24656        std::fs::remove_dir_all(root).unwrap();
24657        std::fs::remove_dir_all(outside).unwrap();
24658    }
24659
24660    #[cfg(feature = "fixture")]
24661    #[test]
24662    fn four_provider_context_fixture_registry_is_unambiguous_and_history_bound() {
24663        let root = temporary_workspace_root("context-fixture-registry");
24664        std::fs::create_dir_all(&root).unwrap();
24665        let proof_path = root.join("context.proof");
24666        let config = NodeServerConfig::new(
24667            format!(
24668                r"\\.\pipe\gate4agent-context-fixture-registry-{}",
24669                std::process::id(),
24670            ),
24671            "fixture-token",
24672            NodeId::new("context-fixture-node").unwrap(),
24673            [WorkspaceConfig::new(
24674                WorkspaceId::new("primary").unwrap(),
24675                std::env::current_dir().unwrap(),
24676            )
24677            .unwrap()],
24678        )
24679        .unwrap();
24680        let server = NodeServer::new_context_pack_fixture(config, proof_path).unwrap();
24681        let mut enabled = server
24682            .shared
24683            .enabled_providers
24684            .iter()
24685            .map(AgentId::as_str)
24686            .collect::<Vec<_>>();
24687        enabled.sort_unstable();
24688        assert_eq!(enabled, ["claude", "codex", "grok", "kimi"]);
24689        for provider in &server.shared.enabled_providers {
24690            assert!(server
24691                .shared
24692                .provider_adapter_contracts
24693                .iter()
24694                .any(|contract| {
24695                    &contract.provider == provider
24696                        && contract.family == AdapterFamily::History
24697                }));
24698        }
24699        drop(server);
24700        std::fs::remove_dir_all(root).unwrap();
24701    }
24702
24703    #[cfg(feature = "fixture")]
24704    #[test]
24705    fn monitoring_context_fixture_combines_claude_and_codex_hooks_with_history() {
24706        let root = temporary_workspace_root("monitoring-context-fixture");
24707        std::fs::create_dir_all(&root).unwrap();
24708        let proof_path = root.join("context.proof");
24709        let standard = NodeServer::context_pack_fixture_catalog(
24710            proof_path.clone(),
24711            None,
24712            false,
24713        ).unwrap();
24714        let monitoring = NodeServer::context_pack_fixture_catalog(
24715            proof_path.clone(),
24716            None,
24717            true,
24718        ).unwrap();
24719        let claude = AgentId::new("claude").unwrap();
24720        let standard_claude = standard.get(&claude).unwrap();
24721        let monitoring_claude = monitoring.get(&claude).unwrap();
24722        // Lifecycle hooks are retired (owner ruling 2026-09-25): neither the
24723        // standard nor the monitoring fixture declares a hook adapter
24724        // anymore.
24725        assert!(monitoring_claude.capabilities.adapters.hook.is_none());
24726        assert_eq!(
24727            monitoring_claude.capabilities.adapters.history,
24728            standard_claude.capabilities.adapters.history,
24729        );
24730        assert_ne!(monitoring_claude.launch, standard_claude.launch);
24731        let codex = AgentId::new("codex").unwrap();
24732        let standard_codex = standard.get(&codex).unwrap();
24733        let monitoring_codex = monitoring.get(&codex).unwrap();
24734        assert!(monitoring_codex.capabilities.adapters.hook.is_none());
24735        assert_eq!(
24736            monitoring_codex.capabilities.adapters.history,
24737            standard_codex.capabilities.adapters.history,
24738        );
24739        assert_ne!(monitoring_codex.launch, standard_codex.launch);
24740        let codex_validation = monitoring_codex.launch.fixed_args.iter()
24741            .find(|argument| argument.contains("F7_CODEX_BUNDLE_CONTEXT_VALIDATED"))
24742            .unwrap();
24743        for required in [
24744            "$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')",
24745            "$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')",
24746            "@('role', 'text')",
24747            "g4a-private-provider-session-canary",
24748            "private-provider-login-identity",
24749            "$rawProof",
24750            "$env:GATE4AGENT_HOOK_URL",
24751            "g4a-private-provider-session-canary",
24752        ] {
24753            assert!(codex_validation.contains(required), "missing privacy validation: {required}");
24754        }
24755        for provider in ["grok", "kimi"] {
24756            let provider = AgentId::new(provider).unwrap();
24757            assert_eq!(monitoring.get(&provider), standard.get(&provider));
24758        }
24759
24760        let config = NodeServerConfig::new(
24761            format!(
24762                r"\\.\pipe\gate4agent-monitoring-context-fixture-{}",
24763                std::process::id(),
24764            ),
24765            "fixture-token",
24766            NodeId::new("monitoring-context-fixture-node").unwrap(),
24767            [WorkspaceConfig::new(
24768                WorkspaceId::new("primary").unwrap(),
24769                std::env::current_dir().unwrap(),
24770            )
24771            .unwrap()],
24772        )
24773        .unwrap();
24774        let server = NodeServer::new_monitoring_context_pack_fixture(config, proof_path).unwrap();
24775        assert!(server.shared.fixture_semantic_hook_policy);
24776        drop(server);
24777        std::fs::remove_dir_all(root).unwrap();
24778    }
24779
24780    #[cfg(feature = "fixture")]
24781    #[test]
24782    fn kimi_context_only_fixture_is_bounded_and_preserves_codex_validation() {
24783        let root = temporary_workspace_root("kimi-context-only-fixture");
24784        std::fs::create_dir_all(&root).unwrap();
24785        let proof_path = root.join("context.proof");
24786        let standard = NodeServer::context_pack_fixture_catalog(
24787            proof_path.clone(),
24788            None,
24789            false,
24790        ).unwrap();
24791        let kimi = AgentId::new("kimi").unwrap();
24792        let context_only = NodeServer::context_pack_fixture_catalog(
24793            proof_path,
24794            Some(&kimi),
24795            false,
24796        )
24797        .unwrap();
24798
24799        let codex = AgentId::new("codex").unwrap();
24800        assert_eq!(
24801            standard.get(&codex).unwrap().launch.fixed_args,
24802            context_only.get(&codex).unwrap().launch.fixed_args,
24803        );
24804        let script = context_only
24805            .get(&kimi)
24806            .unwrap()
24807            .launch
24808            .fixed_args
24809            .iter()
24810            .find(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED"))
24811            .expect("Kimi context-only validation script is missing");
24812        for required in [
24813            "$bundleArgs.Count -ne 0",
24814            "GATE4AGENT_CONTEXT_ROOT",
24815            "context-pack.json",
24816            "$entries.Count -ne 1",
24817            "g4a-context-pack-v1",
24818            "$document.PSObject.Properties['cwd']",
24819            "'claude', 'codex', 'grok', 'kimi'",
24820            "$hasUser",
24821            "$hasAssistant",
24822            "SHA256",
24823            "context-only",
24824        ] {
24825            assert!(script.contains(required), "missing validation: {required}");
24826        }
24827        for forbidden in ["CODEX_HOME", "USERPROFILE", "auth", "token", "global config"] {
24828            assert!(!script.contains(forbidden), "forbidden home/auth access: {forbidden}");
24829        }
24830        assert!(!standard
24831            .get(&kimi)
24832            .unwrap()
24833            .launch
24834            .fixed_args
24835            .iter()
24836            .any(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED")));
24837
24838        std::fs::remove_dir_all(root).unwrap();
24839    }
24840
24841    #[cfg(all(feature = "fixture", windows))]
24842    #[tokio::test]
24843    async fn kimi_context_only_fixture_spawns_catalogued_context_pack_through_spawn_spec() {
24844        let root = temporary_workspace_root("kimi-context-only-spawn");
24845        let workspace_root = root.join("workspace");
24846        std::fs::create_dir_all(&workspace_root).unwrap();
24847        let materialization_root = root.join("private-materializations");
24848        let proof_path = root.join("context.proof");
24849        let node_id = NodeId::new("node-kimi-context-only-spawn").unwrap();
24850        let workspace_id = WorkspaceId::new("primary").unwrap();
24851        let profile_id = crate::protocol::SpawnProfileId::new("target-kimi").unwrap();
24852        let endpoint = format!(
24853            r"\\.\pipe\gate4agent-kimi-context-only-spawn-{}-{}",
24854            std::process::id(),
24855            unix_time_ms(),
24856        );
24857        let spawn_profiles = SpawnProfileRegistry::new([
24858            crate::protocol::SpawnProfileDefaults {
24859                profile_id: profile_id.clone(),
24860                revision: crate::protocol::SpawnProfileRevision::new("target-kimi-r1").unwrap(),
24861                provider: agent("kimi"),
24862                mode: SessionMode::Pty,
24863                terminal_size: gate4agent_types::TerminalSize {
24864                    rows: 30,
24865                    columns: 120,
24866                },
24867                prompt: None,
24868                bundle_id: None,
24869                context_id: None,
24870                environment_profile_id: None,
24871            },
24872        ])
24873        .unwrap();
24874        let mut config = NodeServerConfig::new(
24875            &endpoint,
24876            "fixture-token",
24877            node_id.clone(),
24878            [WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap()],
24879        )
24880        .unwrap()
24881        .with_spawn_profiles(spawn_profiles)
24882        .with_session_environment_materialization(
24883            materialization_root,
24884            Arc::new(FixtureSecretResolver {
24885                deny: Arc::new(AtomicBool::new(false)),
24886            }),
24887        )
24888        .unwrap();
24889        config.fixture_raw_pty_runtime = true;
24890        let server = NodeServer::new_context_only_proof_fixture(
24891            config,
24892            agent("kimi"),
24893            proof_path.clone(),
24894        )
24895        .unwrap();
24896        let shared = Arc::clone(&server.shared);
24897        let shutdown = server.shutdown_handle();
24898
24899        let history = HistorySessionRecord {
24900            session_id: "context-only-source-history".to_owned(),
24901            title: None,
24902            cwd: None,
24903            model: None,
24904            message_count: 2,
24905            completed_turn_count: None,
24906            total_tokens: 0,
24907            messages: vec![
24908                gate4agent_types::HistoryMessageRecord {
24909                    role: gate4agent_types::HistoryMessageRole::User,
24910                    text: "continue the bounded parent run through native C2".to_owned(),
24911                },
24912                gate4agent_types::HistoryMessageRecord {
24913                    role: gate4agent_types::HistoryMessageRole::Assistant,
24914                    text: "the exact parent context is ready for the child run".to_owned(),
24915                },
24916            ],
24917        };
24918        let pack = NodeContextPack::export(
24919            ContextPackLineageReceipt {
24920                source_node_id: node_id.clone(),
24921                source_session: SessionAddress {
24922                    workspace_id: workspace_id.clone(),
24923                    session: SessionKey {
24924                        instance_id: AgentInstanceId(71),
24925                        generation: SessionGeneration(1),
24926                    },
24927                },
24928                source_provider: agent("codex"),
24929            },
24930            &history,
24931        )
24932        .unwrap();
24933        let expected_context_hash = digest(&SHA256, pack.bytes())
24934            .as_ref()
24935            .iter()
24936            .map(|byte| format!("{byte:02x}"))
24937            .collect::<String>();
24938        let context = shared
24939            .context_catalog
24940            .write()
24941            .unwrap_or_else(|poisoned| poisoned.into_inner())
24942            .insert(pack)
24943            .unwrap();
24944        let spec = SpawnSpec {
24945            target: crate::protocol::SpawnTarget {
24946                node_id,
24947                workspace_id: workspace_id.clone(),
24948                worktree_id: None,
24949            },
24950            profile_id,
24951            expected_profile_revision: crate::protocol::SpawnProfileRevision::new(
24952                "target-kimi-r1",
24953            ).unwrap(),
24954            overrides: crate::protocol::SpawnOverrides {
24955                context_id: crate::protocol::SpawnOverride::Set {
24956                    value: context.id.clone(),
24957                },
24958                ..crate::protocol::SpawnOverrides::default()
24959            },
24960            deadline_ms: crate::protocol::SpawnDeadlineMs::new(10_000).unwrap(),
24961            idempotency_key: SpawnIdempotencyKey::new("kimi-context-only-spawn").unwrap(),
24962            required_capabilities: SpawnRequiredCapabilities::default(),
24963        };
24964        let resolved = match shared.resolve_spawn_spec(&spec) {
24965            Ok(resolved) => resolved,
24966            Err(error) => panic!(
24967                "Kimi context-only resolve rejected: exact_code={:?} message={}",
24968                error.code,
24969                error.message,
24970            ),
24971        };
24972        let resolved_context = match shared.resolve_context(&resolved) {
24973            Ok(Some(resolved_context)) => resolved_context,
24974            Ok(None) => panic!("Kimi context-only resolve omitted the exact ContextPack"),
24975            Err(error) => panic!(
24976                "Kimi context catalog resolve rejected: exact_code={:?} message={}",
24977                error.code,
24978                error.message,
24979            ),
24980        };
24981        assert_eq!(resolved_context, context);
24982        let runtime_policy = match shared
24983            .admit_provider_runtime(&agent("kimi"), ProviderRuntimeRequirement::RawPty)
24984            .await
24985        {
24986            Ok(runtime_policy) => runtime_policy,
24987            Err(error) => panic!(
24988                "Kimi RawPty admission rejected: exact_code={:?} message={}",
24989                error.code,
24990                error.message,
24991            ),
24992        };
24993        assert!(runtime_policy.raw_pty_lifecycle);
24994
24995        let launch_control = shared.native_launch_profile_control.as_ref().unwrap();
24996        let generic_overlay = NativeLaunchEnvironmentOverlay::new(
24997            agent("kimi"),
24998            TransportKind::Pty,
24999            vec![EnvMutation {
25000                key: OsString::from("GATE4AGENT_UNPROFILED_GENERIC"),
25001                value: Some(OsString::from("must-remain-rejected")),
25002            }],
25003        )
25004        .unwrap();
25005        assert_eq!(
25006            launch_control
25007                .install_native_launch_environment_overlay(
25008                    AgentInstanceId(72),
25009                    generic_overlay,
25010                )
25011                .unwrap_err(),
25012            gate4agent_runtime_native::NativeLaunchProfileError::EnvironmentOverlaySelectionMissing,
25013        );
25014
25015        let diagnostic_address = SessionAddress {
25016            workspace_id: workspace_id.clone(),
25017            session: SessionKey {
25018                instance_id: AgentInstanceId(73),
25019                generation: SessionGeneration(1),
25020            },
25021        };
25022        let (diagnostic_overlay, diagnostic_materialization) = match shared
25023            .prepare_session_materialization(
25024                &diagnostic_address,
25025                &agent("kimi"),
25026                SessionMode::Pty,
25027                None,
25028                None,
25029                Some(&resolved_context),
25030                None,
25031            )
25032        {
25033            Ok(Some(prepared)) => prepared,
25034            Ok(None) => panic!("Kimi context-only materialization produced no launch overlay"),
25035            Err(error) => panic!(
25036                "Kimi context-only materialization rejected: exact_code={:?} message={}",
25037                error.code,
25038                error.message,
25039            ),
25040        };
25041        let diagnostic_overlay = diagnostic_overlay
25042            .expect("Kimi context-only materialization omitted its environment overlay");
25043        assert!(matches!(
25044            &diagnostic_overlay,
25045            PreparedNativeLaunchOverlay::Environment(_),
25046        ));
25047        match shared.install_prepared_launch_overlay(
25048            diagnostic_address.session.instance_id,
25049            diagnostic_overlay,
25050        ) {
25051            Ok(None) => {}
25052            Ok(Some(_)) => panic!("Kimi context-only overlay unexpectedly retained argv authority"),
25053            Err(error) => panic!(
25054                "Kimi context-only overlay installation rejected: exact_code={:?} message={}",
25055                error.code,
25056                error.message,
25057            ),
25058        }
25059        assert!(launch_control.clear_native_instance_launch_overlay(
25060            diagnostic_address.session.instance_id,
25061        ));
25062        drop(diagnostic_materialization);
25063        assert!(shared
25064            .materializations
25065            .lock()
25066            .unwrap_or_else(|poisoned| poisoned.into_inner())
25067            .is_empty());
25068
25069        let node_task = tokio::spawn(server.run());
25070        let ready_client = timeout(Duration::from_secs(5), async {
25071            loop {
25072                match gate4agent_node_wire::LocalNodeClient::connect(
25073                    &endpoint,
25074                    &shared.node_id,
25075                    ClientRole::Observer,
25076                    "fixture-token",
25077                )
25078                .await
25079                {
25080                    Ok(client) => break client,
25081                    Err(_) if !node_task.is_finished() => sleep(Duration::from_millis(10)).await,
25082                    Err(error) => panic!("Kimi context-only Node stopped before readiness: {error}"),
25083                }
25084            }
25085        })
25086        .await
25087        .expect("Kimi context-only Node did not become ready");
25088        assert_eq!(
25089            ready_client
25090                .hello()
25091                .snapshot
25092                .provider_runtime_statuses
25093                .iter()
25094                .find(|status| status.provider().as_str() == "kimi")
25095                .expect("Kimi runtime status is missing")
25096                .mode(),
25097            crate::protocol::ProviderRuntimeMode::RawPassthrough,
25098        );
25099        drop(ready_client);
25100
25101        let receipt = match shared.spawn_from_spec(spec).await {
25102            Ok(receipt) => receipt,
25103            Err(error) => panic!(
25104                "Kimi context-only SpawnSpec rejected: exact_code={:?} message={}",
25105                error.code,
25106                error.message,
25107            ),
25108        };
25109        assert_eq!(receipt.provider, agent("kimi"));
25110        assert_eq!(receipt.context.as_ref(), Some(&context));
25111        assert!(receipt.context_binding_is_valid());
25112        timeout(Duration::from_secs(5), async {
25113            while !proof_path.is_file() {
25114                sleep(Duration::from_millis(10)).await;
25115            }
25116        })
25117        .await
25118        .expect("Kimi context-only fixture did not write its proof");
25119
25120        let proof = std::fs::read_to_string(&proof_path).unwrap();
25121        let lines = proof.lines().collect::<Vec<_>>();
25122        assert_eq!(lines.len(), 7);
25123        assert_eq!(lines[0], "context-only");
25124        assert!(Path::new(lines[1]).is_absolute());
25125        assert_ne!(
25126            std::fs::canonicalize(Path::new(lines[1])).unwrap(),
25127            std::fs::canonicalize(&workspace_root).unwrap(),
25128        );
25129        assert_eq!(
25130            std::fs::canonicalize(Path::new(lines[2])).unwrap(),
25131            std::fs::canonicalize(&workspace_root).unwrap(),
25132        );
25133        assert_eq!(lines[3], expected_context_hash);
25134        assert_eq!(lines[4], "g4a-context-pack-v1");
25135        assert_eq!(lines[5], "codex");
25136        assert_eq!(lines[6], "2");
25137        assert!(shared.handle.snapshot().sessions.iter().any(|session| {
25138            session.instance_id == receipt.session.session.instance_id
25139                && session.agent_id.as_str() == "kimi"
25140        }));
25141
25142        shutdown.request_shutdown().await.unwrap();
25143        timeout(Duration::from_secs(5), node_task)
25144            .await
25145            .unwrap()
25146            .unwrap()
25147            .unwrap();
25148        drop(shutdown);
25149        drop(shared);
25150        std::fs::remove_dir_all(root).unwrap();
25151    }
25152
25153    #[cfg(feature = "fixture")]
25154    #[test]
25155    fn clean_exit_fixture_constructor_is_fixture_scoped_and_path_bounded() {
25156        let root = temporary_workspace_root("clean-exit-constructor");
25157        std::fs::create_dir_all(&root).unwrap();
25158        let config = construction_test_config("clean-exit-constructor")
25159            .with_state_path(root.join("node-state.json"))
25160            .unwrap();
25161        let server = NodeServer::new_clean_exit_fixture(
25162            config,
25163            root.clone(),
25164            root.join("started.marker"),
25165            root.join("release.signal"),
25166        )
25167        .unwrap();
25168        assert_eq!(
25169            server
25170                .shared
25171                .enabled_providers
25172                .iter()
25173                .map(AgentId::as_str)
25174                .collect::<Vec<_>>(),
25175            ["claude"],
25176        );
25177        drop(server);
25178        std::fs::remove_dir_all(root).unwrap();
25179    }
25180
25181    #[test]
25182    fn production_registry_provider_contract_manifest_is_exact() {
25183        let registry = active_registry().unwrap();
25184        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
25185        assert_eq!(
25186            providers
25187                .iter()
25188                .map(|contract| (contract.provider.as_str(), contract.revision.as_str()))
25189                .collect::<Vec<_>>(),
25190            vec![
25191                ("claude", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25192                ("codex", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25193                ("grok", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25194                ("kimi", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25195            ]
25196        );
25197        assert_eq!(
25198            adapters
25199                .iter()
25200                .map(|contract| (
25201                    contract.provider.as_str(),
25202                    contract.family,
25203                    contract.adapter_id.as_str(),
25204                    contract.revision.as_str(),
25205                ))
25206                .collect::<Vec<_>>(),
25207            vec![
25208                ("claude", AdapterFamily::PtySemantic, "claude-code", "gate4agent-adapter/v1"),
25209                ("claude", AdapterFamily::Pipe, "claude-code", "gate4agent-adapter/v1"),
25210                ("claude", AdapterFamily::Acp, "claude-code", "gate4agent-adapter/v1"),
25211                ("claude", AdapterFamily::OneShot, "claude", "gate4agent-inline/claude-code-2.1/v1"),
25212                ("claude", AdapterFamily::History, "claude-code", "gate4agent-adapter/v1"),
25213                ("claude", AdapterFamily::Resume, "claude-code", "gate4agent-adapter/v1"),
25214                ("claude", AdapterFamily::SessionOptions, "claude-code", "gate4agent-session-options/orca-d8629c4/v1"),
25215                ("codex", AdapterFamily::PtySemantic, "codex", "gate4agent-adapter/v1"),
25216                ("codex", AdapterFamily::Pipe, "codex", "gate4agent-adapter/v1"),
25217                ("codex", AdapterFamily::Acp, "codex", "gate4agent-adapter/v1"),
25218                ("codex", AdapterFamily::OneShot, "codex", "gate4agent-inline/codex-cli-0.144/v1"),
25219                ("codex", AdapterFamily::History, "codex", "gate4agent-adapter/v1"),
25220                ("codex", AdapterFamily::Resume, "codex", "gate4agent-adapter/v1"),
25221                ("codex", AdapterFamily::SessionOptions, "codex", "gate4agent-session-options/orca-d8629c4/v1"),
25222                ("grok", AdapterFamily::Acp, "grok", "gate4agent-adapter/v1"),
25223                ("grok", AdapterFamily::History, "grok", "gate4agent-adapter/v1"),
25224                ("grok", AdapterFamily::Resume, "grok", "gate4agent-adapter/v1"),
25225                ("kimi", AdapterFamily::PtySemantic, "kimi", "gate4agent-adapter/v1"),
25226                ("kimi", AdapterFamily::Pipe, "kimi", "gate4agent-adapter/v1"),
25227                ("kimi", AdapterFamily::Acp, "kimi", "gate4agent-adapter/v1"),
25228                ("kimi", AdapterFamily::OneShot, "kimi", "gate4agent-inline/kimi-code-0.31/v1"),
25229                ("kimi", AdapterFamily::History, "kimi", "gate4agent-adapter/v1"),
25230                ("kimi", AdapterFamily::Resume, "kimi", "gate4agent-adapter/v1"),
25231            ]
25232        );
25233    }
25234
25235    fn construction_test_config(name: &str) -> NodeServerConfig {
25236        let workspace = WorkspaceConfig::new(
25237            WorkspaceId::new("primary").unwrap(),
25238            std::env::current_dir().unwrap(),
25239        )
25240        .unwrap();
25241        NodeServerConfig::new(
25242            format!(r"\\.\pipe\gate4agent-{name}"),
25243            "fixture-token",
25244            NodeId::new(name).unwrap(),
25245            [workspace],
25246        )
25247        .unwrap()
25248    }
25249
25250    fn maximum_size_provider_manifest_registry() -> AgentRegistry {
25251        let adapter = AdapterBinding::new(
25252            gate4agent_types::AdapterId::new("a".repeat(96)).unwrap(),
25253            "r".repeat(crate::protocol::MAX_ADAPTER_CONTRACT_REVISION_BYTES),
25254            gate4agent_types::AdapterVerification::SyntheticFixture,
25255        )
25256        .unwrap();
25257        let agent_ids = ["claude", "codex", "kimi"]
25258            .into_iter()
25259            .map(|agent_id| AgentId::new(agent_id).unwrap())
25260            .collect::<Vec<_>>();
25261        let adapters = gate4agent_catalog::AdapterRegistry::new(
25262            [
25263                AdapterFamily::PtySemantic,
25264                AdapterFamily::Pipe,
25265                AdapterFamily::OneShot,
25266                AdapterFamily::Acp,
25267                AdapterFamily::Hook,
25268                AdapterFamily::ManagedHook,
25269                AdapterFamily::History,
25270                AdapterFamily::Resume,
25271                AdapterFamily::SessionOptions,
25272                AdapterFamily::CapabilityProbe,
25273            ]
25274            .into_iter()
25275            .map(|family| gate4agent_catalog::AdapterDescriptor {
25276                family,
25277                binding: adapter.clone(),
25278                agents: agent_ids.clone(),
25279            }),
25280        )
25281        .unwrap();
25282        let specs = agent_ids
25283            .iter()
25284            .map(|agent_id| {
25285                let agent_id = agent_id.as_str();
25286                let mut spec = builtin_registry().get_by_id(agent_id).unwrap().clone();
25287                spec.revision =
25288                    "r".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES);
25289                spec.capabilities.transports.pty_adapter = Some(adapter.clone());
25290                spec.capabilities.transports.pipe = Some(gate4agent_types::PipeTransportSpec {
25291                    adapter: adapter.clone(),
25292                    protocol: gate4agent_types::PipeProtocol::SemanticNdjson,
25293                    launch_override: None,
25294                    prompt_delivery: gate4agent_types::PipePromptDelivery::None,
25295                });
25296                spec.capabilities.transports.acp = Some(gate4agent_types::AcpTransportSpec {
25297                    adapter: adapter.clone(),
25298                    launch_override: None,
25299                });
25300                spec.capabilities.adapters.hook = Some(adapter.clone());
25301                spec.capabilities.adapters.managed_hook = Some(adapter.clone());
25302                spec.capabilities.adapters.one_shot = Some(adapter.clone());
25303                spec.capabilities.adapters.history = Some(adapter.clone());
25304                spec.capabilities.adapters.resume = Some(adapter.clone());
25305                spec.capabilities.adapters.session_options = Some(adapter.clone());
25306                spec.capabilities.adapters.capability_probe = Some(adapter.clone());
25307                spec
25308            })
25309            .collect::<Vec<_>>();
25310        AgentRegistry::new_with_adapters(specs, &adapters).unwrap()
25311    }
25312
25313    /// The real fleet's manifest is accepted by a real server construction.
25314    ///
25315    /// The count is asserted against `active_registry` itself rather than a
25316    /// literal, so this stays a test that construction succeeds for whatever
25317    /// the fleet is -- which is the property worth guarding -- instead of a
25318    /// second place the fleet size is written down and has to be kept in
25319    /// step with the first.
25320    #[test]
25321    fn node_server_construction_accepts_the_production_manifest() {
25322        let registry = active_registry().unwrap();
25323        let (providers, _) = provider_contract_manifest(&registry).unwrap();
25324        assert_eq!(providers.len(), registry.iter().count());
25325        assert!(!providers.is_empty());
25326        let server = NodeServer::new_with_registry(
25327            construction_test_config("manifest-production-bound"),
25328            registry,
25329        )
25330        .unwrap();
25331        drop(server);
25332    }
25333
25334    #[test]
25335    fn node_server_construction_rejects_count_valid_manifest_over_handshake_capacity() {
25336        let registry = maximum_size_provider_manifest_registry();
25337        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
25338        assert_eq!(providers.len(), 3);
25339        assert_eq!(adapters.len(), 30);
25340        assert!(adapters.len() <= crate::protocol::MAX_PROVIDER_ADAPTER_CONTRACTS);
25341        let error = match NodeServer::new_with_registry(
25342            construction_test_config("manifest-handshake-overflow"),
25343            registry,
25344        ) {
25345            Ok(_) => panic!("oversized negotiated manifest was accepted"),
25346            Err(error) => error,
25347        };
25348        assert!(matches!(
25349            error,
25350            NodeServerError::ProviderContractManifest(message)
25351                if message.contains("authentication binding length")
25352                    && message.contains("8192-byte limit")
25353        ));
25354    }
25355
25356    #[test]
25357    fn provider_contract_manifest_rejects_wire_revision_overflow_before_startup() {
25358        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
25359        spec.revision = "x".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES + 1);
25360        let registry = AgentRegistry::new([spec]).unwrap();
25361        assert!(matches!(
25362            provider_contract_manifest(&registry),
25363            Err(NodeServerError::ProviderContractManifest(message))
25364                if message.contains("exceeds the 128-byte limit")
25365        ));
25366    }
25367
25368    #[test]
25369    fn provider_contract_manifest_rejects_invalid_wire_revision_before_startup() {
25370        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
25371        spec.revision = "invalid revision".to_owned();
25372        let registry = AgentRegistry::new([spec]).unwrap();
25373        assert!(matches!(
25374            provider_contract_manifest(&registry),
25375            Err(NodeServerError::ProviderContractManifest(message))
25376                if message.contains("bounded lowercase ASCII")
25377        ));
25378    }
25379
25380    #[test]
25381    fn provider_contract_manifest_rejects_conflicting_duplicate_family_before_startup() {
25382        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
25383        let transport_binding = spec.capabilities.adapters.one_shot.clone().unwrap();
25384        let conflicting_binding = builtin_registry()
25385            .get_by_id("codex")
25386            .unwrap()
25387            .capabilities
25388            .adapters
25389            .one_shot
25390            .clone()
25391            .unwrap();
25392        let pipe = spec.capabilities.transports.pipe.as_mut().unwrap();
25393        pipe.protocol = gate4agent_types::PipeProtocol::OneShotText;
25394        pipe.adapter = transport_binding;
25395        spec.capabilities.adapters.one_shot = Some(conflicting_binding);
25396        let registry = AgentRegistry::new([spec]).unwrap();
25397        assert!(matches!(
25398            provider_contract_manifest(&registry),
25399            Err(NodeServerError::ProviderContractManifest(message))
25400                if message.contains("conflicting OneShot adapter bindings")
25401        ));
25402    }
25403
25404    #[test]
25405    fn public_node_failures_expose_only_fixed_categories() {
25406        let provider = failure(
25407            NodeFailureCode::BackendOperationFailed,
25408            r"provider bearer-secret failed at C:\private\session.jsonl",
25409        );
25410        assert_eq!(provider.message, "backend-operation-failed");
25411        assert!(!provider.message.contains("bearer-secret"));
25412        assert!(!provider.message.contains(r"C:\private"));
25413        let persistence = persistence_failure(io::Error::new(
25414            io::ErrorKind::Other,
25415            r"state write failed at C:\private\state-v1.json",
25416        ));
25417        assert_eq!(persistence.message, DURABLE_STATE_COMMIT_FAILED_ERROR);
25418        assert!(!persistence.message.contains(r"C:\private"));
25419
25420        for kind in [io::ErrorKind::PermissionDenied, io::ErrorKind::Unsupported] {
25421            let generic = durable_state_load_error(io::Error::new(kind, "host I/O failure"));
25422            assert_eq!(
25423                generic.to_string(),
25424                "node durable state failed: durable-state-load-failed",
25425            );
25426        }
25427    }
25428
25429    #[test]
25430    fn terminal_byte_sequences_are_bounded_before_dispatch() {
25431        let sequence = b"\x1b[1;5D".to_vec();
25432        assert_eq!(
25433            terminal_bytes_action(sequence.clone()).unwrap(),
25434            InputAction::TerminalBytes(sequence),
25435        );
25436        assert_eq!(
25437            terminal_bytes_action(Vec::new()).unwrap_err().code,
25438            NodeFailureCode::InvalidRequest,
25439        );
25440        assert_eq!(
25441            terminal_bytes_action(vec![0; MAX_NODE_TERMINAL_BYTES + 1])
25442                .unwrap_err()
25443                .code,
25444            NodeFailureCode::InvalidRequest,
25445        );
25446    }
25447
25448    #[test]
25449    fn node_server_rejects_a_second_owner_of_the_same_durable_state_path() {
25450        let root = temporary_workspace_root("exclusive-state-owner");
25451        std::fs::create_dir_all(&root).unwrap();
25452        let workspace = WorkspaceConfig::new(
25453            WorkspaceId::new("primary").unwrap(),
25454            &root,
25455        )
25456        .unwrap();
25457        let state_path = root.join("state-v1.json");
25458        let endpoint = format!(
25459            r"\\.\pipe\gate4agent-node-state-lock-{}-{}",
25460            std::process::id(),
25461            std::time::SystemTime::now()
25462                .duration_since(std::time::UNIX_EPOCH)
25463                .unwrap()
25464                .as_nanos(),
25465        );
25466        let config = NodeServerConfig::new(
25467            endpoint,
25468            "fixture-token",
25469            NodeId::new("node-1").unwrap(),
25470            [workspace],
25471        )
25472        .unwrap()
25473        .with_state_path(&state_path)
25474        .unwrap();
25475
25476        let first = NodeServer::new(config.clone()).unwrap();
25477        let error = match NodeServer::new(config.clone()) {
25478            Ok(_) => panic!("a second node server acquired the same durable state path"),
25479            Err(error) => error,
25480        };
25481        assert!(matches!(
25482            &error,
25483            NodeServerError::DurableState(error)
25484                if error.kind() == io::ErrorKind::WouldBlock
25485        ));
25486        assert_eq!(
25487            error.to_string(),
25488            "node durable state failed: durable-state-lock-failed",
25489        );
25490        assert!(!error.to_string().contains(&state_path.to_string_lossy().into_owned()));
25491        drop(first);
25492        let second = NodeServer::new(config).unwrap();
25493        drop(second);
25494        std::fs::remove_dir_all(root).unwrap();
25495    }
25496
25497    #[test]
25498    fn session_binding_is_workspace_scoped_and_removed_fail_closed() {
25499        let catalog = active_registry().unwrap();
25500        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25501        let workspace_id = WorkspaceId::new("primary").unwrap();
25502        let workspace = WorkspaceConfig::new(
25503            workspace_id.clone(),
25504            std::env::current_dir().unwrap(),
25505        )
25506        .unwrap();
25507        let shared = NodeShared::new(
25508            handle,
25509            "fixture-token".to_owned(),
25510            NodeId::new("node-1").unwrap(),
25511            vec![workspace.clone()],
25512            vec![agent("claude"), agent("codex"), agent("kimi")],
25513        );
25514        let instance_id = AgentInstanceId(17);
25515        let generation = SessionGeneration(3);
25516        assert!(shared.address_for(instance_id, generation).is_none());
25517        shared.bind_session(instance_id, workspace_id.clone(), generation);
25518        let address = SessionAddress {
25519            workspace_id,
25520            session: SessionKey { instance_id, generation },
25521        };
25522        assert_eq!(
25523            shared.workspace_root(&address.workspace_id).unwrap(),
25524            workspace.canonical_root,
25525        );
25526        assert_eq!(shared.address_for(instance_id, generation), Some(address.clone()));
25527        shared.remove_binding(&address);
25528        assert!(shared.address_for(instance_id, generation).is_none());
25529    }
25530
25531    #[test]
25532    fn open_provider_request_gate_is_state_aware_and_fail_closed() {
25533        let address = SessionAddress {
25534            workspace_id: WorkspaceId::new("primary").unwrap(),
25535            session: SessionKey {
25536                instance_id: AgentInstanceId(7),
25537                generation: SessionGeneration(2),
25538            },
25539        };
25540        let record_id = SessionRecordId::new("record-a").unwrap();
25541        let size = gate4agent_types::TerminalSize {
25542            rows: 24,
25543            columns: 80,
25544        };
25545        let address_requests = [
25546            NodeRequest::Resume {
25547                session: address.clone(),
25548                terminal_size: size,
25549                initial_prompt: None,
25550            },
25551            NodeRequest::Prompt { session: address.clone(), text: "p".to_owned() },
25552            NodeRequest::Paste { session: address.clone(), text: "p".to_owned() },
25553            NodeRequest::Input { session: address.clone(), text: "p".to_owned() },
25554            NodeRequest::TerminalBytes { session: address.clone(), bytes: vec![1] },
25555            NodeRequest::TerminalControl {
25556                session: address.clone(),
25557                control: TerminalControl::Enter,
25558            },
25559            NodeRequest::Resize { session: address.clone(), size },
25560            NodeRequest::Interrupt { session: address.clone() },
25561            NodeRequest::Stop { session: address.clone(), force: false },
25562            NodeRequest::Remove { session: address },
25563        ];
25564        for request in &address_requests {
25565            assert!(request_requires_open_provider_ids_with(
25566                request,
25567                |_| Some(agent("third-party-agent")),
25568                |_| Some(agent("claude")),
25569            ));
25570            assert!(!request_requires_open_provider_ids_with(
25571                request,
25572                |_| Some(agent("claude")),
25573                |_| Some(agent("claude")),
25574            ));
25575            assert!(request_requires_open_provider_ids_with(
25576                request,
25577                |_| None,
25578                |_| Some(agent("claude")),
25579            ));
25580        }
25581
25582        let record_requests = [
25583            NodeRequest::RenameSessionRecord {
25584                record_id: record_id.clone(),
25585                display_name: "renamed".to_owned(),
25586            },
25587            NodeRequest::ResumeSessionRecord {
25588                record_id: record_id.clone(),
25589                terminal_size: size,
25590                initial_prompt: None,
25591            },
25592            NodeRequest::ForgetSessionRecord { record_id },
25593        ];
25594        for request in &record_requests {
25595            assert!(request_requires_open_provider_ids_with(
25596                request,
25597                |_| Some(agent("claude")),
25598                |_| Some(agent("grok")),
25599            ));
25600            assert!(!request_requires_open_provider_ids_with(
25601                request,
25602                |_| Some(agent("claude")),
25603                |_| Some(agent("kimi")),
25604            ));
25605            assert!(request_requires_open_provider_ids_with(
25606                request,
25607                |_| Some(agent("claude")),
25608                |_| None,
25609            ));
25610        }
25611
25612        assert!(request_requires_open_provider_ids_with(
25613            &NodeRequest::Spawn {
25614                workspace_id: WorkspaceId::new("primary").unwrap(),
25615                provider: agent("third-party-agent"),
25616                mode: SessionMode::Pty,
25617                terminal_size: size,
25618                initial_prompt: None,
25619            },
25620            |_| None,
25621            |_| None,
25622        ));
25623        assert!(!request_requires_open_provider_ids_with(
25624            &NodeRequest::Snapshot,
25625            |_| None,
25626            |_| None,
25627        ));
25628        assert!(request_requires_open_provider_ids_with(
25629            &NodeRequest::ForgetContextPack {
25630                context_id: SpawnContextId::new("ctx-forget").unwrap(),
25631            },
25632            |_| None,
25633            |_| None,
25634        ));
25635    }
25636
25637    #[test]
25638    fn legacy_projection_filters_open_record_replies_and_event_history() {
25639        let catalog = active_registry().unwrap();
25640        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25641        let workspace = WorkspaceConfig::new(
25642            WorkspaceId::new("primary").unwrap(),
25643            std::env::current_dir().unwrap(),
25644        )
25645        .unwrap();
25646        let shared = NodeShared::new(
25647            handle,
25648            "fixture-token".to_owned(),
25649            NodeId::new("node-1").unwrap(),
25650            vec![workspace],
25651            vec![agent("claude"), agent("grok")],
25652        );
25653        let legacy = record("claude", "legacy-record");
25654        let open = record("grok", "open-record");
25655        let legacy_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
25656            record: legacy.clone(),
25657        });
25658        let open_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
25659            record: open.clone(),
25660        });
25661        let legacy_removed = shared.publish(NodeEvent::SessionRecordRemoved {
25662            record_id: legacy.record_id.clone(),
25663        });
25664        let open_removed = shared.publish(NodeEvent::SessionRecordRemoved {
25665            record_id: open.record_id.clone(),
25666        });
25667        assert!(project_event_legacy_provider_ids(&shared, legacy_upsert.clone()).is_some());
25668        assert!(project_event_legacy_provider_ids(&shared, open_upsert.clone()).is_none());
25669        assert!(project_event_legacy_provider_ids(&shared, legacy_removed.clone()).is_some());
25670        assert!(project_event_legacy_provider_ids(&shared, open_removed.clone()).is_none());
25671
25672        let mut record_reply = ResponseEnvelope {
25673            request_id: 1,
25674            result: Ok(NodeResponse::SessionRecordUpdated { record: open }),
25675        };
25676        project_response_legacy_provider_ids(&shared, &mut record_reply);
25677        assert!(matches!(
25678            record_reply.result,
25679            Err(NodeFailure { code: NodeFailureCode::UnsupportedCapability, .. })
25680        ));
25681
25682        let mut resync = ResponseEnvelope {
25683            request_id: 2,
25684            result: Ok(NodeResponse::Resync {
25685                event_sequence: open_removed.sequence,
25686                oldest_available_sequence: 1,
25687                snapshot: shared.snapshot(),
25688                events: vec![legacy_upsert, open_upsert, legacy_removed, open_removed],
25689            }),
25690        };
25691        project_response_legacy_provider_ids(&shared, &mut resync);
25692        let Ok(NodeResponse::Resync { snapshot, events, .. }) = resync.result else {
25693            panic!("legacy projection changed the resync response kind");
25694        };
25695        assert!(snapshot.session_records.iter().all(|item| {
25696            provider_id_is_legacy(&item.provider)
25697        }));
25698        assert_eq!(events.len(), 2);
25699        assert!(events.iter().all(|event| matches!(
25700            &event.event,
25701            NodeEvent::SessionRecordUpserted { record }
25702                if provider_id_is_legacy(&record.provider)
25703        ) || matches!(&event.event, NodeEvent::SessionRecordRemoved { .. })));
25704    }
25705
25706    #[test]
25707    fn session_binding_capacity_is_bounded_by_the_control_session_limit() {
25708        let catalog = active_registry().unwrap();
25709        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25710        let workspace_id = WorkspaceId::new("primary").unwrap();
25711        let workspace = WorkspaceConfig::new(
25712            workspace_id.clone(),
25713            std::env::current_dir().unwrap(),
25714        )
25715        .unwrap();
25716        let shared = NodeShared::new(
25717            handle,
25718            "fixture-token".to_owned(),
25719            NodeId::new("node-1").unwrap(),
25720            vec![workspace],
25721            vec![agent("claude")],
25722        );
25723        for index in 0..CONTROL_SESSIONS_MAX {
25724            shared.bind_session(
25725                AgentInstanceId((index + 1) as u64),
25726                workspace_id.clone(),
25727                SessionGeneration(1),
25728            );
25729        }
25730        let error = shared.ensure_binding_capacity().unwrap_err();
25731        assert_eq!(error.code, NodeFailureCode::BackendBusy);
25732    }
25733
25734    #[tokio::test]
25735    async fn provider_spawn_admission_rejects_before_session_mutation() {
25736        let catalog = active_registry().unwrap();
25737        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25738        let workspace = WorkspaceConfig::new(
25739            WorkspaceId::new("primary").unwrap(),
25740            std::env::current_dir().unwrap(),
25741        )
25742        .unwrap();
25743        let mut shared = NodeShared::new(
25744            handle,
25745            "fixture-token".to_owned(),
25746            NodeId::new("node-provider-admission").unwrap(),
25747            vec![workspace],
25748            vec![agent("claude")],
25749        );
25750        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25751            crate::protocol::ProviderRuntimeStatus::unavailable(agent("claude")),
25752        ])
25753        .unwrap();
25754        let terminal_size = gate4agent_types::TerminalSize {
25755            rows: 24,
25756            columns: 80,
25757        };
25758
25759        let unavailable = shared
25760            .spawn_session(
25761                WorkspaceId::new("primary").unwrap(),
25762                agent("claude"),
25763                SessionMode::Pty,
25764                terminal_size,
25765                None,
25766            )
25767            .await
25768            .unwrap_err();
25769        assert_eq!(unavailable.code, NodeFailureCode::BackendOperationFailed);
25770        assert_eq!(unavailable.message, "backend-operation-failed");
25771
25772        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25773            crate::protocol::ProviderRuntimeStatus::raw_passthrough(
25774                agent("claude"),
25775                Some(crate::protocol::ProviderRuntimeVersion::new("999.0.0").unwrap()),
25776            ),
25777        ])
25778        .unwrap();
25779        let semantic = shared
25780            .spawn_session(
25781                WorkspaceId::new("primary").unwrap(),
25782                agent("claude"),
25783                SessionMode::Inline,
25784                terminal_size,
25785                None,
25786            )
25787            .await
25788            .unwrap_err();
25789        assert_eq!(semantic.code, NodeFailureCode::UnsupportedCapability);
25790        assert_eq!(semantic.message, "unsupported-capability");
25791
25792        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
25793        assert!(shared
25794            .session_bindings
25795            .lock()
25796            .unwrap_or_else(|poisoned| poisoned.into_inner())
25797            .is_empty());
25798        assert!(shared
25799            .session_records
25800            .lock()
25801            .unwrap_or_else(|poisoned| poisoned.into_inner())
25802            .records
25803            .is_empty());
25804        assert!(shared.handle.snapshot().sessions.is_empty());
25805    }
25806
25807    #[tokio::test]
25808    async fn raw_spawn_binding_is_visible_with_a_durable_live_record() {
25809        let catalog = active_registry().unwrap();
25810        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25811        let workspace_id = WorkspaceId::new("primary").unwrap();
25812        let workspace = WorkspaceConfig::new(
25813            workspace_id.clone(),
25814            std::env::current_dir().unwrap(),
25815        )
25816        .unwrap();
25817        let shared = NodeShared::new(
25818            handle,
25819            "fixture-token".to_owned(),
25820            NodeId::new("node-raw-spawn-record").unwrap(),
25821            vec![workspace],
25822            vec![agent("claude")],
25823        );
25824        let address = SessionAddress {
25825            workspace_id,
25826            session: SessionKey {
25827                instance_id: AgentInstanceId(71),
25828                generation: SessionGeneration::default(),
25829            },
25830        };
25831
25832        let record_id = shared
25833            .bind_spawn_session(
25834                &address,
25835                agent("claude"),
25836                SessionMode::Pty,
25837                ProviderRuntimePolicy::raw_pty(),
25838                SpawnRecordPolicy::Always,
25839                None,
25840            )
25841            .unwrap()
25842            .unwrap();
25843        shared
25844            .handle
25845            .dispatch(shared.prepare_command(ControlCommand::Register {
25846                instance_id: address.session.instance_id,
25847                agent_id: agent("claude"),
25848                transport: TransportKind::Pty,
25849            }))
25850            .unwrap();
25851        runtime.tick().await;
25852
25853        assert!(shared.handle.snapshot().sessions.iter().any(|session| {
25854            session.instance_id == address.session.instance_id
25855                && session.generation == address.session.generation
25856        }));
25857        let record = shared.record(&record_id).unwrap();
25858        assert_eq!(record.display_name, "claude #1");
25859        assert_eq!(record.state, ManagedSessionState::Live);
25860        assert_eq!(record.active_session.as_ref(), Some(&address));
25861        assert!(record.provider_session.is_none());
25862        let binding_record_id = shared
25863            .session_bindings
25864            .lock()
25865            .unwrap_or_else(|poisoned| poisoned.into_inner())
25866            .get(&address.session.instance_id)
25867            .and_then(|binding| binding.record_id.clone());
25868        assert_eq!(binding_record_id.as_ref(), Some(&record_id));
25869
25870        let renamed = shared
25871            .rename_session_record(&record_id, "raw review".to_owned())
25872            .unwrap();
25873        assert_eq!(renamed.record_id, record_id);
25874        assert_eq!(renamed.display_name, "raw review");
25875        shared.reconcile_managed_record(
25876            &record_id,
25877            &address,
25878            &ControlEvent {
25879                sequence: 1,
25880                command_id: None,
25881                instance_id: address.session.instance_id,
25882                generation: address.session.generation,
25883                event: ControlEventKind::Running { process_id: None },
25884            },
25885            false,
25886            false,
25887        );
25888        let running = shared.record(&record_id).unwrap();
25889        assert_eq!(running.state, ManagedSessionState::Live);
25890        assert_eq!(running.active_session.as_ref(), Some(&address));
25891        assert_eq!(running.display_name, "raw review");
25892
25893        shared.reconcile_managed_record(
25894            &record_id,
25895            &address,
25896            &ControlEvent {
25897                sequence: 2,
25898                command_id: None,
25899                instance_id: address.session.instance_id,
25900                generation: address.session.generation,
25901                event: ControlEventKind::Exited {
25902                    exit_code: Some(0),
25903                    forced: false,
25904                },
25905            },
25906            false,
25907            false,
25908        );
25909        let detached = shared.record(&record_id).unwrap();
25910        assert_eq!(detached.state, ManagedSessionState::Unavailable);
25911        assert!(detached.active_session.is_none());
25912        assert_eq!(detached.display_name, "raw review");
25913        let renamed_detached = shared
25914            .rename_session_record(&record_id, "raw review detached".to_owned())
25915            .unwrap();
25916        assert_eq!(renamed_detached.record_id, record_id);
25917        assert_eq!(renamed_detached.state, ManagedSessionState::Unavailable);
25918        assert!(renamed_detached.active_session.is_none());
25919        assert_eq!(renamed_detached.display_name, "raw review detached");
25920        assert_eq!(shared.snapshot().session_records, vec![renamed_detached]);
25921    }
25922
25923    #[test]
25924    fn legacy_raw_spawn_binding_remains_untracked() {
25925        let catalog = active_registry().unwrap();
25926        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25927        let workspace_id = WorkspaceId::new("primary").unwrap();
25928        let workspace = WorkspaceConfig::new(
25929            workspace_id.clone(),
25930            std::env::current_dir().unwrap(),
25931        )
25932        .unwrap();
25933        let shared = NodeShared::new(
25934            handle,
25935            "fixture-token".to_owned(),
25936            NodeId::new("node-legacy-raw-spawn").unwrap(),
25937            vec![workspace],
25938            vec![agent("claude")],
25939        );
25940        let address = SessionAddress {
25941            workspace_id,
25942            session: SessionKey {
25943                instance_id: AgentInstanceId(72),
25944                generation: SessionGeneration::default(),
25945            },
25946        };
25947
25948        let record_id = shared
25949            .bind_spawn_session(
25950                &address,
25951                agent("claude"),
25952                SessionMode::Pty,
25953                ProviderRuntimePolicy::raw_pty(),
25954                SpawnRecordPolicy::ProviderIdentityOnly,
25955                None,
25956            )
25957            .unwrap();
25958
25959        assert!(record_id.is_none());
25960        let binding_record_id = shared
25961            .session_bindings
25962            .lock()
25963            .unwrap_or_else(|poisoned| poisoned.into_inner())
25964            .get(&address.session.instance_id)
25965            .and_then(|binding| binding.record_id.clone());
25966        assert!(binding_record_id.is_none());
25967        assert!(shared.snapshot().session_records.is_empty());
25968    }
25969
25970    #[tokio::test]
25971    async fn raw_runtime_policy_rejects_prompt_paste_and_resume_before_mutation() {
25972        let catalog = active_registry().unwrap();
25973        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25974        let workspace_id = WorkspaceId::new("primary").unwrap();
25975        let workspace = WorkspaceConfig::new(
25976            workspace_id.clone(),
25977            std::env::current_dir().unwrap(),
25978        )
25979        .unwrap();
25980        let mut shared = NodeShared::new(
25981            handle,
25982            "fixture-token".to_owned(),
25983            NodeId::new("node-raw-semantic-gate").unwrap(),
25984            vec![workspace],
25985            vec![agent("claude")],
25986        );
25987        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25988            crate::protocol::ProviderRuntimeStatus::raw_passthrough(agent("claude"), None),
25989        ])
25990        .unwrap();
25991        shared
25992            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25993            .unwrap();
25994        let address = SessionAddress {
25995            workspace_id,
25996            session: SessionKey {
25997                instance_id: AgentInstanceId(72),
25998                generation: SessionGeneration::default(),
25999            },
26000        };
26001        shared.bind_session_with_policy(
26002            &address,
26003            ProviderRuntimePolicy::raw_pty(),
26004            None,
26005        );
26006        shared
26007            .handle
26008            .dispatch(shared.prepare_command(ControlCommand::Register {
26009                instance_id: address.session.instance_id,
26010                agent_id: agent("claude"),
26011                transport: TransportKind::Pty,
26012            }))
26013            .unwrap();
26014        runtime.tick().await;
26015        let command_id_before = shared.next_command_id.load(Ordering::Acquire);
26016        let revision_before = shared.handle.snapshot().revision;
26017        let history_sequence_before = shared.current_sequence();
26018        let terminal_size = gate4agent_types::TerminalSize {
26019            rows: 24,
26020            columns: 80,
26021        };
26022
26023        for request in [
26024            NodeRequest::Prompt {
26025                session: address.clone(),
26026                text: "prompt".to_owned(),
26027            },
26028            NodeRequest::Paste {
26029                session: address.clone(),
26030                text: "paste".to_owned(),
26031            },
26032            // `initial_prompt: Some(_)` selects `ResumeWithPrompt`, which needs
26033            // the same semantic capabilities as `Prompt`/`Paste`. A bare
26034            // (promptless) resume is a raw PTY relaunch and IS admitted under
26035            // `raw_pty()` — see `raw_pty_admits_provider_native_resume_without_prompt_only`
26036            // in `provider_runtime.rs`; it is deliberately not exercised here.
26037            NodeRequest::Resume {
26038                session: address.clone(),
26039                terminal_size,
26040                initial_prompt: Some("resume".to_owned()),
26041            },
26042        ] {
26043            let error = process_request_inner(
26044                &shared,
26045                77,
26046                ClientRole::Operator,
26047                request,
26048            )
26049            .await
26050            .unwrap_err();
26051            assert_eq!(error.code, NodeFailureCode::UnsupportedCapability);
26052        }
26053
26054        assert_eq!(shared.next_command_id.load(Ordering::Acquire), command_id_before);
26055        assert_eq!(shared.handle.snapshot().revision, revision_before);
26056        assert_eq!(shared.current_sequence(), history_sequence_before);
26057        let bindings = shared
26058            .session_bindings
26059            .lock()
26060            .unwrap_or_else(|poisoned| poisoned.into_inner());
26061        let binding = bindings.get(&address.session.instance_id).unwrap();
26062        assert_eq!(binding.generation, address.session.generation);
26063        assert!(binding.pending_resume.is_none());
26064        assert!(binding.record_id.is_none());
26065    }
26066
26067    #[tokio::test]
26068    async fn windows_rejects_unix_bytes_workspace_path_before_filesystem_access() {
26069        let catalog = active_registry().unwrap();
26070        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26071        let primary = WorkspaceConfig::new(
26072            WorkspaceId::new("primary").unwrap(),
26073            std::env::current_dir().unwrap(),
26074        )
26075        .unwrap();
26076        let shared = NodeShared::new(
26077            handle,
26078            "fixture-token".to_owned(),
26079            NodeId::new("node-1").unwrap(),
26080            vec![primary],
26081            vec![agent("claude")],
26082        );
26083        shared
26084            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
26085            .unwrap();
26086        let before = shared.snapshot();
26087        let error = process_request_inner(
26088            &shared,
26089            77,
26090            ClientRole::Operator,
26091            NodeRequest::RegisterWorkspace {
26092                workspace_id: WorkspaceId::new("foreign").unwrap(),
26093                root: OpaqueHostPath::unix_bytes(b"/srv/repo".to_vec()).unwrap(),
26094            },
26095        )
26096        .await
26097        .unwrap_err();
26098        assert_eq!(error.code, NodeFailureCode::InvalidRequest);
26099        assert_eq!(error.message, "invalid-request");
26100        assert_eq!(shared.snapshot().workspaces, before.workspaces);
26101    }
26102
26103    #[tokio::test]
26104    async fn dynamic_workspaces_reject_duplicates_busy_and_last_then_publish_ordered_events() {
26105        let catalog = active_registry().unwrap();
26106        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26107        let primary_id = WorkspaceId::new("primary").unwrap();
26108        let primary = WorkspaceConfig::new(
26109            primary_id.clone(),
26110            std::env::current_dir().unwrap(),
26111        )
26112        .unwrap();
26113        let shared = NodeShared::new(
26114            handle,
26115            "fixture-token".to_owned(),
26116            NodeId::new("node-1").unwrap(),
26117            vec![primary.clone()],
26118            vec![agent("claude")],
26119        );
26120        let secondary_id = WorkspaceId::new("secondary").unwrap();
26121        let secondary = shared
26122            .register_workspace(
26123                secondary_id.clone(),
26124                std::env::temp_dir().to_string_lossy().into_owned(),
26125            )
26126            .await
26127            .unwrap();
26128        assert_eq!(secondary.workspace_id, secondary_id);
26129        assert!(secondary.sessions.is_empty());
26130        assert_eq!(shared.snapshot().workspaces.len(), 2);
26131
26132        let duplicate_id = shared
26133            .register_workspace(
26134                secondary_id.clone(),
26135                primary.canonical_root().to_owned(),
26136            )
26137            .await
26138            .unwrap_err();
26139        assert_eq!(duplicate_id.code, NodeFailureCode::DuplicateWorkspaceId);
26140        let duplicate_root = shared
26141            .register_workspace(
26142                WorkspaceId::new("duplicate-root").unwrap(),
26143                windows_path_text(&secondary.canonical_root).to_owned(),
26144            )
26145            .await
26146            .unwrap_err();
26147        assert_eq!(duplicate_root.code, NodeFailureCode::DuplicateWorkspaceRoot);
26148
26149        let address = SessionAddress {
26150            workspace_id: secondary_id.clone(),
26151            session: SessionKey {
26152                instance_id: AgentInstanceId(41),
26153                generation: SessionGeneration(1),
26154            },
26155        };
26156        shared.bind_session(
26157            address.session.instance_id,
26158            secondary_id.clone(),
26159            address.session.generation,
26160        );
26161        let busy = shared.unregister_workspace(&secondary_id).unwrap_err();
26162        assert_eq!(busy.code, NodeFailureCode::WorkspaceBusy);
26163        shared.remove_binding(&address);
26164        shared.unregister_workspace(&secondary_id).unwrap();
26165        assert_eq!(shared.snapshot().workspaces.len(), 1);
26166
26167        let last = shared.unregister_workspace(&primary_id).unwrap_err();
26168        assert_eq!(last.code, NodeFailureCode::LastWorkspace);
26169        let history = shared
26170            .history
26171            .lock()
26172            .unwrap_or_else(|poisoned| poisoned.into_inner());
26173        assert!(matches!(
26174            &history.events[0],
26175            NodeEventEnvelope {
26176                sequence: 1,
26177                event: NodeEvent::WorkspaceAdded { workspace },
26178            } if workspace.workspace_id == secondary_id
26179        ));
26180        assert!(matches!(
26181            &history.events[1],
26182            NodeEventEnvelope {
26183                sequence: 2,
26184                event: NodeEvent::WorkspaceRemoved { workspace_id },
26185            } if workspace_id == &secondary_id
26186        ));
26187    }
26188
26189    #[tokio::test]
26190    async fn provider_session_reference_index_is_controller_gated_and_idempotent() {
26191        let secondary_root = temporary_workspace_root("provider-session-reference-index");
26192        std::fs::create_dir_all(&secondary_root).unwrap();
26193        let catalog = active_registry().unwrap();
26194        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26195        let primary_id = WorkspaceId::new("primary").unwrap();
26196        let secondary_id = WorkspaceId::new("secondary").unwrap();
26197        let shared = NodeShared::new(
26198            handle,
26199            "fixture-token".to_owned(),
26200            NodeId::new("node-session-reference-index").unwrap(),
26201            vec![
26202                WorkspaceConfig::new(primary_id.clone(), std::env::current_dir().unwrap())
26203                    .unwrap(),
26204                WorkspaceConfig::new(secondary_id.clone(), &secondary_root).unwrap(),
26205            ],
26206            vec![agent("claude"), agent("codex")],
26207        );
26208        let identity = ProviderSessionIdentity {
26209            key: gate4agent_types::ProviderSessionKey::SessionId,
26210            id: "provider-session-42".to_owned(),
26211            transcript_path: None,
26212        };
26213        let index_request = |workspace_id: WorkspaceId,
26214                             provider: AgentId,
26215                             identity: ProviderSessionIdentity,
26216                             display_name: &str| {
26217            NodeRequest::IndexProviderSession {
26218                workspace_id,
26219                provider,
26220                identity,
26221                display_name: display_name.to_owned(),
26222            }
26223        };
26224        let request = index_request(
26225            primary_id.clone(),
26226            agent("claude"),
26227            identity.clone(),
26228            "release shepherd",
26229        );
26230        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
26231            capability.as_str() == NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY
26232        }));
26233        let observer = process_request_inner(
26234            &shared,
26235            77,
26236            ClientRole::Observer,
26237            request.clone(),
26238        )
26239        .await
26240        .unwrap_err();
26241        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26242        let uncontrolled = process_request_inner(
26243            &shared,
26244            77,
26245            ClientRole::Operator,
26246            request.clone(),
26247        )
26248        .await
26249        .unwrap_err();
26250        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
26251        shared
26252            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
26253            .unwrap();
26254
26255        let indexed = process_request_inner(
26256            &shared,
26257            77,
26258            ClientRole::Operator,
26259            request.clone(),
26260        )
26261        .await
26262        .unwrap();
26263        let NodeResponse::ProviderSessionIndexed { record } = indexed else {
26264            panic!("provider session reference index returned another response");
26265        };
26266        assert_eq!(record.state, ManagedSessionState::Dormant);
26267        assert_eq!(record.mode, SessionMode::Pty);
26268        assert_eq!(record.workspace_id, primary_id);
26269        assert_eq!(record.provider_session.as_ref(), Some(&identity));
26270        assert!(record.active_session.is_none());
26271        assert!(record.environment_profile.is_none());
26272        assert!(record.bundle.is_none());
26273        assert!(record.context.is_none());
26274
26275        let repeated = process_request_inner(
26276            &shared,
26277            77,
26278            ClientRole::Operator,
26279            index_request(
26280                primary_id.clone(),
26281                agent("claude"),
26282                identity.clone(),
26283                "ignored replacement",
26284            ),
26285        )
26286        .await
26287        .unwrap();
26288        assert!(matches!(
26289            repeated,
26290            NodeResponse::ProviderSessionIndexed { record: ref existing }
26291                if existing.record_id == record.record_id
26292                    && existing.display_name == "release shepherd"
26293        ));
26294        let conflicting_workspace = process_request_inner(
26295            &shared,
26296            77,
26297            ClientRole::Operator,
26298            index_request(
26299                secondary_id,
26300                agent("claude"),
26301                identity.clone(),
26302                "release shepherd",
26303            ),
26304        )
26305        .await
26306        .unwrap_err();
26307        assert_eq!(conflicting_workspace.code, NodeFailureCode::SessionRecordConflict);
26308        let conflicting_provider = process_request_inner(
26309            &shared,
26310            77,
26311            ClientRole::Operator,
26312            index_request(
26313                primary_id.clone(),
26314                agent("codex"),
26315                identity.clone(),
26316                "release shepherd",
26317            ),
26318        )
26319        .await
26320        .unwrap_err();
26321        assert_eq!(conflicting_provider.code, NodeFailureCode::SessionRecordConflict);
26322        let transcript_rejected = process_request_inner(
26323            &shared,
26324            77,
26325            ClientRole::Operator,
26326            index_request(
26327                primary_id,
26328                agent("claude"),
26329                ProviderSessionIdentity {
26330                    transcript_path: Some("C:/provider/transcript.jsonl".to_owned()),
26331                    ..identity
26332                },
26333                "release shepherd",
26334            ),
26335        )
26336        .await
26337        .unwrap_err();
26338        assert_eq!(transcript_rejected.code, NodeFailureCode::InvalidRequest);
26339        let upserts = shared
26340            .history
26341            .lock()
26342            .unwrap_or_else(|poisoned| poisoned.into_inner())
26343            .events
26344            .iter()
26345            .filter(|event| matches!(
26346                &event.event,
26347                NodeEvent::SessionRecordUpserted { record: published }
26348                    if published.record_id == record.record_id
26349            ))
26350            .count();
26351        assert_eq!(upserts, 1);
26352        std::fs::remove_dir_all(secondary_root).unwrap();
26353    }
26354
26355    #[tokio::test]
26356    async fn standalone_workspace_is_independent_registered_and_controller_gated() {
26357        let fixture_root = temporary_workspace_root("standalone-independent");
26358        let target = fixture_root.join("independent");
26359        std::fs::create_dir_all(&fixture_root).unwrap();
26360        let catalog = active_registry().unwrap();
26361        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26362        let selected = WorkspaceConfig::new(
26363            WorkspaceId::new("primary").unwrap(),
26364            std::env::current_dir().unwrap(),
26365        )
26366        .unwrap();
26367        let selected_root = selected.canonical_root().to_owned();
26368        let shared = NodeShared::new(
26369            handle,
26370            "fixture-token".to_owned(),
26371            NodeId::new("node-standalone").unwrap(),
26372            vec![selected],
26373            vec![agent("claude")],
26374        );
26375        let workspace_id = WorkspaceId::new("independent").unwrap();
26376        let request = NodeRequest::CreateStandaloneWorkspace {
26377            workspace_id: workspace_id.clone(),
26378            root: opaque_windows_path(target.to_string_lossy().into_owned()),
26379            initial_branch: Some("main".to_owned()),
26380        };
26381        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
26382            capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
26383        }));
26384        assert!(!request_uses_unnegotiated_capability(
26385            &request,
26386            &baseline_capabilities().unwrap(),
26387        ));
26388        assert!(request_uses_unnegotiated_capability(&request, &[]));
26389
26390        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
26391            .await
26392            .unwrap_err();
26393        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26394        assert!(!target.exists());
26395        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
26396            .await
26397            .unwrap_err();
26398        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
26399        assert!(!target.exists());
26400        shared
26401            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
26402            .unwrap();
26403
26404        let response = process_request_inner(&shared, 77, ClientRole::Operator, request)
26405            .await
26406            .unwrap();
26407        let NodeResponse::StandaloneWorkspaceCreated { workspace } = response else {
26408            panic!("standalone workspace creation returned another response");
26409        };
26410        assert_eq!(workspace.workspace_id, workspace_id);
26411        assert!(target.join(".git").is_dir());
26412        let branch = run_git_bounded(
26413            target.to_str().unwrap(),
26414            &["branch", "--show-current"],
26415            GIT_OUTPUT_MAX_BYTES,
26416        )
26417        .await
26418        .unwrap();
26419        assert!(branch.success);
26420        assert_eq!(String::from_utf8(branch.stdout).unwrap().trim(), "main");
26421        let worktrees = list_git_worktrees(target.to_str().unwrap()).await.unwrap();
26422        assert_eq!(worktrees.len(), 1);
26423        let listed_root = WorkspaceConfig::new(
26424            WorkspaceId::new("listed-independent").unwrap(),
26425            &worktrees[0].path,
26426        )
26427        .unwrap();
26428        assert!(platform::roots_equal(
26429            listed_root.canonical_root(),
26430            windows_path_text(&workspace.canonical_root),
26431        ));
26432        assert!(!platform::roots_equal(listed_root.canonical_root(), &selected_root));
26433        assert!(shared.snapshot().workspaces.iter().any(|snapshot| {
26434            snapshot.workspace_id == workspace_id
26435                && snapshot.canonical_root == workspace.canonical_root
26436        }));
26437        assert!(shared
26438            .history
26439            .lock()
26440            .unwrap_or_else(|poisoned| poisoned.into_inner())
26441            .events
26442            .iter()
26443            .any(|event| matches!(
26444                &event.event,
26445                NodeEvent::WorkspaceAdded { workspace }
26446                    if workspace.workspace_id == workspace_id
26447            )));
26448        std::fs::remove_dir_all(fixture_root).unwrap();
26449    }
26450
26451    #[tokio::test]
26452    async fn standalone_workspace_registration_failure_preserves_guarded_repository_for_recovery() {
26453        let fixture_root = temporary_workspace_root("standalone-compensation");
26454        let created_target = fixture_root.join("created-target");
26455        let existing_target = fixture_root.join("existing-target");
26456        let invalid_state_parent = fixture_root.join("state-parent-is-a-file");
26457        let invalid_state_path = invalid_state_parent.join("state.json");
26458        std::fs::create_dir_all(&existing_target).unwrap();
26459        std::fs::write(&invalid_state_parent, "not a directory").unwrap();
26460        let catalog = active_registry().unwrap();
26461        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26462        let primary = WorkspaceConfig::new(
26463            WorkspaceId::new("primary").unwrap(),
26464            std::env::current_dir().unwrap(),
26465        )
26466        .unwrap();
26467        let mut shared = NodeShared::new(
26468            handle,
26469            "fixture-token".to_owned(),
26470            NodeId::new("node-standalone-compensation").unwrap(),
26471            vec![primary],
26472            vec![agent("claude")],
26473        );
26474        shared.state_path = Some(invalid_state_path);
26475
26476        let created_recovery = shared
26477            .create_standalone_workspace(
26478                WorkspaceId::new("compensated").unwrap(),
26479                created_target.to_string_lossy().into_owned(),
26480                Some("main".to_owned()),
26481            )
26482            .await
26483            .unwrap_err();
26484        assert_eq!(
26485            created_recovery.code,
26486            NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
26487        );
26488        assert!(created_target.join(".git").is_dir());
26489        assert!(shared.snapshot().workspaces.iter().all(|workspace| {
26490            workspace.workspace_id.as_str() != "compensated"
26491        }));
26492
26493        let existing_recovery = shared
26494            .create_standalone_workspace(
26495                WorkspaceId::new("restored").unwrap(),
26496                existing_target.to_string_lossy().into_owned(),
26497                None,
26498            )
26499            .await
26500            .unwrap_err();
26501        assert_eq!(
26502            existing_recovery.code,
26503            NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
26504        );
26505        assert!(existing_target.is_dir());
26506        assert!(existing_target.join(".git").is_dir());
26507        assert!(shared.snapshot().workspaces.iter().all(|workspace| {
26508            workspace.workspace_id.as_str() != "restored"
26509        }));
26510
26511        let registered_empty = fixture_root.join("registered-empty");
26512        std::fs::create_dir(&registered_empty).unwrap();
26513        let catalog = active_registry().unwrap();
26514        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26515        let registered = WorkspaceConfig::new(
26516            WorkspaceId::new("registered-empty").unwrap(),
26517            &registered_empty,
26518        )
26519        .unwrap();
26520        let duplicate_shared = NodeShared::new(
26521            handle,
26522            "fixture-token".to_owned(),
26523            NodeId::new("node-standalone-duplicate-root").unwrap(),
26524            vec![registered],
26525            vec![agent("claude")],
26526        );
26527        let duplicate = duplicate_shared
26528            .create_standalone_workspace(
26529                WorkspaceId::new("duplicate-root").unwrap(),
26530                registered_empty.to_string_lossy().into_owned(),
26531                None,
26532            )
26533            .await
26534            .unwrap_err();
26535        assert_eq!(duplicate.code, NodeFailureCode::DuplicateWorkspaceRoot);
26536        assert!(std::fs::read_dir(&registered_empty).unwrap().next().is_none());
26537        std::fs::remove_dir_all(fixture_root).unwrap();
26538    }
26539
26540    #[test]
26541    fn managed_record_id_collision_preserves_the_existing_record() {
26542        let catalog = active_registry().unwrap();
26543        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26544        let workspace = WorkspaceConfig::new(
26545            WorkspaceId::new("primary").unwrap(),
26546            std::env::current_dir().unwrap(),
26547        )
26548        .unwrap();
26549        let shared = NodeShared::new(
26550            handle,
26551            "fixture-token".to_owned(),
26552            NodeId::new("node-1").unwrap(),
26553            vec![workspace.clone()],
26554            vec![agent("claude")],
26555        );
26556        let record_id = SessionRecordId::new("sr-collision").unwrap();
26557        let original = ManagedSessionRecord {
26558            record_id: record_id.clone(),
26559            display_name: "original".to_owned(),
26560            provider: agent("claude"),
26561            mode: SessionMode::Pty,
26562            state: ManagedSessionState::Dormant,
26563            workspace_id: workspace.workspace_id,
26564            canonical_root: opaque_windows_path(workspace.canonical_root),
26565            provider_session: None,
26566            active_session: None,
26567            environment_profile: None,
26568            bundle: None,
26569            context_id: None,
26570            context: None,
26571            exported_context: None,
26572            task_binding: None,
26573            created_at_unix_ms: 1,
26574            updated_at_unix_ms: 1,
26575            last_error: None,
26576        };
26577        shared.insert_record(original.clone()).unwrap();
26578        let mut collision = original.clone();
26579        collision.display_name = "replacement".to_owned();
26580        let error = shared.insert_record(collision).unwrap_err();
26581        assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
26582        assert_eq!(shared.record(&record_id).unwrap(), original);
26583    }
26584
26585    #[test]
26586    fn managed_record_runtime_errors_are_exposed_only_as_allowlisted_categories() {
26587        let catalog = active_registry().unwrap();
26588        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26589        let workspace = WorkspaceConfig::new(
26590            WorkspaceId::new("primary").unwrap(),
26591            std::env::current_dir().unwrap(),
26592        )
26593        .unwrap();
26594        let shared = NodeShared::new(
26595            handle,
26596            "fixture-token".to_owned(),
26597            NodeId::new("node-1").unwrap(),
26598            vec![workspace.clone()],
26599            vec![agent("claude")],
26600        );
26601        let record_id = SessionRecordId::new("sr-runtime-error").unwrap();
26602        shared
26603            .insert_record(ManagedSessionRecord {
26604                record_id: record_id.clone(),
26605                display_name: "runtime error".to_owned(),
26606                provider: agent("claude"),
26607                mode: SessionMode::Pty,
26608                state: ManagedSessionState::IdentityPending,
26609                workspace_id: workspace.workspace_id,
26610                canonical_root: opaque_windows_path(workspace.canonical_root),
26611                provider_session: None,
26612                active_session: None,
26613                environment_profile: None,
26614                bundle: None,
26615                context_id: None,
26616                context: None,
26617                exported_context: None,
26618                task_binding: None,
26619                created_at_unix_ms: 1,
26620                updated_at_unix_ms: 1,
26621                last_error: None,
26622            })
26623            .unwrap();
26624
26625        shared
26626            .mark_record_error(
26627                &record_id,
26628                r"provider failed with bearer-secret-123 at C:\private\session.jsonl",
26629            )
26630            .unwrap();
26631        let record = shared.record(&record_id).unwrap();
26632        assert_eq!(record.state, ManagedSessionState::Unavailable);
26633        assert_eq!(
26634            record.last_error.as_deref(),
26635            Some("provider-runtime-failed"),
26636        );
26637        let snapshot = shared.snapshot();
26638        assert_eq!(snapshot.session_records, vec![record]);
26639    }
26640
26641    /// Regression coverage for the "operator sees `deadline` instead of the
26642    /// kernel's own named rejection" defect: a spawn-dispatch waiter must be
26643    /// able to learn a command's asynchronous kernel rejection (here,
26644    /// specifically classified as `UnsupportedTransport`) exactly once, and
26645    /// must not still find it after that -- proving the fast path this
26646    /// mechanism exists for (`spawn_session_with_deadline`'s commit-wait
26647    /// loop) drains the reason instead of leaving it to be rediscovered (or
26648    /// missed) on a later poll.
26649    #[test]
26650    fn rejected_command_reason_is_classified_and_consumed_exactly_once() {
26651        let catalog = active_registry().unwrap();
26652        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26653        let workspace = WorkspaceConfig::new(
26654            WorkspaceId::new("primary").unwrap(),
26655            std::env::current_dir().unwrap(),
26656        )
26657        .unwrap();
26658        let shared = NodeShared::new(
26659            handle,
26660            "fixture-token".to_owned(),
26661            NodeId::new("node-1").unwrap(),
26662            vec![workspace],
26663            vec![agent("claude")],
26664        );
26665
26666        assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
26667
26668        shared.record_rejected_command(
26669            CommandId(1),
26670            "agent 'claude' does not support transport Acp".to_owned(),
26671            true,
26672        );
26673        // An unrelated command_id must not see another command's reason.
26674        assert!(shared.take_rejected_command_reason(CommandId(2)).is_none());
26675
26676        let reason = shared.take_rejected_command_reason(CommandId(1)).unwrap();
26677        assert_eq!(reason.message, "agent 'claude' does not support transport Acp");
26678        assert!(reason.unsupported_transport);
26679
26680        // Consumed: a second read for the same command_id finds nothing.
26681        assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
26682
26683        // A rejection unrelated to transport support keeps its own
26684        // classification distinct, so the reader can choose the generic
26685        // `BackendOperationFailed` code instead of misreporting
26686        // `UnsupportedTransport`.
26687        shared.record_rejected_command(
26688            CommandId(3),
26689            "backend busy".to_owned(),
26690            false,
26691        );
26692        let unclassified = shared.take_rejected_command_reason(CommandId(3)).unwrap();
26693        assert!(!unclassified.unsupported_transport);
26694    }
26695
26696    #[test]
26697    fn provider_identity_update_is_ignored_without_exact_session_policy() {
26698        let catalog = active_registry().unwrap();
26699        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26700        let workspace = WorkspaceConfig::new(
26701            WorkspaceId::new("primary").unwrap(),
26702            std::env::current_dir().unwrap(),
26703        )
26704        .unwrap();
26705        let shared = NodeShared::new(
26706            handle,
26707            "fixture-token".to_owned(),
26708            NodeId::new("node-identity-policy").unwrap(),
26709            vec![workspace.clone()],
26710            vec![agent("claude")],
26711        );
26712        let address = SessionAddress {
26713            workspace_id: workspace.workspace_id.clone(),
26714            session: SessionKey {
26715                instance_id: AgentInstanceId(90),
26716                generation: SessionGeneration(1),
26717            },
26718        };
26719        let record_id = SessionRecordId::new("sr-policy-denied").unwrap();
26720        shared
26721            .insert_record(ManagedSessionRecord {
26722                record_id: record_id.clone(),
26723                display_name: "policy denied".to_owned(),
26724                provider: agent("claude"),
26725                mode: SessionMode::Pty,
26726                state: ManagedSessionState::IdentityPending,
26727                workspace_id: workspace.workspace_id,
26728                canonical_root: opaque_windows_path(workspace.canonical_root),
26729                provider_session: None,
26730                active_session: Some(address.clone()),
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 event = ControlEvent {
26743            sequence: 1,
26744            command_id: None,
26745            instance_id: address.session.instance_id,
26746            generation: address.session.generation,
26747            event: ControlEventKind::ProviderEvent {
26748                sequence: 1,
26749                source: gate4agent_types::ProviderSource {
26750                    family: AdapterFamily::PtySemantic,
26751                    binding: AdapterBinding::new(
26752                        gate4agent_types::AdapterId::new("claude").unwrap(),
26753                        "fixture/v1",
26754                        gate4agent_types::AdapterVerification::SyntheticFixture,
26755                    )
26756                    .unwrap(),
26757                },
26758                source_sequence: 1,
26759                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26760                    identity: gate4agent_types::ProviderSessionIdentity {
26761                        key: gate4agent_types::ProviderSessionKey::SessionId,
26762                        id: "provider-session".to_owned(),
26763                        transcript_path: None,
26764                    },
26765                },
26766            },
26767        };
26768
26769        shared.reconcile_managed_record(&record_id, &address, &event, false, false);
26770
26771        let record = shared.record(&record_id).unwrap();
26772        assert!(record.provider_session.is_none());
26773        assert_eq!(record.state, ManagedSessionState::IdentityPending);
26774        assert_eq!(record.active_session.as_ref(), Some(&address));
26775    }
26776
26777    #[test]
26778    fn provider_identity_cannot_rebind_a_record_across_workspaces() {
26779        let secondary_root = temporary_workspace_root("identity-scope");
26780        std::fs::create_dir_all(&secondary_root).unwrap();
26781        let catalog = active_registry().unwrap();
26782        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26783        let primary = WorkspaceConfig::new(
26784            WorkspaceId::new("primary").unwrap(),
26785            std::env::current_dir().unwrap(),
26786        )
26787        .unwrap();
26788        let secondary = WorkspaceConfig::new(
26789            WorkspaceId::new("secondary").unwrap(),
26790            &secondary_root,
26791        )
26792        .unwrap();
26793        let shared = NodeShared::new(
26794            handle,
26795            "fixture-token".to_owned(),
26796            NodeId::new("node-1").unwrap(),
26797            vec![primary.clone(), secondary.clone()],
26798            vec![agent("claude")],
26799        );
26800        let identity = gate4agent_types::ProviderSessionIdentity {
26801            key: gate4agent_types::ProviderSessionKey::SessionId,
26802            id: "provider-session".to_owned(),
26803            transcript_path: None,
26804        };
26805        let dormant_id = SessionRecordId::new("sr-dormant").unwrap();
26806        shared
26807            .insert_record(ManagedSessionRecord {
26808                record_id: dormant_id.clone(),
26809                display_name: "dormant".to_owned(),
26810                provider: agent("claude"),
26811                mode: SessionMode::Pty,
26812                state: ManagedSessionState::Dormant,
26813                workspace_id: primary.workspace_id,
26814                canonical_root: opaque_windows_path(primary.canonical_root),
26815                provider_session: Some(identity.clone()),
26816                active_session: None,
26817                environment_profile: None,
26818                bundle: None,
26819                context_id: None,
26820                context: None,
26821                exported_context: None,
26822                task_binding: None,
26823                created_at_unix_ms: 1,
26824                updated_at_unix_ms: 1,
26825                last_error: None,
26826            })
26827            .unwrap();
26828        let address = SessionAddress {
26829            workspace_id: secondary.workspace_id.clone(),
26830            session: SessionKey {
26831                instance_id: AgentInstanceId(91),
26832                generation: SessionGeneration(1),
26833            },
26834        };
26835        let current_id = SessionRecordId::new("sr-current").unwrap();
26836        shared
26837            .insert_record(ManagedSessionRecord {
26838                record_id: current_id.clone(),
26839                display_name: "current".to_owned(),
26840                provider: agent("claude"),
26841                mode: SessionMode::Pty,
26842                state: ManagedSessionState::IdentityPending,
26843                workspace_id: secondary.workspace_id,
26844                canonical_root: opaque_windows_path(secondary.canonical_root),
26845                provider_session: None,
26846                active_session: Some(address.clone()),
26847                environment_profile: None,
26848                bundle: None,
26849                context_id: None,
26850                context: None,
26851                exported_context: None,
26852                task_binding: None,
26853                created_at_unix_ms: 2,
26854                updated_at_unix_ms: 2,
26855                last_error: None,
26856            })
26857            .unwrap();
26858        shared.bind_managed_session(
26859            &address,
26860            current_id.clone(),
26861            ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26862            None,
26863        );
26864        let event = ControlEvent {
26865            sequence: 1,
26866            command_id: None,
26867            instance_id: address.session.instance_id,
26868            generation: address.session.generation,
26869            event: ControlEventKind::ProviderEvent {
26870                sequence: 1,
26871                source: gate4agent_types::ProviderSource {
26872                    family: gate4agent_types::AdapterFamily::PtySemantic,
26873                    binding: gate4agent_types::AdapterBinding::new(
26874                        gate4agent_types::AdapterId::new("claude").unwrap(),
26875                        "fixture/v1",
26876                        gate4agent_types::AdapterVerification::SyntheticFixture,
26877                    )
26878                    .unwrap(),
26879                },
26880                source_sequence: 1,
26881                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26882                    identity,
26883                },
26884            },
26885        };
26886        shared.reconcile_managed_record(&current_id, &address, &event, true, false);
26887
26888        let binding = shared
26889            .session_bindings
26890            .lock()
26891            .unwrap_or_else(|poisoned| poisoned.into_inner())
26892            .get(&address.session.instance_id)
26893            .cloned()
26894            .unwrap();
26895        assert_eq!(binding.record_id.as_ref(), Some(&current_id));
26896        assert_eq!(shared.record(&dormant_id).unwrap().state, ManagedSessionState::Dormant);
26897        let current = shared.record(&current_id).unwrap();
26898        assert_eq!(current.state, ManagedSessionState::Unavailable);
26899        assert_eq!(current.active_session.as_ref(), Some(&address));
26900        assert_eq!(
26901            current.last_error.as_deref(),
26902            Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR),
26903        );
26904        std::fs::remove_dir_all(secondary_root).unwrap();
26905    }
26906
26907    #[tokio::test]
26908    async fn semantic_identity_with_transcript_path_reuses_record_and_survives_reload() {
26909        let root = temporary_workspace_root("semantic-provider-session");
26910        std::fs::create_dir_all(&root).unwrap();
26911        let state_path = root.join("state-v1.json");
26912        let catalog = active_registry().unwrap();
26913        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26914        let workspace = WorkspaceConfig::new(
26915            WorkspaceId::new("primary").unwrap(),
26916            &root,
26917        )
26918        .unwrap();
26919        let node_id = NodeId::new("node-semantic-identity").unwrap();
26920        let shared = NodeShared::new_with_incarnation(
26921            handle,
26922            "fixture-token".to_owned(),
26923            node_id.clone(),
26924            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
26925            vec![workspace.clone()],
26926            vec![agent("claude")],
26927            ProviderRuntimeStatuses::default(),
26928            None,
26929            Vec::new(),
26930            Vec::new(),
26931            SpawnProfileRegistry::default(),
26932            None,
26933            None,
26934            None,
26935            Some(state_path.clone()),
26936            Vec::new(),
26937            Vec::new(),
26938            Vec::new(),
26939            Vec::new(),
26940            None,
26941            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
26942        );
26943        let persisted_identity = gate4agent_types::ProviderSessionIdentity {
26944            key: gate4agent_types::ProviderSessionKey::SessionId,
26945            id: "provider-session".to_owned(),
26946            transcript_path: None,
26947        };
26948        let durable_id = SessionRecordId::new("sr-durable").unwrap();
26949        shared
26950            .insert_record(ManagedSessionRecord {
26951                record_id: durable_id.clone(),
26952                display_name: "durable".to_owned(),
26953                provider: agent("claude"),
26954                mode: SessionMode::Pty,
26955                state: ManagedSessionState::Dormant,
26956                workspace_id: workspace.workspace_id.clone(),
26957                canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26958                provider_session: Some(persisted_identity.clone()),
26959                active_session: None,
26960                environment_profile: None,
26961                bundle: None,
26962                context_id: None,
26963                context: None,
26964                exported_context: None,
26965                task_binding: None,
26966                created_at_unix_ms: 1,
26967                updated_at_unix_ms: 1,
26968                last_error: None,
26969            })
26970            .unwrap();
26971        let address = SessionAddress {
26972            workspace_id: workspace.workspace_id.clone(),
26973            session: SessionKey {
26974                instance_id: AgentInstanceId(92),
26975                generation: SessionGeneration(2),
26976            },
26977        };
26978        let pending_id = SessionRecordId::new("sr-pending").unwrap();
26979        shared
26980            .insert_record(ManagedSessionRecord {
26981                record_id: pending_id.clone(),
26982                display_name: "pending".to_owned(),
26983                provider: agent("claude"),
26984                mode: SessionMode::Pty,
26985                state: ManagedSessionState::IdentityPending,
26986                workspace_id: workspace.workspace_id.clone(),
26987                canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26988                provider_session: None,
26989                active_session: Some(address.clone()),
26990                environment_profile: None,
26991                bundle: None,
26992                context_id: None,
26993                context: None,
26994                exported_context: None,
26995                task_binding: None,
26996                created_at_unix_ms: 2,
26997                updated_at_unix_ms: 2,
26998                last_error: None,
26999            })
27000            .unwrap();
27001        shared.bind_managed_session(
27002            &address,
27003            pending_id.clone(),
27004            ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
27005            None,
27006        );
27007        let observed_identity = gate4agent_types::ProviderSessionIdentity {
27008            transcript_path: Some(r"C:\private\provider-transcript.jsonl".to_owned()),
27009            ..persisted_identity
27010        };
27011        let event = ControlEvent {
27012            sequence: 1,
27013            command_id: None,
27014            instance_id: address.session.instance_id,
27015            generation: address.session.generation,
27016            event: ControlEventKind::ProviderEvent {
27017                sequence: 1,
27018                source: gate4agent_types::ProviderSource {
27019                    family: gate4agent_types::AdapterFamily::PtySemantic,
27020                    binding: gate4agent_types::AdapterBinding::new(
27021                        gate4agent_types::AdapterId::new("claude").unwrap(),
27022                        "fixture/v1",
27023                        gate4agent_types::AdapterVerification::SyntheticFixture,
27024                    )
27025                    .unwrap(),
27026                },
27027                source_sequence: 1,
27028                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
27029                    identity: observed_identity.clone(),
27030                },
27031            },
27032        };
27033        shared.reconcile_managed_record(&pending_id, &address, &event, true, false);
27034
27035        let snapshot = shared.snapshot();
27036        assert_eq!(snapshot.session_records.len(), 1);
27037        let live = &snapshot.session_records[0];
27038        assert_eq!(live.record_id, durable_id);
27039        assert_eq!(live.state, ManagedSessionState::Live);
27040        assert_eq!(live.active_session.as_ref(), Some(&address));
27041        assert_eq!(live.provider_session.as_ref(), Some(&observed_identity));
27042        let binding = shared
27043            .session_bindings
27044            .lock()
27045            .unwrap_or_else(|poisoned| poisoned.into_inner())
27046            .get(&address.session.instance_id)
27047            .cloned()
27048            .unwrap();
27049        assert_eq!(binding.record_id.as_ref(), Some(&durable_id));
27050
27051        let mut duplicate = live.clone();
27052        duplicate.record_id = SessionRecordId::new("sr-duplicate").unwrap();
27053        duplicate.display_name = "duplicate".to_owned();
27054        duplicate.state = ManagedSessionState::Dormant;
27055        duplicate.active_session = None;
27056        duplicate.provider_session.as_mut().unwrap().transcript_path = None;
27057        let duplicate_error = shared.insert_record(duplicate).unwrap_err();
27058        assert_eq!(duplicate_error.code, NodeFailureCode::SessionRecordConflict);
27059        assert_eq!(shared.snapshot().session_records.len(), 1);
27060
27061        let terminal_size = gate4agent_types::TerminalSize {
27062            rows: 24,
27063            columns: 80,
27064        };
27065        let (first_busy, second_busy) = tokio::join!(
27066            shared.resume_session_record(&durable_id, terminal_size, None),
27067            shared.resume_session_record(&durable_id, terminal_size, None),
27068        );
27069        for busy in [first_busy.unwrap_err(), second_busy.unwrap_err()] {
27070            assert_eq!(busy.code, NodeFailureCode::SessionRecordBusy);
27071            assert_eq!(busy.message, "session-record-busy");
27072        }
27073
27074        let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
27075        assert_eq!(loaded.records.len(), 1);
27076        assert_eq!(loaded.records[0].record_id, durable_id);
27077        assert_eq!(loaded.records[0].state, ManagedSessionState::Dormant);
27078        assert_eq!(
27079            loaded.records[0]
27080                .provider_session
27081                .as_ref()
27082                .unwrap()
27083                .transcript_path,
27084            None,
27085        );
27086        let catalog = active_registry().unwrap();
27087        let (reloaded_handle, _reloaded_runtime) =
27088            NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27089        let reloaded = NodeShared::new_with_incarnation(
27090            reloaded_handle,
27091            "fixture-token".to_owned(),
27092            node_id,
27093            NodeIncarnationId::from_bytes([1; crate::protocol::NODE_INCARNATION_ID_BYTES]),
27094            vec![workspace],
27095            vec![agent("claude")],
27096            ProviderRuntimeStatuses::default(),
27097            None,
27098            Vec::new(),
27099            Vec::new(),
27100            SpawnProfileRegistry::default(),
27101            None,
27102            None,
27103            None,
27104            None,
27105            loaded.records,
27106            loaded.managed_worktrees,
27107            loaded.managed_worktree_tombstones,
27108            loaded.materializations,
27109            loaded.warning,
27110            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
27111        );
27112        assert_eq!(reloaded.snapshot().session_records.len(), 1);
27113        assert_eq!(reloaded.snapshot().session_records[0].record_id, durable_id);
27114        std::fs::remove_dir_all(root).unwrap();
27115    }
27116
27117    #[tokio::test]
27118    async fn workspace_reregistration_requires_the_recorded_root_and_restores_records() {
27119        let secondary_root = temporary_workspace_root("workspace-restore");
27120        let other_root = temporary_workspace_root("workspace-conflict");
27121        std::fs::create_dir_all(&secondary_root).unwrap();
27122        std::fs::create_dir_all(&other_root).unwrap();
27123        let catalog = active_registry().unwrap();
27124        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27125        let primary = WorkspaceConfig::new(
27126            WorkspaceId::new("primary").unwrap(),
27127            std::env::current_dir().unwrap(),
27128        )
27129        .unwrap();
27130        let secondary = WorkspaceConfig::new(
27131            WorkspaceId::new("secondary").unwrap(),
27132            &secondary_root,
27133        )
27134        .unwrap();
27135        let shared = NodeShared::new(
27136            handle,
27137            "fixture-token".to_owned(),
27138            NodeId::new("node-1").unwrap(),
27139            vec![primary, secondary.clone()],
27140            vec![agent("claude")],
27141        );
27142        let record_id = SessionRecordId::new("sr-workspace").unwrap();
27143        shared
27144            .insert_record(ManagedSessionRecord {
27145                record_id: record_id.clone(),
27146                display_name: "workspace record".to_owned(),
27147                provider: agent("claude"),
27148                mode: SessionMode::Pty,
27149                state: ManagedSessionState::Dormant,
27150                workspace_id: secondary.workspace_id.clone(),
27151                canonical_root: opaque_windows_path(secondary.canonical_root.clone()),
27152                provider_session: Some(gate4agent_types::ProviderSessionIdentity {
27153                    key: gate4agent_types::ProviderSessionKey::SessionId,
27154                    id: "provider-session".to_owned(),
27155                    transcript_path: None,
27156                }),
27157                active_session: None,
27158                environment_profile: None,
27159                bundle: None,
27160                context_id: None,
27161                context: None,
27162                exported_context: None,
27163                task_binding: None,
27164                created_at_unix_ms: 1,
27165                updated_at_unix_ms: 1,
27166                last_error: None,
27167            })
27168            .unwrap();
27169        shared.unregister_workspace(&secondary.workspace_id).unwrap();
27170        let unavailable = shared.record(&record_id).unwrap();
27171        assert_eq!(unavailable.state, ManagedSessionState::Unavailable);
27172        assert_eq!(windows_path_text(&unavailable.canonical_root), secondary.canonical_root);
27173
27174        let conflict = shared
27175            .register_workspace(
27176                secondary.workspace_id.clone(),
27177                other_root.to_string_lossy().into_owned(),
27178            )
27179            .await
27180            .unwrap_err();
27181        assert_eq!(conflict.code, NodeFailureCode::SessionRecordConflict);
27182        shared
27183            .register_workspace(
27184                secondary.workspace_id,
27185                secondary_root.to_string_lossy().into_owned(),
27186            )
27187            .await
27188            .unwrap();
27189        let restored = shared.record(&record_id).unwrap();
27190        assert_eq!(restored.state, ManagedSessionState::Dormant);
27191        assert!(restored.last_error.is_none());
27192        std::fs::remove_dir_all(secondary_root).unwrap();
27193        std::fs::remove_dir_all(other_root).unwrap();
27194    }
27195
27196    #[test]
27197    fn dynamic_workspace_root_is_rejected_before_filesystem_canonicalization() {
27198        let error = validate_workspace_request_root(
27199            &"x".repeat(MAX_WORKSPACE_ROOT_BYTES + 1),
27200        )
27201        .unwrap_err();
27202        assert_eq!(error.code, NodeFailureCode::InvalidWorkspaceRoot);
27203        let control = validate_workspace_request_root("C:\\repo\nchild").unwrap_err();
27204        assert_eq!(control.code, NodeFailureCode::InvalidWorkspaceRoot);
27205    }
27206
27207    #[test]
27208    fn concurrent_publish_and_resync_never_acknowledge_an_unappended_event() {
27209        let catalog = active_registry().unwrap();
27210        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27211        let workspace = WorkspaceConfig::new(
27212            WorkspaceId::new("primary").unwrap(),
27213            std::env::current_dir().unwrap(),
27214        )
27215        .unwrap();
27216        let shared = NodeShared::new(
27217            handle,
27218            "fixture-token".to_owned(),
27219            NodeId::new("node-1").unwrap(),
27220            vec![workspace],
27221            vec![agent("claude")],
27222        );
27223        let publishers = 4_u64;
27224        let events_per_publisher = 256_u64;
27225        let start = std::sync::Barrier::new(publishers as usize + 1);
27226        let finished = AtomicU64::new(0);
27227
27228        std::thread::scope(|scope| {
27229            for _ in 0..publishers {
27230                scope.spawn(|| {
27231                    start.wait();
27232                    for _ in 0..events_per_publisher {
27233                        shared.publish(NodeEvent::ControllerChanged { controller: None });
27234                    }
27235                    finished.fetch_add(1, Ordering::Release);
27236                });
27237            }
27238            start.wait();
27239            while finished.load(Ordering::Acquire) != publishers {
27240                assert_resync_history_is_linearized(shared.resync(0));
27241                std::thread::yield_now();
27242            }
27243        });
27244        let response = shared.resync(0);
27245        assert_resync_history_is_linearized(response.clone());
27246        let NodeResponse::Resync { event_sequence, events, .. } = response else {
27247            unreachable!("resync helper returned another response");
27248        };
27249        assert_eq!(event_sequence, publishers * events_per_publisher);
27250        assert_eq!(events.len() as u64, event_sequence);
27251    }
27252
27253    fn assert_resync_history_is_linearized(response: NodeResponse) {
27254        let NodeResponse::Resync { event_sequence, events, .. } = response else {
27255            unreachable!("resync helper returned another response");
27256        };
27257        assert!(events.windows(2).all(|pair| {
27258            pair[0].sequence.checked_add(1) == Some(pair[1].sequence)
27259        }));
27260        assert_eq!(events.last().map_or(0, |event| event.sequence), event_sequence);
27261    }
27262
27263    #[test]
27264    fn canonical_workspace_roots_are_safe_for_cmd_exe() {
27265        assert_eq!(
27266            platform::normalize_canonical_root(r"\\?\C:\repo\worktree".to_owned()),
27267            r"C:\repo\worktree",
27268        );
27269        assert_eq!(
27270            platform::normalize_canonical_root(r"\\?\UNC\server\share\repo".to_owned()),
27271            r"\\server\share\repo",
27272        );
27273        assert!(validate_workspace_root(
27274            &WorkspaceId::new("network").unwrap(),
27275            r"\\server\share\repo",
27276        )
27277        .is_err());
27278        let workspace = WorkspaceConfig::new(
27279            WorkspaceId::new("primary").unwrap(),
27280            std::env::current_dir().unwrap(),
27281        )
27282        .unwrap();
27283        assert!(!workspace.canonical_root.starts_with(r"\\?\"));
27284    }
27285
27286    #[test]
27287    fn resume_rebinds_only_on_the_expected_authorized_generation() {
27288        let catalog = active_registry().unwrap();
27289        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27290        let workspace_id = WorkspaceId::new("primary").unwrap();
27291        let workspace = WorkspaceConfig::new(
27292            workspace_id.clone(),
27293            std::env::current_dir().unwrap(),
27294        )
27295        .unwrap();
27296        let shared = NodeShared::new(
27297            handle,
27298            "fixture-token".to_owned(),
27299            NodeId::new("node-1").unwrap(),
27300            vec![workspace],
27301            vec![agent("claude")],
27302        );
27303        let instance_id = AgentInstanceId(17);
27304        let original = SessionAddress {
27305            workspace_id,
27306            session: SessionKey {
27307                instance_id,
27308                generation: SessionGeneration(3),
27309            },
27310        };
27311        shared.bind_session(
27312            instance_id,
27313            original.workspace_id.clone(),
27314            original.session.generation,
27315        );
27316        shared
27317            .arm_resume(
27318                &original,
27319                CommandId(41),
27320                ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
27321            )
27322            .unwrap();
27323
27324        shared.publish_control(ControlEvent {
27325            sequence: 1,
27326            command_id: None,
27327            instance_id,
27328            generation: SessionGeneration(4),
27329            event: ControlEventKind::Running { process_id: None },
27330        });
27331        assert_eq!(
27332            shared.address_for(instance_id, SessionGeneration(3)),
27333            Some(original.clone()),
27334        );
27335        assert!(shared.address_for(instance_id, SessionGeneration(4)).is_none());
27336
27337        shared.publish_control(ControlEvent {
27338            sequence: 2,
27339            command_id: Some(CommandId(42)),
27340            instance_id,
27341            generation: SessionGeneration(3),
27342            event: ControlEventKind::CommandRejected {
27343                message: "unrelated command".to_owned(),
27344            },
27345        });
27346
27347        shared.publish_control(ControlEvent {
27348            sequence: 3,
27349            command_id: None,
27350            instance_id,
27351            generation: SessionGeneration(4),
27352            event: ControlEventKind::ResumeAuthorized {
27353                session: gate4agent_types::ResumeSessionSummary {
27354                    key: gate4agent_types::ProviderSessionKey::SessionId,
27355                    id: "fixture-session".to_owned(),
27356                },
27357            },
27358        });
27359        assert!(shared.address_for(instance_id, SessionGeneration(3)).is_none());
27360        let rebound = shared.address_for(instance_id, SessionGeneration(4)).unwrap();
27361        assert_eq!(rebound.workspace_id, original.workspace_id);
27362        let history = shared
27363            .history
27364            .lock()
27365            .unwrap_or_else(|poisoned| poisoned.into_inner());
27366        assert!(history.events.iter().any(|envelope| matches!(
27367            &envelope.event,
27368            NodeEvent::Control { address, event }
27369                if address == &rebound
27370                    && matches!(&event.event, ControlEventKind::ResumeAuthorized { .. })
27371        )));
27372    }
27373
27374    #[test]
27375    fn all_node_pty_prompts_use_bracketed_paste_framing() {
27376        for agent in ["claude", "codex", "kimi"] {
27377            assert_eq!(
27378                prompt_framing(&AgentId::new(agent).unwrap()),
27379                PromptFraming::BracketedPaste
27380            );
27381        }
27382    }
27383
27384    fn generous_walk_deadline() -> Instant {
27385        Instant::now() + Duration::from_secs(30)
27386    }
27387
27388    #[test]
27389    fn workspace_tree_is_relative_and_skips_heavy_directories() {
27390        let root = temporary_workspace_root("tree");
27391        std::fs::create_dir_all(root.join("src")).unwrap();
27392        std::fs::create_dir_all(root.join(".git/objects")).unwrap();
27393        std::fs::create_dir_all(root.join("target/debug")).unwrap();
27394        std::fs::create_dir_all(root.join("node_modules/package")).unwrap();
27395        std::fs::write(root.join("src/lib.rs"), b"pub fn fixture() {}\n").unwrap();
27396        std::fs::write(root.join("README.md"), b"fixture\n").unwrap();
27397        // A renamed/agent-scoped build directory (`target-a2`-style, per the
27398        // real repository's own build layout) that carries the Cache
27399        // Directory Tagging Specification marker cargo writes into every
27400        // `--target-dir` it creates: must be skipped even though its name
27401        // does not exactly match `target`.
27402        std::fs::create_dir_all(root.join("target-a2/debug")).unwrap();
27403        std::fs::write(root.join("target-a2/CACHEDIR.TAG"), b"Signature: 8a477f597d28d172789f06886806bc55\n").unwrap();
27404        // A directory that merely *starts with* `target` but carries no
27405        // CACHEDIR.TAG: the new prefix-adjacent check must not false-match
27406        // on the name alone, so this stays in the response.
27407        std::fs::create_dir_all(root.join("target-notes")).unwrap();
27408        std::fs::write(root.join("target-notes/plan.md"), b"not a build directory\n").unwrap();
27409
27410        let (entries, truncated, _budget) =
27411            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
27412        assert!(!truncated);
27413        assert!(entries.iter().any(|entry| {
27414            entry.relative_path.as_utf8() == Some("src")
27415                && entry.kind == WorkspaceEntryKind::Directory
27416        }));
27417        assert!(entries.iter().any(|entry| {
27418            entry.relative_path.as_utf8() == Some("src/lib.rs")
27419                && entry.kind == WorkspaceEntryKind::File
27420        }));
27421        assert!(entries.iter().any(|entry| {
27422            entry.relative_path.as_utf8() == Some("target-notes")
27423                && entry.kind == WorkspaceEntryKind::Directory
27424        }));
27425        assert!(entries.iter().any(|entry| {
27426            entry.relative_path.as_utf8() == Some("target-notes/plan.md")
27427                && entry.kind == WorkspaceEntryKind::File
27428        }));
27429        assert!(entries.iter().all(|entry| {
27430            let path = entry.relative_path.as_utf8().unwrap();
27431            !path.starts_with(".git")
27432                && !(path == "target" || path.starts_with("target/"))
27433                && !(path == "target-a2" || path.starts_with("target-a2/"))
27434                && !path.starts_with("node_modules")
27435        }));
27436
27437        std::fs::remove_dir_all(&root).unwrap();
27438    }
27439
27440    #[test]
27441    fn workspace_walk_stops_at_its_entry_cap_and_marks_a_partial_result() {
27442        let root = temporary_workspace_root("tree-entry-cap");
27443        std::fs::create_dir_all(&root).unwrap();
27444        for index in 0..20 {
27445            std::fs::write(root.join(format!("file-{index:02}.txt")), b"fixture\n").unwrap();
27446        }
27447
27448        let (entries, truncated, budget) =
27449            collect_workspace_entries(&root, generous_walk_deadline(), 5);
27450        assert!(truncated);
27451        assert!(budget.entry_cap_exceeded);
27452        assert!(!budget.time_budget_exceeded);
27453        assert!(entries.len() <= 5);
27454
27455        let inspection_truncation = workspace_inspection_truncation(&budget, false, Duration::from_millis(1))
27456            .expect("an entry-cap-truncated walk must report a truncation marker");
27457        assert!(inspection_truncation.walk_entry_cap_exceeded);
27458        assert!(!inspection_truncation.walk_time_budget_exceeded);
27459        assert!(!inspection_truncation.git_time_budget_exceeded);
27460        assert!(inspection_truncation.entries_visited >= 5);
27461
27462        std::fs::remove_dir_all(&root).unwrap();
27463    }
27464
27465    #[test]
27466    fn workspace_walk_within_budget_reports_no_truncation() {
27467        let root = temporary_workspace_root("tree-no-truncation");
27468        std::fs::create_dir_all(&root).unwrap();
27469        std::fs::write(root.join("only-file.txt"), b"fixture\n").unwrap();
27470
27471        let (_entries, truncated, budget) =
27472            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
27473        assert!(!truncated);
27474        assert!(workspace_inspection_truncation(&budget, false, Duration::from_millis(1)).is_none());
27475
27476        std::fs::remove_dir_all(&root).unwrap();
27477    }
27478
27479    #[test]
27480    fn workspace_walk_emits_strict_global_byte_order_for_prefix_named_siblings() {
27481        let root = temporary_workspace_root("tree-prefix-siblings");
27482        std::fs::create_dir_all(root.join("gate4agent-c2/src")).unwrap();
27483        std::fs::write(root.join("gate4agent-c2/src/lib.rs"), b"fixture\n").unwrap();
27484        std::fs::create_dir_all(root.join("gate4agent-c2-client")).unwrap();
27485        std::fs::write(root.join("gate4agent-c2-client/lib.rs"), b"fixture\n").unwrap();
27486
27487        let (entries, truncated, _budget) =
27488            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
27489        assert!(!truncated);
27490        let paths = entries
27491            .iter()
27492            .map(|entry| entry.relative_path.as_utf8().unwrap().to_owned())
27493            .collect::<Vec<_>>();
27494        assert!(paths.windows(2).all(|pair| pair[0] < pair[1]));
27495        assert_eq!(
27496            paths,
27497            vec![
27498                "gate4agent-c2".to_owned(),
27499                "gate4agent-c2-client".to_owned(),
27500                "gate4agent-c2-client/lib.rs".to_owned(),
27501                "gate4agent-c2/src".to_owned(),
27502                "gate4agent-c2/src/lib.rs".to_owned(),
27503            ],
27504        );
27505
27506        std::fs::remove_dir_all(&root).unwrap();
27507    }
27508
27509    #[test]
27510    fn git_parsers_cap_status_and_commit_results() {
27511        let mut status_output = Vec::new();
27512        for index in 0..=GIT_STATUS_MAX_ENTRIES {
27513            status_output.extend_from_slice(format!(" M src/file-{index}.rs\0").as_bytes());
27514        }
27515        let mut snapshot = git_snapshot_for_parser();
27516        parse_git_status(&status_output, &mut snapshot);
27517        assert_eq!(snapshot.status.len(), GIT_STATUS_MAX_ENTRIES);
27518        assert!(snapshot.truncated);
27519        assert_eq!(snapshot.status[0].index_status, " ");
27520        assert_eq!(snapshot.status[0].worktree_status, "M");
27521
27522        snapshot.truncated = false;
27523        let commit_output = (0..=GIT_COMMIT_MAX_ENTRIES)
27524            .map(|index| format!("abc{index}\u{1f}commit {index}\n"))
27525            .collect::<String>();
27526        parse_git_commits(commit_output.as_bytes(), &mut snapshot);
27527        assert_eq!(snapshot.recent_commits.len(), GIT_COMMIT_MAX_ENTRIES);
27528        assert!(snapshot.truncated);
27529    }
27530
27531    #[test]
27532    fn porcelain_v1_z_parser_preserves_current_then_previous_rename_and_copy_order() {
27533        let mut snapshot = git_snapshot_for_parser();
27534        parse_git_status(
27535            b"R  src/current name.rs\0src/previous name.rs\0 C src/copy.rs\0src/original.rs\0?? src/line\nname.rs\0",
27536            &mut snapshot,
27537        );
27538
27539        assert!(!snapshot.truncated);
27540        assert_eq!(snapshot.status.len(), 3);
27541        assert_eq!(snapshot.status[0].index_status, "R");
27542        assert_eq!(snapshot.status[0].worktree_status, " ");
27543        assert_eq!(snapshot.status[0].path.as_utf8(), Some("src/current name.rs"));
27544        assert_eq!(
27545            snapshot.status[0]
27546                .previous_path
27547                .as_ref()
27548                .and_then(RepositoryPath::as_utf8),
27549            Some("src/previous name.rs"),
27550        );
27551        assert_eq!(snapshot.status[1].index_status, " ");
27552        assert_eq!(snapshot.status[1].worktree_status, "C");
27553        assert_eq!(snapshot.status[1].path.as_utf8(), Some("src/copy.rs"));
27554        assert_eq!(
27555            snapshot.status[1]
27556                .previous_path
27557                .as_ref()
27558                .and_then(RepositoryPath::as_utf8),
27559            Some("src/original.rs"),
27560        );
27561        assert_eq!(snapshot.status[2].path.as_utf8(), Some("src/line\nname.rs"));
27562        assert!(snapshot.status[2].previous_path.is_none());
27563    }
27564
27565    #[test]
27566    fn porcelain_v1_z_parser_never_invents_invalid_or_incomplete_path_identity() {
27567        let mut snapshot = git_snapshot_for_parser();
27568        parse_git_status(
27569            b"?? valid.rs\0?? invalid-\xff.rs\0?? invalid\\windows.rs\0R  current.rs\0",
27570            &mut snapshot,
27571        );
27572
27573        assert!(snapshot.truncated);
27574        assert_eq!(snapshot.status.len(), 1);
27575        assert_eq!(snapshot.status[0].path.as_utf8(), Some("valid.rs"));
27576        assert!(snapshot.status[0].previous_path.is_none());
27577    }
27578
27579    #[tokio::test]
27580    async fn observer_can_browse_host_directories_without_controller() {
27581        let root = temporary_workspace_root("host-directory-observer");
27582        std::fs::create_dir_all(root.join("directory-b")).unwrap();
27583        std::fs::create_dir_all(root.join("directory-a")).unwrap();
27584        std::fs::write(root.join("ordinary-file"), b"not a directory").unwrap();
27585        let workspace_id = WorkspaceId::new("primary").unwrap();
27586        let workspace = WorkspaceConfig::new(workspace_id, &root).unwrap();
27587        let catalog = active_registry().unwrap();
27588        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27589        let shared = NodeShared::new(
27590            handle,
27591            "fixture-token".to_owned(),
27592            NodeId::new("node-1").unwrap(),
27593            vec![workspace],
27594            vec![agent("claude")],
27595        );
27596        let request = NodeRequest::BrowseHostDirectories {
27597            directory: Some(OpaqueHostPath::utf8(root.to_string_lossy().into_owned()).unwrap()),
27598            after: None,
27599        };
27600        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
27601            capability.as_str() == CAPABILITY_HOST_DIRECTORY_BROWSE_V1
27602        }));
27603        assert!(!request_uses_unnegotiated_capability(
27604            &request,
27605            &baseline_capabilities().unwrap(),
27606        ));
27607        assert!(request_uses_unnegotiated_capability(&request, &[]));
27608
27609        let response = process_request_inner(&shared, 77, ClientRole::Observer, request)
27610            .await
27611            .unwrap();
27612        let NodeResponse::HostDirectoriesBrowsed { listing } = response else {
27613            panic!("host directory browse returned another response");
27614        };
27615        assert_eq!(
27616            listing
27617                .entries
27618                .iter()
27619                .map(|entry| entry.display_name.as_str())
27620                .collect::<Vec<_>>(),
27621            vec!["directory-a", "directory-b"],
27622        );
27623        assert!(listing.entries.iter().all(|entry| !entry.is_link));
27624        assert!(!listing.incomplete);
27625        std::fs::remove_dir_all(root).unwrap();
27626    }
27627
27628    #[tokio::test]
27629    async fn observer_can_inspect_only_a_registered_workspace() {
27630        let root = temporary_workspace_root("observer");
27631        std::fs::create_dir_all(root.join("src")).unwrap();
27632        std::fs::write(root.join("src/main.rs"), b"fn main() {}\n").unwrap();
27633        let workspace_id = WorkspaceId::new("primary").unwrap();
27634        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27635        let catalog = active_registry().unwrap();
27636        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27637        let shared = NodeShared::new(
27638            handle,
27639            "fixture-token".to_owned(),
27640            NodeId::new("node-1").unwrap(),
27641            vec![workspace],
27642            vec![agent("claude")],
27643        );
27644
27645        let response = process_request_inner(
27646            &shared,
27647            77,
27648            ClientRole::Observer,
27649            NodeRequest::InspectWorkspace {
27650                workspace_id: workspace_id.clone(),
27651            },
27652        )
27653        .await
27654        .unwrap();
27655        let NodeResponse::WorkspaceInspected { inspection } = response else {
27656            panic!("workspace inspection returned another response");
27657        };
27658        assert_eq!(inspection.workspace_id, workspace_id);
27659        assert!(inspection.entries.iter().any(|entry| {
27660            entry.relative_path.as_utf8() == Some("src/main.rs")
27661        }));
27662
27663        let unknown = process_request_inner(
27664            &shared,
27665            77,
27666            ClientRole::Observer,
27667            NodeRequest::InspectWorkspace {
27668                workspace_id: WorkspaceId::new("unknown").unwrap(),
27669            },
27670        )
27671        .await
27672        .unwrap_err();
27673        assert_eq!(unknown.code, NodeFailureCode::UnknownWorkspace);
27674        std::fs::remove_dir_all(&root).unwrap();
27675    }
27676
27677    #[test]
27678    fn context_pack_source_accepts_only_running_or_successful_exit() {
27679        assert!(context_pack_source_status_is_usable(&SessionStatus::Running));
27680        assert!(context_pack_source_status_is_usable(&SessionStatus::Exited {
27681            exit_code: Some(0),
27682        }));
27683        assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
27684            exit_code: Some(1),
27685        }));
27686        assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
27687            exit_code: None,
27688        }));
27689        assert!(!context_pack_source_status_is_usable(&SessionStatus::Failed {
27690            message: "provider failed".to_owned(),
27691        }));
27692        assert!(!context_pack_source_status_is_usable(&SessionStatus::Registered));
27693        assert!(!context_pack_source_status_is_usable(&SessionStatus::Starting));
27694        assert!(!context_pack_source_status_is_usable(&SessionStatus::Stopping));
27695    }
27696
27697    #[test]
27698    fn context_pack_repository_rejects_changed_head_with_same_git_snapshot() {
27699        let git = git_snapshot_for_parser();
27700        let first = ContextPackRepositoryObservation {
27701            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27702            git: git.clone(),
27703            files: Vec::new(),
27704        };
27705        let second = ContextPackRepositoryObservation {
27706            head: ContextPackRepositoryHead::Commit("b".repeat(40)),
27707            git,
27708            files: Vec::new(),
27709        };
27710
27711        let error = stable_context_pack_repository(first, second).unwrap_err();
27712
27713        assert_eq!(
27714            error.code,
27715            NodeFailureCode::ContextPackMaterializationFailed,
27716        );
27717    }
27718
27719    #[test]
27720    fn context_pack_repository_rejects_changed_file_with_same_git_snapshot() {
27721        let git = git_snapshot_for_parser();
27722        let first = ContextPackRepositoryObservation {
27723            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27724            git: git.clone(),
27725            files: vec![ContextPackRepositoryFileSource::utf8(
27726                "README.md",
27727                "first\n".to_owned(),
27728                6,
27729            )],
27730        };
27731        let second = ContextPackRepositoryObservation {
27732            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27733            git,
27734            files: vec![ContextPackRepositoryFileSource::utf8(
27735                "README.md",
27736                "other\n".to_owned(),
27737                6,
27738            )],
27739        };
27740
27741        let error = stable_context_pack_repository(first, second).unwrap_err();
27742
27743        assert_eq!(
27744            error.code,
27745            NodeFailureCode::ContextPackMaterializationFailed,
27746        );
27747    }
27748
27749    #[tokio::test]
27750    async fn native_session_catalog_zero_active_session_fixture() {
27751        let workspace_root = temporary_workspace_root("native-catalog-workspace");
27752        let history_root = temporary_workspace_root("native-catalog-history");
27753        let outside_root = temporary_workspace_root("native-catalog-outside");
27754        std::fs::create_dir_all(&workspace_root).unwrap();
27755        std::fs::create_dir_all(&history_root).unwrap();
27756        std::fs::create_dir_all(&outside_root).unwrap();
27757        let transcript = |session_id: &str, cwd: &Path, title: &str| {
27758            [
27759                serde_json::json!({
27760                    "type": "user",
27761                    "sessionId": session_id,
27762                    "cwd": cwd.to_str().unwrap(),
27763                    "message": { "content": "raw user text must not cross the catalog wire" }
27764                }),
27765                serde_json::json!({
27766                    "type": "assistant",
27767                    "sessionId": session_id,
27768                    "cwd": cwd.to_str().unwrap(),
27769                    "message": { "model": "claude-sonnet", "content": "raw answer" }
27770                }),
27771                serde_json::json!({
27772                    "type": "ai-title",
27773                    "sessionId": session_id,
27774                    "aiTitle": title
27775                }),
27776            ]
27777            .into_iter()
27778            .map(|record| record.to_string())
27779            .collect::<Vec<_>>()
27780            .join("\n")
27781        };
27782        std::fs::write(
27783            history_root.join("stable-session.jsonl"),
27784            transcript("stable-session", &workspace_root, "Workspace review"),
27785        )
27786        .unwrap();
27787        std::fs::write(
27788            history_root.join("outside-session.jsonl"),
27789            transcript("outside-session", &outside_root, "Outside review"),
27790        )
27791        .unwrap();
27792        let history = NativeHistoryConfig::new(vec![
27793            NativeHistoryRoot::new(
27794                gate4agent_types::AdapterId::new("claude-code").unwrap(),
27795                HistorySourceLayout::SingleNdjson,
27796                history_root.clone(),
27797            )
27798            .unwrap(),
27799        ])
27800        .unwrap();
27801        let catalog = active_registry().unwrap();
27802        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27803        assert!(handle.snapshot().sessions.is_empty());
27804        let workspace_id = WorkspaceId::new("primary").unwrap();
27805        let workspace = WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap();
27806        let mut shared = NodeShared::new(
27807            handle,
27808            "fixture-token".to_owned(),
27809            NodeId::new("node-native-catalog").unwrap(),
27810            vec![workspace],
27811            vec![agent("claude")],
27812        );
27813        shared.native_session_catalog = Some(Arc::new(Mutex::new(
27814            NativeSessionCatalogAuthority::new(history),
27815        )));
27816        let route = NativeSessionCatalogRoute::workspace(
27817            workspace_id.clone(),
27818            agent("claude"),
27819        );
27820        let request = NodeRequest::CatalogNativeSessions {
27821            route: route.clone(),
27822            limit: 2,
27823        };
27824        let observer = process_request_inner(&shared, 7, ClientRole::Observer, request.clone())
27825            .await
27826            .unwrap_err();
27827        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27828        let response = process_request_inner(&shared, 7, ClientRole::Operator, request)
27829            .await
27830            .unwrap();
27831        let NodeResponse::NativeSessionsCataloged { entries, summary, .. } = response else {
27832            panic!("native catalog returned a different response");
27833        };
27834        let summary = summary.expect("node must emit native session catalog summary metadata");
27835        assert_eq!(entries.len(), 1);
27836        assert!(gate4agent_types::validate_candidate_id(&entries[0].selection_id).is_ok());
27837        assert_eq!(entries[0].external_group, None);
27838        assert_eq!(entries[0].record_id, None);
27839        assert_eq!(entries[0].title, None);
27840        assert_eq!(entries[0].model, None);
27841        assert_eq!(entries[0].message_count, 0);
27842        let encoded_entries = serde_json::to_string(&entries).unwrap();
27843        assert!(!encoded_entries.contains("stable-session"));
27844        assert!(!encoded_entries.contains(history_root.to_string_lossy().as_ref()));
27845        let page_request = NodeRequest::PageNativeSessions {
27846            route: route.clone(),
27847            window: crate::protocol::NativeSessionCatalogWindow::Recent,
27848            catalog_revision: summary.catalog_revision,
27849            recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27850            after_selection_id: None,
27851            limit: 2,
27852        };
27853        let observer = process_request_inner(
27854            &shared,
27855            7,
27856            ClientRole::Observer,
27857            page_request.clone(),
27858        )
27859        .await
27860        .unwrap_err();
27861        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27862        let page_response = process_request_inner(
27863            &shared,
27864            7,
27865            ClientRole::Operator,
27866            page_request.clone(),
27867        )
27868        .await
27869        .unwrap();
27870        assert!(matches!(
27871            page_response,
27872            NodeResponse::NativeSessionsPaged { page, .. }
27873                if page.entries.len() == 1 && !page.has_more && page.remaining_count == 0
27874        ));
27875        let stale = process_request_inner(
27876            &shared,
27877            7,
27878            ClientRole::Operator,
27879            NodeRequest::PageNativeSessions {
27880                route: route.clone(),
27881                window: crate::protocol::NativeSessionCatalogWindow::Recent,
27882                catalog_revision: summary.catalog_revision.wrapping_add(1),
27883                recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27884                after_selection_id: None,
27885                limit: 2,
27886            },
27887        )
27888        .await
27889        .unwrap_err();
27890        assert_eq!(stale.code, NodeFailureCode::StaleNativeSessionCatalog);
27891        let selection = NativeSessionSelection {
27892            route: route.clone(),
27893            catalog_revision: summary.catalog_revision,
27894            recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27895            selection_id: entries[0].selection_id.clone(),
27896        };
27897        let preview_response = process_request_inner(
27898            &shared,
27899            7,
27900            ClientRole::Operator,
27901            NodeRequest::PreviewNativeSession {
27902                selection: selection.clone(),
27903                message_limit: 1,
27904            },
27905        )
27906        .await
27907        .unwrap();
27908        let NodeResponse::NativeSessionPreviewed { preview, .. } = preview_response else {
27909            panic!("native preview returned a different response");
27910        };
27911        assert_eq!(preview.messages.len(), 1);
27912        assert!(preview.message_count_exact);
27913        assert_eq!(preview.messages[0].text, "raw answer");
27914        assert!(preview.truncated);
27915
27916        let index_request = NodeRequest::IndexNativeSession {
27917            selection: selection.clone(),
27918            display_name: "Saved Claude".to_owned(),
27919        };
27920        let observer = process_request_inner(
27921            &shared,
27922            7,
27923            ClientRole::Observer,
27924            index_request.clone(),
27925        )
27926        .await
27927        .unwrap_err();
27928        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27929        let without_controller = process_request_inner(
27930            &shared,
27931            7,
27932            ClientRole::Operator,
27933            index_request.clone(),
27934        )
27935        .await
27936        .unwrap_err();
27937        assert_eq!(without_controller.code, NodeFailureCode::ControllerRequired);
27938        let acquired = process_request_inner(
27939            &shared,
27940            7,
27941            ClientRole::Operator,
27942            NodeRequest::AcquireController { lease_ms: 5_000 },
27943        )
27944        .await
27945        .unwrap();
27946        assert!(matches!(acquired, NodeResponse::Controller { controller: Some(_) }));
27947        let indexed = process_request_inner(
27948            &shared,
27949            7,
27950            ClientRole::Operator,
27951            index_request,
27952        )
27953        .await
27954        .unwrap();
27955        assert!(indexed.native_session_index_contract_is_valid());
27956        let NodeResponse::NativeSessionIndexed {
27957            selection: echoed_selection,
27958            record,
27959        } = indexed else {
27960            panic!("native session index returned a different response");
27961        };
27962        assert_eq!(echoed_selection, selection);
27963        let recataloged = process_request_inner(
27964            &shared,
27965            7,
27966            ClientRole::Operator,
27967            NodeRequest::CatalogNativeSessions {
27968                route,
27969                limit: 2,
27970            },
27971        )
27972        .await
27973        .unwrap();
27974        let NodeResponse::NativeSessionsCataloged { entries, .. } = recataloged else {
27975            panic!("native catalog returned a different response after indexing");
27976        };
27977        assert_eq!(entries[0].record_id.as_ref(), Some(&record.record_id));
27978
27979        let unregistered_route = NativeSessionCatalogRoute::unregistered(agent("claude"));
27980        let external_catalog = process_request_inner(
27981            &shared,
27982            7,
27983            ClientRole::Operator,
27984            NodeRequest::CatalogNativeSessions {
27985                route: unregistered_route.clone(),
27986                limit: 2,
27987            },
27988        )
27989        .await
27990        .unwrap();
27991        let external_json = serde_json::to_string(&external_catalog).unwrap();
27992        assert!(!external_json.contains("outside-session"));
27993        assert!(!external_json.contains(outside_root.to_string_lossy().as_ref()));
27994        assert!(!external_json.contains(history_root.to_string_lossy().as_ref()));
27995        assert!(!external_json.contains("\"session_id\""));
27996        assert!(!external_json.contains("\"cwd\""));
27997        assert!(!external_json.contains("\"path\""));
27998        let NodeResponse::NativeSessionsCataloged {
27999            route: echoed_route,
28000            entries: external_entries,
28001            summary: Some(external_summary),
28002        } = external_catalog
28003        else {
28004            panic!("unregistered native catalog returned a different response");
28005        };
28006        assert_eq!(echoed_route, unregistered_route);
28007        assert_eq!(external_entries.len(), 1);
28008        assert_eq!(external_entries[0].record_id, None);
28009        let external_group = external_entries[0]
28010            .external_group
28011            .as_ref()
28012            .expect("unregistered session must have an external group");
28013        assert_eq!(
28014            external_group.kind,
28015            gate4agent_types::NativeSessionExternalGroupKind::Project,
28016        );
28017        assert!(external_group.group_id.starts_with("external-"));
28018        assert_eq!(
28019            external_group.display_name,
28020            outside_root.file_name().unwrap().to_string_lossy(),
28021        );
28022        let external_selection = NativeSessionSelection {
28023            route: unregistered_route,
28024            catalog_revision: external_summary.catalog_revision,
28025            recent_cutoff_unix_ms: external_summary.recent_cutoff_unix_ms,
28026            selection_id: external_entries[0].selection_id.clone(),
28027        };
28028        let external_preview = process_request_inner(
28029            &shared,
28030            7,
28031            ClientRole::Operator,
28032            NodeRequest::PreviewNativeSession {
28033                selection: external_selection.clone(),
28034                message_limit: 1,
28035            },
28036        )
28037        .await
28038        .unwrap();
28039        let external_preview_json = serde_json::to_string(&external_preview).unwrap();
28040        assert!(!external_preview_json.contains("outside-session"));
28041        assert!(!external_preview_json.contains(outside_root.to_string_lossy().as_ref()));
28042        assert!(!external_preview_json.contains("\"session_id\""));
28043        let NodeResponse::NativeSessionPreviewed { preview, .. } = external_preview else {
28044            panic!("unregistered native preview returned a different response");
28045        };
28046        assert_eq!(preview.messages.len(), 1);
28047        let external_index = process_request_inner(
28048            &shared,
28049            7,
28050            ClientRole::Operator,
28051            NodeRequest::IndexNativeSession {
28052                selection: external_selection,
28053                display_name: "External Claude".to_owned(),
28054            },
28055        )
28056        .await
28057        .unwrap_err();
28058        assert_eq!(
28059            external_index.code,
28060            NodeFailureCode::WorkspaceRegistrationRequired,
28061        );
28062        let record_response = process_request_inner(
28063            &shared,
28064            7,
28065            ClientRole::Operator,
28066            NodeRequest::PreviewSessionRecord {
28067                record_id: record.record_id.clone(),
28068                message_limit: 2,
28069            },
28070        )
28071        .await
28072        .unwrap();
28073        let NodeResponse::SessionRecordPreviewed { record_id, preview } = record_response else {
28074            panic!("record preview returned a different response");
28075        };
28076        assert_eq!(record_id, record.record_id);
28077        assert_eq!(preview.messages.len(), 2);
28078        let encoded = serde_json::to_string(&preview).unwrap();
28079        assert!(!encoded.contains("stable-session"));
28080        assert!(!encoded.contains(history_root.to_string_lossy().as_ref()));
28081        let NodeResponse::Resync { events, .. } = shared.resync(0) else {
28082            panic!("record preview history summary returned a different response");
28083        };
28084        let summaries = events
28085            .iter()
28086            .filter_map(|envelope| match &envelope.event {
28087                NodeEvent::SessionRecordHistorySummarized { record_id, summary }
28088                    if record_id == &record.record_id =>
28089                {
28090                    Some(*summary)
28091                }
28092                _ => None,
28093            })
28094            .collect::<Vec<_>>();
28095        assert_eq!(summaries, vec![SessionHistorySummaryV1::from(&preview)]);
28096        let summary_json = serde_json::to_string(&summaries).unwrap();
28097        for private in [
28098            "stable-session",
28099            "raw user text must not cross the catalog wire",
28100            "raw answer",
28101            "Workspace review",
28102            "claude-sonnet",
28103        ] {
28104            assert!(!summary_json.contains(private), "{private}");
28105        }
28106        let identity = record.provider_session.clone().unwrap();
28107        assert_eq!(
28108            shared.revalidate_session_record_preview(&record, &identity),
28109            Ok(())
28110        );
28111        shared.remove_record_memory(&record.record_id);
28112        assert_eq!(
28113            shared
28114                .revalidate_session_record_preview(&record, &identity)
28115                .unwrap_err()
28116                .code,
28117            NodeFailureCode::UnknownSessionRecord
28118        );
28119        let released = process_request_inner(
28120            &shared,
28121            7,
28122            ClientRole::Operator,
28123            NodeRequest::ReleaseController,
28124        )
28125        .await
28126        .unwrap();
28127        assert!(matches!(released, NodeResponse::Controller { controller: None }));
28128        assert!(shared.controller_state().is_none());
28129        std::fs::remove_dir_all(workspace_root).unwrap();
28130        std::fs::remove_dir_all(history_root).unwrap();
28131        std::fs::remove_dir_all(outside_root).unwrap();
28132    }
28133
28134    #[tokio::test]
28135    async fn native_session_catalog_concurrent_operation_fails_fast_without_waiting_on_mutex() {
28136        let history_root = temporary_workspace_root("native-catalog-concurrent-operation");
28137        std::fs::create_dir_all(&history_root).unwrap();
28138        let history = NativeHistoryConfig::new(vec![
28139            NativeHistoryRoot::new(
28140                gate4agent_types::AdapterId::new("claude-code").unwrap(),
28141                HistorySourceLayout::SingleNdjson,
28142                history_root.clone(),
28143            )
28144            .unwrap(),
28145        ])
28146        .unwrap();
28147        let catalog = Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)));
28148        let held = catalog.lock().unwrap();
28149        let result = timeout(
28150            Duration::from_secs(1),
28151            run_native_session_catalog_operation(
28152                Arc::clone(&catalog),
28153                "native session catalog",
28154                |_| Ok::<(), std::convert::Infallible>(()),
28155            ),
28156        )
28157        .await
28158        .expect("concurrent native catalog request must not wait for the operation deadline")
28159        .unwrap_err();
28160        assert_eq!(result.code, NodeFailureCode::BackendBusy);
28161        drop(held);
28162        std::fs::remove_dir_all(history_root).unwrap();
28163    }
28164
28165    fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
28166        let unique = std::time::SystemTime::now()
28167            .duration_since(std::time::UNIX_EPOCH)
28168            .unwrap()
28169            .as_nanos();
28170        std::env::temp_dir().join(format!(
28171            "gate4agent-node-{label}-{}-{unique}",
28172            std::process::id(),
28173        ))
28174    }
28175
28176    fn git_snapshot_for_parser() -> GitSnapshot {
28177        GitSnapshot {
28178            is_repository: true,
28179            branch: Some("main".to_owned()),
28180            status: Vec::new(),
28181            recent_commits: Vec::new(),
28182            worktrees: Vec::new(),
28183            managed_worktree: None,
28184            truncated: false,
28185            diagnostic: None,
28186        }
28187    }
28188
28189    #[tokio::test]
28190    async fn workspace_inspection_scopes_only_exact_active_managed_branch() {
28191        let root = temporary_workspace_root("managed-git-scope");
28192        std::fs::create_dir_all(&root).unwrap();
28193        run_test_git(&root, &["init"]);
28194        run_test_git(&root, &["config", "user.email", "gate4agent@example.invalid"]);
28195        run_test_git(&root, &["config", "user.name", "Gate4Agent Test"]);
28196        std::fs::write(root.join("seed.txt"), "seed\n").unwrap();
28197        run_test_git(&root, &["add", "seed.txt"]);
28198        run_test_git(&root, &["commit", "-m", "seed"]);
28199        run_test_git(&root, &["checkout", "-b", "gate4agent/a"]);
28200
28201        let workspace_id = WorkspaceId::new("managed-a").unwrap();
28202        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
28203        let catalog = active_registry().unwrap();
28204        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28205        let shared = NodeShared::new(
28206            handle,
28207            "fixture-token".to_owned(),
28208            NodeId::new("node-1").unwrap(),
28209            vec![workspace],
28210            vec![agent("claude")],
28211        );
28212        let mut lease = ManagedWorktreeLeaseRecord {
28213            lease_id: ManagedWorktreeLeaseId::new("mw-a").unwrap(),
28214            source_workspace_id: WorkspaceId::new("primary").unwrap(),
28215            workspace_id: workspace_id.clone(),
28216            profile_id: WorktreeProfileId::new("default").unwrap(),
28217            profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
28218            target_root: root.to_string_lossy().into_owned(),
28219            branch: "gate4agent/a".to_owned(),
28220            base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
28221            expected_head: None,
28222            retention: ManagedWorktreeRetention::Retain,
28223            state: ManagedWorktreeLeaseState::InUse,
28224            session_holders: vec![ManagedWorktreeSessionHolder {
28225                incarnation_id: shared.incarnation_id,
28226                instance_id: AgentInstanceId(7),
28227                generation: SessionGeneration(1),
28228            }],
28229            record_holders: Vec::new(),
28230            cleanup_failure: None,
28231            created_at_unix_ms: 1,
28232            updated_at_unix_ms: 1,
28233        };
28234        *shared
28235            .managed_worktrees
28236            .lock()
28237            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
28238            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
28239
28240        let inspection = shared.inspect_workspace(workspace_id.clone()).await.unwrap();
28241        let scope = inspection.git.managed_worktree.unwrap();
28242        assert_eq!(scope.lease_id, lease.lease_id);
28243        assert_eq!(scope.branch, "gate4agent/a");
28244        assert_eq!(scope.active_session_count, 1);
28245
28246        lease.branch = "gate4agent/b".to_owned();
28247        *shared
28248            .managed_worktrees
28249            .lock()
28250            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
28251            ManagedWorktreeRegistry::from_records(vec![lease], Vec::new()).unwrap();
28252        let mismatch = shared.inspect_workspace(workspace_id).await.unwrap();
28253        assert!(mismatch.git.managed_worktree.is_none());
28254        std::fs::remove_dir_all(root).unwrap();
28255    }
28256
28257    #[tokio::test]
28258    async fn workspace_file_write_requires_controller_and_rejects_stale_revision() {
28259        let root = temporary_workspace_root("workspace-file-write-controller");
28260        std::fs::create_dir_all(root.join("src")).unwrap();
28261        std::fs::write(root.join("src/lib.rs"), b"old\n").unwrap();
28262        let workspace_id = WorkspaceId::new("primary").unwrap();
28263        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
28264        let catalog = active_registry().unwrap();
28265        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28266        let shared = NodeShared::new(
28267            handle,
28268            "fixture-token".to_owned(),
28269            NodeId::new("node-1").unwrap(),
28270            vec![workspace],
28271            vec![agent("claude")],
28272        );
28273        let path = RepositoryPath::utf8("src/lib.rs".to_owned()).unwrap();
28274        let expected_revision = workspace_file_revision(b"old\n");
28275        let request = NodeRequest::WriteWorkspaceFile {
28276            workspace_id: workspace_id.clone(),
28277            path: path.clone(),
28278            expected_revision: expected_revision.clone(),
28279            text: "new\n".to_owned(),
28280        };
28281        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
28282            .await
28283            .unwrap_err();
28284        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
28285        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
28286            .await
28287            .unwrap_err();
28288        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
28289        shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
28290        let response = process_request_inner(&shared, 77, ClientRole::Operator, request).await.unwrap();
28291        let NodeResponse::WorkspaceFileWritten { file } = response else {
28292            panic!("workspace file write returned another response");
28293        };
28294        assert_eq!(file.revision, Some(workspace_file_revision(b"new\n")));
28295        assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
28296        let stale = process_request_inner(
28297            &shared,
28298            77,
28299            ClientRole::Operator,
28300            NodeRequest::WriteWorkspaceFile {
28301                workspace_id,
28302                path,
28303                expected_revision,
28304                text: "lost update\n".to_owned(),
28305            },
28306        )
28307        .await
28308        .unwrap_err();
28309        assert_eq!(stale.code, NodeFailureCode::RepositoryFileRevisionConflict);
28310        assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
28311        std::fs::remove_dir_all(root).unwrap();
28312    }
28313
28314    #[tokio::test]
28315    async fn workspace_entry_create_requires_controller_and_never_overwrites() {
28316        let root = temporary_workspace_root("workspace-entry-create-controller");
28317        std::fs::create_dir_all(root.join("src")).unwrap();
28318        let workspace_id = WorkspaceId::new("primary").unwrap();
28319        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
28320        let catalog = active_registry().unwrap();
28321        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28322        let shared = NodeShared::new(
28323            handle,
28324            "fixture-token".to_owned(),
28325            NodeId::new("node-1").unwrap(),
28326            vec![workspace],
28327            vec![agent("claude")],
28328        );
28329        let path = RepositoryPath::utf8("src/new.rs".to_owned()).unwrap();
28330        let request = NodeRequest::CreateWorkspaceFile {
28331            workspace_id: workspace_id.clone(),
28332            path: path.clone(),
28333        };
28334        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
28335            capability.as_str() == NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY
28336        }));
28337        assert!(!request_uses_unnegotiated_capability(
28338            &request,
28339            &baseline_capabilities().unwrap(),
28340        ));
28341        assert!(request_uses_unnegotiated_capability(&request, &[]));
28342        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
28343            .await
28344            .unwrap_err();
28345        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
28346        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
28347            .await
28348            .unwrap_err();
28349        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
28350        shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
28351
28352        let response = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
28353            .await
28354            .unwrap();
28355        let NodeResponse::WorkspaceFileCreated { file } = response else {
28356            panic!("workspace file create returned another response");
28357        };
28358        assert_eq!(file.workspace_id, workspace_id);
28359        assert_eq!(file.path, path);
28360        assert_eq!(file.revision, Some(workspace_file_revision(&[])));
28361        assert_eq!(std::fs::read(root.join("src/new.rs")).unwrap(), Vec::<u8>::new());
28362
28363        let duplicate = process_request_inner(&shared, 77, ClientRole::Operator, request)
28364            .await
28365            .unwrap_err();
28366        assert_eq!(duplicate.code, NodeFailureCode::RepositoryEntryAlreadyExists);
28367
28368        let directory_path = RepositoryPath::utf8("src/nested".to_owned()).unwrap();
28369        let response = process_request_inner(
28370            &shared,
28371            77,
28372            ClientRole::Operator,
28373            NodeRequest::CreateWorkspaceDirectory {
28374                workspace_id: workspace_id.clone(),
28375                path: directory_path.clone(),
28376            },
28377        )
28378        .await
28379        .unwrap();
28380        assert_eq!(
28381            response,
28382            NodeResponse::WorkspaceDirectoryCreated {
28383                workspace_id: workspace_id.clone(),
28384                entry: WorkspaceEntry {
28385                    relative_path: directory_path,
28386                    kind: WorkspaceEntryKind::Directory,
28387                },
28388            },
28389        );
28390        assert!(root.join("src/nested").is_dir());
28391
28392        let missing_parent = process_request_inner(
28393            &shared,
28394            77,
28395            ClientRole::Operator,
28396            NodeRequest::CreateWorkspaceFile {
28397                workspace_id: workspace_id.clone(),
28398                path: RepositoryPath::utf8("missing/new.rs".to_owned()).unwrap(),
28399            },
28400        )
28401        .await
28402        .unwrap_err();
28403        assert_eq!(missing_parent.code, NodeFailureCode::RepositoryParentNotFound);
28404        let non_directory_parent = process_request_inner(
28405            &shared,
28406            77,
28407            ClientRole::Operator,
28408            NodeRequest::CreateWorkspaceFile {
28409                workspace_id,
28410                path: RepositoryPath::utf8("src/new.rs/child".to_owned()).unwrap(),
28411            },
28412        )
28413        .await
28414        .unwrap_err();
28415        assert_eq!(
28416            non_directory_parent.code,
28417            NodeFailureCode::RepositoryParentNotDirectory,
28418        );
28419        std::fs::remove_dir_all(root).unwrap();
28420    }
28421
28422    #[tokio::test]
28423    async fn workspace_entry_create_timeout_cancels_before_commit_without_late_mutation() {
28424        let root = temporary_workspace_root("workspace-entry-create-timeout-cancel");
28425        std::fs::create_dir_all(root.join("src")).unwrap();
28426        #[cfg(windows)]
28427        let root = std::fs::canonicalize(root).unwrap();
28428        let path = RepositoryPath::utf8("src/late.rs".to_owned()).unwrap();
28429        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
28430        let barrier = Arc::new(std::sync::Barrier::new(2));
28431        let done = Arc::new(AtomicBool::new(false));
28432        let task_commit_state = Arc::clone(&commit_state);
28433        let task_barrier = Arc::clone(&barrier);
28434        let task_done = Arc::clone(&done);
28435        let task_root = root.clone();
28436        let task = tokio::task::spawn_blocking(move || {
28437            task_barrier.wait();
28438            let result = create_workspace_file_on_disk(
28439                &task_root,
28440                &path,
28441                &task_commit_state,
28442            );
28443            task_done.store(true, Ordering::Release);
28444            result
28445        });
28446
28447        let error = settle_workspace_entry_create(
28448            task,
28449            Arc::clone(&commit_state),
28450            Duration::from_millis(10),
28451            "fixture deadline",
28452            "fixture task failed",
28453        )
28454        .await
28455        .unwrap_err();
28456        assert_eq!(error.code, NodeFailureCode::RepositoryEntryCreateTimedOut);
28457        assert_eq!(
28458            commit_state.load(Ordering::Acquire),
28459            WORKSPACE_ENTRY_CREATE_CANCELED,
28460        );
28461
28462        barrier.wait();
28463        timeout(Duration::from_secs(1), async {
28464            while !done.load(Ordering::Acquire) {
28465                tokio::task::yield_now().await;
28466            }
28467        })
28468        .await
28469        .expect("canceled blocking create did not settle");
28470        assert!(!root.join("src/late.rs").exists());
28471        std::fs::remove_dir_all(root).unwrap();
28472    }
28473}
28474
28475#[cfg(test)]
28476mod provisional_spawn_tests {
28477    use super::*;
28478    use std::sync::Arc;
28479
28480    fn shared() -> NodeShared {
28481        let catalog = active_registry().unwrap();
28482        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28483        let workspace = WorkspaceConfig::new(
28484            WorkspaceId::new("primary").unwrap(),
28485            std::env::current_dir().unwrap(),
28486        )
28487        .unwrap()
28488        .with_worktree_service_mode(WorktreeServiceMode::Manual);
28489        NodeShared::new(
28490            handle,
28491            "fixture-token".to_owned(),
28492            NodeId::new("node-provisional-test").unwrap(),
28493            vec![workspace],
28494            vec![AgentId::new("claude").unwrap()],
28495        )
28496    }
28497
28498    /// Provisional draft map: sessionize by instance id only; 50 sequential
28499    /// mark/claim cycles never collide, and a claimed draft is never reaped.
28500    #[test]
28501    fn provisional_spawn_keys_are_unique_and_sessionize_prevents_reap() {
28502        let shared = shared();
28503        let ws = WorkspaceId::new("primary").unwrap();
28504        let mut ids = Vec::with_capacity(50);
28505        for i in 1u64..=50 {
28506            let id = AgentInstanceId(i);
28507            ids.push(id);
28508            let address = SessionAddress {
28509                workspace_id: ws.clone(),
28510                session: SessionKey {
28511                    instance_id: id,
28512                    generation: SessionGeneration(1),
28513                },
28514            };
28515            shared.mark_spawn_provisional(address);
28516        }
28517        assert_eq!(
28518            shared
28519                .provisional_spawns
28520                .lock()
28521                .unwrap_or_else(|p| p.into_inner())
28522                .len(),
28523            50
28524        );
28525        for id in ids.iter().step_by(2) {
28526            shared.sessionize_spawn(*id);
28527        }
28528        assert_eq!(
28529            shared
28530                .provisional_spawns
28531                .lock()
28532                .unwrap_or_else(|p| p.into_inner())
28533                .len(),
28534            25
28535        );
28536        assert!(shared.take_due_provisional_spawns().is_empty());
28537        {
28538            let mut map = shared
28539                .provisional_spawns
28540                .lock()
28541                .unwrap_or_else(|p| p.into_inner());
28542            let past = Instant::now() - Duration::from_secs(91);
28543            for (_, (_, at)) in map.iter_mut() {
28544                *at = past;
28545            }
28546        }
28547        let due = shared.take_due_provisional_spawns();
28548        assert_eq!(due.len(), 25);
28549        let due_ids: std::collections::BTreeSet<_> =
28550            due.iter().map(|a| a.session.instance_id.0).collect();
28551        let expected: std::collections::BTreeSet<_> =
28552            ids.iter().skip(1).step_by(2).map(|id| id.0).collect();
28553        assert_eq!(due_ids, expected);
28554        assert!(shared
28555            .provisional_spawns
28556            .lock()
28557            .unwrap_or_else(|p| p.into_inner())
28558            .is_empty());
28559    }
28560
28561    /// Twenty parallel provisional marks must each get their own instance key;
28562    /// killing/sessionizing one id must not touch the others.
28563    #[test]
28564    fn provisional_spawn_parallel_marks_isolate_kills_by_instance_id() {
28565        let shared = Arc::new(shared());
28566        let ws = WorkspaceId::new("primary").unwrap();
28567        let mut handles = Vec::new();
28568        for i in 1u64..=20 {
28569            let shared = Arc::clone(&shared);
28570            let ws = ws.clone();
28571            handles.push(std::thread::spawn(move || {
28572                let id = AgentInstanceId(i);
28573                let address = SessionAddress {
28574                    workspace_id: ws,
28575                    session: SessionKey {
28576                        instance_id: id,
28577                        generation: SessionGeneration(1),
28578                    },
28579                };
28580                shared.mark_spawn_provisional(address);
28581                id
28582            }));
28583        }
28584        let mut ids = Vec::new();
28585        for h in handles {
28586            ids.push(h.join().unwrap());
28587        }
28588        ids.sort_by_key(|id| id.0);
28589        assert_eq!(ids.len(), 20);
28590        ids.dedup();
28591        assert_eq!(ids.len(), 20, "parallel marks must not share instance keys");
28592        assert_eq!(
28593            shared
28594                .provisional_spawns
28595                .lock()
28596                .unwrap_or_else(|p| p.into_inner())
28597                .len(),
28598            20
28599        );
28600        shared.sessionize_spawn(AgentInstanceId(7));
28601        let left: Vec<_> = shared
28602            .provisional_spawns
28603            .lock()
28604            .unwrap_or_else(|p| p.into_inner())
28605            .keys()
28606            .map(|id| id.0)
28607            .collect();
28608        assert!(!left.contains(&7));
28609        assert_eq!(left.len(), 19);
28610    }
28611}
28612
28613
28614#[cfg(unix)]
28615extern "C" {
28616    #[link_name = "geteuid"]
28617    fn libc_geteuid() -> u32;
28618}
28619
28620#[cfg(test)]
28621mod call_home_link_tests {
28622    use super::*;
28623
28624    #[test]
28625    fn call_home_backoff_is_capped_and_monotonic() {
28626        let mut last = Duration::ZERO;
28627        for attempt in 1..20 {
28628            let next = call_home_backoff(attempt);
28629            assert!(next >= last);
28630            assert!(next <= Duration::from_secs(8));
28631            last = next;
28632        }
28633        assert_eq!(call_home_backoff(0), Duration::from_millis(500));
28634    }
28635
28636    #[tokio::test]
28637    async fn call_home_socket_gets_keepalive() {
28638        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
28639        let addr = listener.local_addr().unwrap();
28640        let (client, _server) = tokio::join!(tokio::net::TcpStream::connect(addr), listener.accept());
28641        let client = client.unwrap();
28642        enable_call_home_keepalive(&client).unwrap();
28643        let sock = socket2::SockRef::from(&client);
28644        assert!(sock.keepalive().unwrap());
28645        #[cfg(any(target_os = "linux", target_os = "macos"))]
28646        assert_eq!(sock.tcp_keepalive_time().unwrap(), CALL_HOME_KEEPALIVE_IDLE);
28647    }
28648}
28649
28650/// A provider transcript for this session id exists in a known provider home.
28651fn provider_session_exists(provider: &str, id: &str) -> bool {
28652    if provider != "claude" { return false; }
28653    let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
28654    if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") { homes.extend(std::env::split_paths(&extra)); }
28655    let name = format!("{id}.jsonl");
28656    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())))
28657}
28658
28659/// Append launch args to a prepared overlay (or create an instance overlay for them).
28660fn overlay_with_extra_args(
28661    provider: &AgentId,
28662    mode: SessionMode,
28663    overlay: Option<PreparedNativeLaunchOverlay>,
28664    args: Vec<OsString>,
28665) -> Result<Option<PreparedNativeLaunchOverlay>, NodeFailure> {
28666    let bad = || failure(NodeFailureCode::BackendOperationFailed, "resume launch args overlay is invalid");
28667    Ok(match (overlay, args) {
28668        (prepared, args) if args.is_empty() => prepared,
28669        (Some(PreparedNativeLaunchOverlay::Instance(mut o)), args) => { o.append_extra_args(args).map_err(|_| bad())?; Some(PreparedNativeLaunchOverlay::Instance(o)) }
28670        (Some(PreparedNativeLaunchOverlay::Environment(o)), args) => Some(PreparedNativeLaunchOverlay::Instance(o.into_instance_with_extra_args(args).map_err(|_| bad())?)),
28671        (None, args) => Some(PreparedNativeLaunchOverlay::Instance(NativeInstanceLaunchOverlay::new(
28672            provider.clone(),
28673            match mode { SessionMode::Pty => TransportKind::Pty, SessionMode::Acp => TransportKind::Acp, SessionMode::Inline => TransportKind::Pipe },
28674            Vec::new(),
28675            args,
28676        ).map_err(|_| bad())?)),
28677    })
28678}
28679
28680/// Log a provider-error decision; escalations and limits go to core as session signals.
28681fn provider_error_act(workspace: &str, instance: u64, provider: &str, via: &str, act: crate::provider_error::Action) {
28682    use crate::provider_error::Action;
28683    match act {
28684        Action::Retry { attempt, delay } => tracing::info!(target: "provider_retry", instance_id = instance, provider, via, attempt, max = crate::provider_error::MAX_ATTEMPTS, delay_ms = delay.as_millis() as u64, "provider error: retry scheduled"),
28685        Action::Limit { found } => {
28686            tracing::info!(target: "provider_retry", instance_id = instance, provider, via, "provider error: limit, no retry");
28687            if via == "acp" {
28688                crate::session_signals::emit(workspace, instance, provider, crate::session_signals::Signal::Limit { hint: found.hint });
28689            }
28690        }
28691        Action::Escalate { found, attempts } => {
28692            tracing::warn!(target: "provider_retry", instance_id = instance, provider, via, class = found.class.as_str(), code = ?found.code, attempts, "provider error: escalated");
28693            crate::session_signals::emit(workspace, instance, provider, crate::session_signals::Signal::ProviderError { class: found.class.as_str(), code: found.code, attempts, hint: found.hint });
28694        }
28695    }
28696}
28697
28698/// Turn trace in the node log for ACP sessions (start, tools, permission asks, end, errors):
28699/// names and sizes only, never prompt, tool input or output text.
28700fn trace_acp_turn(address: &SessionAddress, event: &ControlEvent) {
28701    let id = address.session.instance_id.0;
28702    let ControlEventKind::ProviderEvent { event: e, .. } = &event.event else { return };
28703    match e {
28704        ProviderEvent::TurnStarted { prompt } => tracing::info!(instance_id = id, prompt_chars = prompt.as_deref().map(str::len).unwrap_or(0), "acp turn started"),
28705        ProviderEvent::ToolStarted { name, .. } => tracing::info!(instance_id = id, tool = %name, "acp tool call started"),
28706        ProviderEvent::ToolCompleted { is_error, duration_ms, non_execution_kind, .. } => tracing::info!(instance_id = id, is_error, duration_ms = ?duration_ms, not_run = ?non_execution_kind, "acp tool call finished"),
28707        ProviderEvent::InteractionRequested { interaction_kind, tool_name, options, .. } => tracing::info!(instance_id = id, kind = ?interaction_kind, tool = %tool_name, options = options.len(), "acp permission/interaction requested"),
28708        ProviderEvent::TurnInterrupted => tracing::info!(instance_id = id, "acp turn interrupted"),
28709        ProviderEvent::SessionEnded { is_error, stop_reason, .. } => tracing::info!(instance_id = id, is_error, stop_reason = ?stop_reason, "acp turn ended"),
28710        ProviderEvent::Error { message } => tracing::warn!(instance_id = id, error = %message.chars().take(200).collect::<String>(), "acp provider error"),
28711        _ => {}
28712    }
28713}