Skip to main content

hatchery_node/
server.rs

1use crate::git_worktree::{
2    create_worktree as create_git_worktree,
3    create_worktree_with_timeout as create_git_worktree_with_timeout,
4    list_worktrees as list_git_worktrees,
5    list_worktrees_with_deadline as list_git_worktrees_with_deadline,
6    paths_equal as worktree_paths_equal, remove_worktree as remove_git_worktree,
7    removal_lookup_path as normalize_worktree_removal_target,
8    resolve_base_commit_with_timeout,
9    run_git_read_bounded, GitCommandOutput, GitWorktreeError, GitWorktreeErrorKind,
10    NativeGitWorktreeSnapshot,
11};
12use crate::host_directory_browser::{
13    browse_host_directories, HostDirectoryBrowseError, HostDirectoryBrowseErrorKind,
14};
15use crate::environment_profiles::{
16    EnvironmentProfileBinding, NodeEnvironmentProfile, MAX_NODE_ENVIRONMENT_PROFILES,
17};
18use crate::bundle_catalog::{BundleCatalog, NodeBundle};
19use self::bundle_delivery::{DeliveryStore, DeliveryStoreError};
20use self::context_pack_store::ContextPackStore;
21use crate::bundle_provider::{
22    bundle_launch_arguments, validate_bundle_binding, BundleProviderLayout,
23};
24use crate::context_pack::{
25    ContextPackCatalog, ContextPackRepository, ContextPackRepositoryFileSource,
26    ContextPackSelectedFileSkipReason, NodeContextPack, CONTEXT_PACK_SELECTED_FILES,
27};
28use crate::harness_mcp_proxy::{
29    HarnessMcpProxyError, HarnessMcpProxyRegistry, PreparedHarnessMcpSpawn,
30    ReviewedHarnessMcpProgram,
31};
32use crate::session_environment::{
33    MaterializationId, MaterializationOwner, MaterializationOwnershipRecord,
34    MaterializationState, NodeSecretResolver, NodeSessionMaterializationProfile,
35    SessionEnvironmentMaterializeError, SessionEnvironmentMaterializer,
36};
37use crate::worktree_service::{
38    exact_created_worktree, exact_owned_worktree, ManagedWorktreeLeaseRecord,
39    ManagedWorktreeProfile, ManagedWorktreeRegistry, ManagedWorktreeSessionHolder,
40};
41use crate::session_registry::{
42    self, validate_display_name, LoadedNodeState, ManagedWorktreeSpawnReplayRecordV10,
43    ManagedWorktreeSpawnReplayStateV10, MAX_MANAGED_SESSION_RECORDS,
44    MAX_MANAGED_WORKTREE_SPAWN_REPLAYS,
45};
46#[cfg(unix)]
47use crate::workspace_file_unix::{
48    create_workspace_directory as create_workspace_directory_on_disk,
49    create_workspace_file as create_workspace_file_on_disk,
50    read_workspace_file as read_workspace_file_from_disk,
51    write_workspace_file as write_workspace_file_to_disk, WorkspaceFileBytes,
52    WorkspaceFileReadError, WorkspaceFileReadErrorKind,
53    WORKSPACE_ENTRY_CREATE_CANCELED, WORKSPACE_ENTRY_CREATE_COMMITTING,
54    WORKSPACE_ENTRY_CREATE_PENDING,
55};
56#[cfg(windows)]
57use crate::workspace_file_windows::{
58    create_workspace_directory as create_workspace_directory_on_disk,
59    create_workspace_file as create_workspace_file_on_disk,
60    read_workspace_file as read_workspace_file_from_disk,
61    write_workspace_file as write_workspace_file_to_disk, WorkspaceFileBytes,
62    WorkspaceFileReadError, WorkspaceFileReadErrorKind,
63    WORKSPACE_ENTRY_CREATE_CANCELED, WORKSPACE_ENTRY_CREATE_COMMITTING,
64    WORKSPACE_ENTRY_CREATE_PENDING,
65};
66use crate::platform;
67use crate::provider_runtime::{
68    require_policy, ProviderRuntimeAdmissionError, ProviderRuntimeMonitor,
69    ProviderRuntimeRequirement,
70};
71use crate::spawn_spec::SpawnProfileRegistry;
72use crate::standalone_workspace::{
73    prepare_standalone_workspace, StandaloneWorkspaceError, StandaloneWorkspaceErrorKind,
74};
75use crate::protocol::{
76    read_json_frame_limited_body_timeout, write_json_frame, write_json_frame_limited,
77    validate_node_negotiated_handshake_capacity, validate_provider_contract_manifest,
78    provider_id_is_legacy, AdapterContractRevision, AgentStreamChunkKindV1, AgentStreamChunkV1,
79    AgentStreamInteractionOptionV1, AgentStreamNamedIdV1,
80    CapabilityId, ClientFrame, ClientRole,
81    ControllerState, DeliveryBlobChunkHexV1, DeliveryBlobDigestV1,
82    DeliveryBundleManifestV2, DeliveryCommitReceiptV1, DeliveryStageId,
83    AgentProgressAttentionKindV1, AgentProgressAttentionV1, AgentProgressCurrentV1,
84    AgentProgressEventKindV1, AgentProgressUsageV1, AgentProgressV1, FrameError,
85    GitCommitDetails, GitDiff, GitDiffMode, GitDiffRequest, GitHistoryPage,
86    GitObjectId, GitCommitSummary, GitSignatureStatus, GitSnapshot, GitStatusEntry,
87    GitWorktreeSnapshot, HarnessMcpActivationDigest, HarnessMcpReservationId,
88    HostDirectoryListing,
89    NodeCompatibilitySupport, NodeEvent,
90    NodeEventEnvelope, NodeFailure, NodeFailureCode, NodeHello, NodeId, NodeIncarnationId,
91    NegotiatedNodeCompatibility, NodeRequest, NodeResponse, NodeSnapshot, OpaqueHostPath,
92    ProtocolRange,
93    ProviderAdapterContractSupport,
94    ProviderContractRevision, ProviderContractSupport, RepositoryPath, RequestEnvelope,
95    ProviderRuntimeStatuses, ResolvedBundleReceipt, ResolvedEnvironmentProfileReceipt,
96    ContextPackLineageReceipt, ContextPackBytesRead, ResolvedContextPackReceipt,
97    ResolvedHarnessMcpProxyReceiptV1, ResolvedSpawnReceipt,
98    ResolvedSpawnSpec, ResponseEnvelope,
99    ServerChallenge, ServerFrame, SessionAddress, SessionAgentProgress, SessionKey, SessionMode,
100    ManagedSessionRecord,
101    ManagedSessionState, ManagedWorktreeCleanupFailure, ManagedWorktreeLeaseId,
102    SessionRecordRetentionConfig,
103    ManagedWorktreeLeaseSnapshot, ManagedWorktreeLeaseState, ManagedWorktreeRetention,
104    ManagedWorktreeSpawnReceipt, ManagedWorktreeSpawnRequest,
105    ManagedWorktreeSpawnRequestV2, SessionRecordId,
106    NativeSessionCatalogRoute, NativeSessionSelection, SessionTaskBindingV1,
107    SessionTaskTargetV1, TaskId,
108    BlockAuthorityV1, HarnessMcpLaunchV1, SessionHistorySummaryV1, MAX_ACP_BLOCKED_HELP_BYTES, MAX_ACP_BLOCKED_REASON_BYTES,
109    StateSchemaSupport, WorkspaceEntry, WorkspaceEntryKind, WorktreeProfileId,
110    SpawnContextId, SpawnEnvironmentProfileId, SpawnIdempotencyKey,
111    SpawnProfileDefaults, SpawnRequiredCapabilities, SpawnSpec, SpawnSpecResolveError,
112    WorkspaceFileContent, WorkspaceFileRevision,
113    WorkspaceFileRead, WorkspaceId, WorkspaceInspection, WorkspaceInspectionTruncationV1,
114    WorkspaceSnapshot,
115    WorktreeServiceMode,
116    LaunchInventory, ManagedWorktreeProfileSummary, SpawnProfileSummary,
117    WorktreeProfileInventory, MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
118    DEFAULT_CONTROLLER_LEASE_MS,
119    MAX_CONTROLLER_LEASE_MS, MIN_CONTROLLER_LEASE_MS, NODE_COMPATIBILITY_METADATA_CAPABILITY,
120    CAPABILITY_HOST_DIRECTORY_BROWSE_V1,
121    NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY, NODE_REPOSITORY_PATH_CAPABILITY,
122    NODE_PROVIDER_ID_OPEN_CAPABILITY,
123    NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
124    NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY, NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
125    NODE_HISTORY_CONTEXT_PACK_CAPABILITY, NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
126    NODE_NATIVE_SESSION_CATALOG_CAPABILITY, NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
127    NODE_NATIVE_SESSION_INDEX_CAPABILITY, NODE_NATIVE_SESSION_PREVIEW_CAPABILITY,
128    NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
129    NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY,
130    NODE_HARNESS_MCP_READ_PROXY_CAPABILITY,
131    NODE_SESSION_TASK_CORRELATION_CAPABILITY,
132    NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY,
133    NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
134    NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
135    NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
136    NODE_SPAWN_PROFILE_REVISION_CAPABILITY, NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
137    NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
138    NODE_AGENT_STREAM_EVENTS_CAPABILITY, NODE_ACP_CONTROL_CAPABILITY,
139    NODE_GIT_READ_CAPABILITY, NODE_WORKSPACE_FILE_READ_CAPABILITY,
140    NODE_WORKSPACE_FILE_WRITE_CAPABILITY, NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY,
141    NODE_WORKTREE_SELECTION_CAPABILITY,
142    BUILD_STAMP, MAX_NODE_CLIENT_FRAME_BYTES, MAX_NODE_HELLO_FRAME_BYTES,
143    MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS, MAX_AGENT_PROGRESS_ENTRY_BYTES,
144    MAX_NODE_TERMINAL_BYTES, MAX_NODE_TEXT_BYTES,
145    MAX_REPOSITORY_PATH_BYTES,
146    MAX_GIT_DIFF_BYTES, MAX_GIT_HISTORY_COMMITS, MAX_WORKSPACE_FILE_BYTES,
147    MAX_HARNESS_MCP_PENDING_CALLS_PER_NODE,
148    MAX_WORKSPACE_ROOT_BYTES, NODE_STATE_SCHEMA_V1, NODE_STATE_SCHEMA_V10,
149    SPAWN_RUNTIME_PROVIDER_SESSION_IDENTITY, SPAWN_RUNTIME_RAW_PTY_LIFECYCLE,
150    SPAWN_RUNTIME_SEMANTIC_READINESS, SPAWN_RUNTIME_SEMANTIC_RESUME,
151    SPAWN_RUNTIME_STRUCTURED_PROMPT,
152};
153#[cfg(test)]
154use crate::protocol::WorktreeProfileRevision;
155use ring::digest::{digest, SHA256};
156use crate::protocol::correlation::{
157    interaction_correlation, sanitize_progress_tool_label, tool_class_label, tool_correlation,
158    truncate_text,
159};
160use gate4agent_catalog::{builtin_registry, AgentRegistry};
161use gate4agent_handle::{EventSubscription, Gate4AgentHandle, PortDispatchError};
162use gate4agent_runtime_native::{
163    NativeMcpServerLaunchOverlay, NativeInstanceLaunchOverlay,
164    NativeLaunchEnvironmentOverlay,
165    NativeHistoryConfig, NativeLaunchProfileControl, NativeRuntime, NativeRuntimeConfig,
166    NativeSessionCatalogAuthority, NativeSessionCatalogError, NativeSessionPreviewError,
167    ScopedNativeSessionCatalogEntry,
168};
169use gate4agent_runtime_native::shell_efficiency::ShellEfficiencyProfile;
170use std::sync::OnceLock;
171use gate4agent_runtime_native::tick_profile::TickProfileSnapshot;
172use drive_loop_profile::DriveLoopProfiler;
173#[cfg(test)]
174use gate4agent_runtime_native::{HistorySourceLayout, NativeHistoryRoot};
175use gate4agent_node_wire::{
176    auth_proof, negotiated_auth_proof, proofs_match, random_incarnation_id, random_nonce,
177    AuthDirection, OwnerOnlyLocalListener,
178};
179use gate4agent_types::{
180    AdapterBinding, AdapterFamily, AgentAdapterCapabilities, AgentCapabilities, AgentId,
181    AgentInstanceId, AgentReadinessSpec, AgentSpec, AgentTransportCapabilities, ApprovalLevel,
182    DetectionSpec, InitialPromptMode, LaunchSpec, ProcessMatcher, PromptSpec, SpecVerification,
183    CommandEnvelope, CommandId,
184    validate_candidate_id, ControlCommand, ControlEvent, ControlEventKind,
185    HistoryCandidateSummary, HistoryOperation, HistoryQuery, HistorySessionRecord,
186    HostDecisionAuthority as ProviderHostDecisionAuthority,
187    HostRequestDecision as ProviderHostRequestDecision,
188    InputAction,
189    PromptFraming, PromptPayload, ResumeLaunchRequest,
190    ProviderActivity, ProviderEvent, ProviderInteractionKind, ProviderInteractionStatus,
191    ProviderRuntimeCapability, ProviderRuntimePolicy, ProviderSessionIdentity, ProviderSessionKey,
192    ProviderSnapshot, ProviderStopReason,
193    ResumeTarget,
194    SessionGeneration, StartRequest,
195    TerminalControl, TerminalText,
196    SessionStatus, TerminalFrame, TransportKind, CONTROL_SESSIONS_MAX,
197    HISTORY_DISCOVERY_LIMIT_MAX, WORKING_DIRECTORY_MAX_BYTES,
198};
199use std::collections::{BTreeMap, VecDeque};
200use std::ffi::OsString;
201use std::io;
202use std::path::{Path, PathBuf};
203use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, AtomicUsize, Ordering};
204use std::sync::{Arc, Mutex, RwLock};
205use std::time::{Duration, Instant};
206use thiserror::Error;
207use tokio::sync::{broadcast, mpsc, Mutex as AsyncMutex, Notify, OwnedSemaphorePermit, Semaphore};
208use tokio::task::{AbortHandle, JoinSet};
209use tokio::time::{sleep, timeout};
210
211mod http_api;
212pub mod backend_paths;
213pub mod node_wake;
214
215tokio::task_local! {
216    /// Launch options (model / effort) of a `SpawnWithOptions` request, scoped around that
217    /// spawn so the provider's start carries them (g4a maps them to the CLI's own flags).
218    static SPAWN_SESSION_OPTIONS: Option<gate4agent_types::SessionOptionSelection>;
219}
220
221/// `SpawnSessionOptions` -> g4a selection. Effort needs a model (g4a options hang off a model).
222fn spawn_selection(
223    options: &gate4agent_node_protocol::SpawnSessionOptions,
224) -> Result<Option<gate4agent_types::SessionOptionSelection>, NodeFailure> {
225    options.validate().map_err(|e| failure(NodeFailureCode::InvalidRequest, e))?;
226    match (&options.model, &options.effort) {
227        (None, None) => Ok(None),
228        (None, Some(_)) => Err(failure(NodeFailureCode::InvalidRequest, "spawn effort needs a model")),
229        (Some(model), effort) => {
230            let mut sel = gate4agent_types::SessionOptionSelection::new(model.clone());
231            if let Some(e) = effort {
232                sel = sel.with_value("effort", e.clone());
233            }
234            Ok(Some(sel))
235        }
236    }
237}
238
239mod browser_bridge;
240
241use gate4agent_node_core::drive_loop_profile;
242
243#[path = "bundle_delivery.rs"]
244mod bundle_delivery;
245
246#[path = "context_pack_store.rs"]
247mod context_pack_store;
248
249#[cfg(windows)]
250pub use crate::platform::DEFAULT_NODE_ENDPOINT;
251
252const NODE_EVENT_HISTORY_MAX: usize = 4_096;
253const NODE_BROADCAST_CAPACITY: usize = 1_024;
254const NODE_TERMINAL_BROADCAST_CAPACITY: usize = 1;
255/// Unlike `NODE_TERMINAL_BROADCAST_CAPACITY`'s single replaceable slot (a
256/// terminal frame is a full-screen snapshot; only the latest ever matters),
257/// an `AgentStreamChunkV1` is one delta in an ordered sequence -- collapsing
258/// two pending sends into one would silently drop content a subscriber can
259/// never recover. This needs headroom for a burst instead.
260const NODE_AGENT_STREAM_BROADCAST_CAPACITY: usize = 256;
261/// The node never has more than `MAX_HARNESS_MCP_PENDING_CALLS_PER_NODE`
262/// `NodeEvent::HarnessMcpReadCall`s in flight at once (`begin_call`'s own
263/// admission check), and `publish_transient` sends each exactly once as a
264/// single-envelope batch -- so this many slots covers the entire pending
265/// set without ever lagging the one subscriber that matters, the harness's
266/// own connection, the way sharing the durable `event_tx`'s bus could.
267const NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY: usize = MAX_HARNESS_MCP_PENDING_CALLS_PER_NODE;
268const NODE_CONNECTION_EVENT_BURST_MAX: usize = 16;
269const CONTROL_EVENT_SUBSCRIPTION_CAPACITY: usize = 1_024;
270const MAX_PREAUTH_CONNECTIONS: usize = 32;
271const MAX_AUTHENTICATED_CONNECTIONS: usize = 16;
272const AUTH_FRAME_TIMEOUT_MS: u64 = 5_000;
273const FRAME_BODY_TIMEOUT_MS: u64 = 5_000;
274const CONNECTION_SHUTDOWN_GRACE_MS: u64 = 250;
275const SPAWN_DISPATCH_TIMEOUT_MS: u64 = 2_000;
276/// Fresh Spawn stays provisional until HQ successfully attaches (first Resize /
277/// TerminalBytes). This is an *attach-never-arrived* timeout from spawn time —
278/// not the disconnect reconnect grace (HQ owns that, starting only on a real
279/// client WS close). A provider conversation id is a session; a PTY with no
280/// attached HQ view is still a draft and is reaped here if attach never lands.
281const PROVISIONAL_SPAWN_GRACE_MS: u64 = 30_000;
282/// How often `drive_runtime_until_shutdown`'s loop re-evaluates the session
283/// record retention policy (`NodeServerConfig::session_record_retention`).
284/// A sweep cadence, not a policy value -- the policy itself lives entirely
285/// in the operator-configured age/keep-N flags; this just bounds how stale
286/// the in-memory view of dead `Unavailable` records can get between sweeps
287/// on an otherwise-idle node, mirroring the once-per-startup sweep that
288/// also runs alongside `reconcile_managed_worktrees`.
289const SESSION_RECORD_RETENTION_SWEEP_PERIOD: Duration = Duration::from_secs(5 * 60);
290/// TTL for `NodeShared::rejected_command_reasons` entries -- see that
291/// field's own doc for why a spawn waiter needs this at all. Must outlive
292/// the longest a waiter can still be polling for this exact command's
293/// outcome: `gate4agent_node_protocol::MAX_SPAWN_DEADLINE_MS` (120s) is the
294/// largest `deadline_ms` any caller may request, and the margin on top
295/// absorbs the waiter's own poll cadence plus clock skew between the drive
296/// loop (writer) and the waiter (reader).
297const REJECTED_COMMAND_REASON_TTL_MS: u64 = 150_000;
298/// Bounds `NodeShared::rejected_command_reasons` against an unbounded
299/// backlog of asynchronously-rejected commands nobody ever collects (a
300/// waiter that stops polling, or a burst of always-rejected commands from a
301/// misbehaving caller): once at capacity, the oldest entry is evicted to
302/// make room for the newest, mirroring the eviction rule already used by
303/// `spawn_idempotency`.
304const REJECTED_COMMAND_REASON_MAX_ENTRIES: usize = 256;
305/// How long `wait_until_removed` waits between two `Remove` re-dispatches
306/// for the same session. The presence poll runs far faster (2ms); this
307/// only paces the sending, so an accepted `Remove` still in flight is not
308/// piled on by a dozen more that land on an already-dropped instance.
309const REMOVE_REDISPATCH_INTERVAL_MS: u64 = 100;
310const SPAWN_IDEMPOTENCY_MAX_ENTRIES: usize = 256;
311const SPAWN_IDEMPOTENCY_TTL_MS: u64 = 15 * 60 * 1_000;
312const PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS: u64 = 2_500;
313const MUTATION_SETTLE_TIMEOUT_MS: u64 = 5_000;
314const READINESS_SETTLE_HEADROOM_MS: u64 = 2_000;
315const MANAGED_RESUME_SETTLE_TIMEOUT_MS: u64 = 30_000;
316const WORKSPACE_INSPECTION_MAX_CONCURRENCY: usize = 4;
317const WORKSPACE_FILE_READ_TIMEOUT_MS: u64 = 2_000;
318const HOST_DIRECTORY_BROWSE_TIMEOUT_MS: u64 = 2_000;
319const NATIVE_SESSION_CATALOG_TIMEOUT_MS: u64 = 30_000;
320const WORKSPACE_TREE_MAX_DEPTH: usize = 6;
321const WORKSPACE_TREE_MAX_ENTRIES: usize = 512;
322/// Default total wall-clock budget for `inspect_workspace`'s walk+git
323/// chain, overridable via `GATE4AGENT_NODE_WORKSPACE_INSPECTION_BUDGET_MS`.
324const WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT: u64 = 8_000;
325/// The harness relay enforces its own flat response deadline
326/// (`HOST_RUN_READ_RESPONSE_DEADLINE`, `hatchery-harness-service::runtime`,
327/// 12s) above this node-side budget. The two constants live in different
328/// crates with no shared definition, so clamp any override here comfortably
329/// below that external ceiling — a misconfigured value above it would
330/// reopen the exact deadline race this budget exists to close.
331const WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX: u64 = 11_000;
332/// Default cap on directory entries visited (not just returned) by one
333/// walk, overridable via `GATE4AGENT_NODE_WORKSPACE_INSPECTION_ENTRY_CAP`.
334const WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT: usize = 50_000;
335const WORKSPACE_INSPECTION_ENTRY_CAP_MAX: usize = 1_000_000;
336const GIT_STATUS_MAX_ENTRIES: usize = 128;
337const GIT_COMMIT_MAX_ENTRIES: usize = 12;
338const GIT_OUTPUT_MAX_BYTES: usize = 64 * 1_024;
339const GIT_DIAGNOSTIC_MAX_BYTES: usize = 1_024;
340const WORKSPACE_FILE_WRITE_TIMEOUT_MS: u64 = 5_000;
341const WORKSPACE_ENTRY_CREATE_TIMEOUT_MS: u64 = 5_000;
342const GIT_COMMAND_TIMEOUT_MS: u64 = 1_500;
343const WORKSPACE_UNAVAILABLE_ERROR: &str = "workspace-unavailable";
344const PROVIDER_SESSION_SCOPE_CONFLICT_ERROR: &str = "provider-session-scope-conflict";
345const PROVIDER_SESSION_LIVE_CONFLICT_ERROR: &str = "provider-session-live-conflict";
346const MANAGED_SESSION_CAPACITY_ERROR: &str = "managed-session-capacity";
347const PROVIDER_IDENTITY_ALLOCATION_ERROR: &str =
348    "provider-session-identity-allocation-failed";
349const PROVIDER_RESUME_REJECTED_ERROR: &str = "provider-resume-rejected";
350const DURABLE_STATE_COMMIT_FAILED_ERROR: &str = "durable-state-commit-failed";
351const DURABLE_STATE_LOCK_FAILED_ERROR: &str = "durable-state-lock-failed";
352const DURABLE_STATE_LOAD_FAILED_ERROR: &str = "durable-state-load-failed";
353const DURABLE_STATE_CONFLICT_ERROR: &str = "durable-state-conflict";
354const DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR: &str = "durable-state-schema-unsupported";
355const DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR: &str =
356    "durable-state-path-semantics-unsupported";
357
358fn opaque_windows_path(value: String) -> OpaqueHostPath {
359    OpaqueHostPath::utf8(value).expect("validated Windows node path must remain wire-safe")
360}
361
362fn agent_progress_from_provider_snapshot(
363    address: SessionAddress,
364    provider: &ProviderSnapshot,
365) -> Option<SessionAgentProgress> {
366    let mut truncated = false;
367    let active_tool_count = bounded_progress_count(provider.active_tools.len(), &mut truncated);
368    let mut active_tool_labels = Vec::with_capacity(
369        provider
370            .active_tools
371            .len()
372            .min(MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS),
373    );
374    for tool in &provider.active_tools {
375        if active_tool_labels.len() == MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS {
376            truncated = true;
377            break;
378        }
379        if let Some(label) = sanitize_progress_tool_label(&tool.name, &mut truncated) {
380            active_tool_labels.push(label);
381        }
382    }
383    let attention = provider
384        .interactions
385        .iter()
386        .find(|interaction| {
387            matches!(
388                interaction.status,
389                ProviderInteractionStatus::Pending | ProviderInteractionStatus::Resolving { .. }
390            )
391        })
392        .map(|interaction| AgentProgressAttentionV1 {
393            kind: match interaction.interaction_kind {
394                ProviderInteractionKind::Approval => AgentProgressAttentionKindV1::Approval,
395                ProviderInteractionKind::Question => AgentProgressAttentionKindV1::Question,
396            },
397            tool_label: sanitize_progress_tool_label(&interaction.tool_name, &mut truncated),
398        });
399    let subagent_count = bounded_progress_count(provider.subagents.len(), &mut truncated);
400    let usage = (provider.completed_turns > 0
401        || provider.usage.input_tokens > 0
402        || provider.usage.output_tokens > 0
403        || provider.usage.cache_read_tokens > 0
404        || provider.usage.cache_write_tokens > 0
405        || provider.usage.reasoning_tokens > 0)
406        .then_some(AgentProgressUsageV1 {
407            input_tokens: provider.usage.input_tokens,
408            output_tokens: provider.usage.output_tokens,
409            cache_read_tokens: provider.usage.cache_read_tokens,
410            cache_write_tokens: provider.usage.cache_write_tokens,
411            reasoning_tokens: provider.usage.reasoning_tokens,
412        });
413    let progress = AgentProgressV1 {
414        provider_sequence: provider.sequence,
415        activity: provider.activity,
416        completed_turns: provider.completed_turns,
417        usage,
418        current: AgentProgressCurrentV1::from(provider.activity),
419        active_tool_labels,
420        active_tool_count,
421        attention,
422        subagent_count,
423        last_event_kind: provider.last_event.as_ref().and_then(agent_progress_event_kind),
424        gap_count: provider.gap_count,
425        stale: provider.stale,
426        truncated,
427    };
428    let entry = SessionAgentProgress { address, progress };
429    serde_json::to_vec(&entry)
430        .ok()
431        .filter(|encoded| encoded.len() <= MAX_AGENT_PROGRESS_ENTRY_BYTES)
432        .map(|_| entry)
433}
434
435fn bounded_progress_count(count: usize, truncated: &mut bool) -> u32 {
436    match u32::try_from(count) {
437        Ok(count) => count,
438        Err(_) => {
439            *truncated = true;
440            u32::MAX
441        }
442    }
443}
444
445fn agent_progress_event_kind(event: &ProviderEvent) -> Option<AgentProgressEventKindV1> {
446    if matches!(event, ProviderEvent::ContextWindowUsage { .. }) {
447        return None;
448    }
449    Some(match event {
450        ProviderEvent::SessionStarted { .. } => AgentProgressEventKindV1::SessionStarted,
451        ProviderEvent::SessionIdentityObserved { .. } => {
452            AgentProgressEventKindV1::SessionIdentityObserved
453        }
454        ProviderEvent::TurnStarted { .. } => AgentProgressEventKindV1::TurnStarted,
455        ProviderEvent::WorkingObserved => AgentProgressEventKindV1::WorkingObserved,
456        ProviderEvent::Text { .. } => AgentProgressEventKindV1::Text,
457        ProviderEvent::Thinking { .. } => AgentProgressEventKindV1::Thinking,
458        ProviderEvent::ToolStarted { .. } => AgentProgressEventKindV1::ToolStarted,
459        ProviderEvent::ToolCompleted { .. } => AgentProgressEventKindV1::ToolCompleted,
460        ProviderEvent::TurnCompleted { .. } => AgentProgressEventKindV1::TurnCompleted,
461        ProviderEvent::TurnInterrupted => AgentProgressEventKindV1::TurnInterrupted,
462        ProviderEvent::SessionEnded { .. } => AgentProgressEventKindV1::SessionEnded,
463        ProviderEvent::Error { .. } => AgentProgressEventKindV1::Error,
464        ProviderEvent::Ready => AgentProgressEventKindV1::Ready,
465        ProviderEvent::InteractionRequested { .. } => {
466            AgentProgressEventKindV1::InteractionRequested
467        }
468        ProviderEvent::InteractionResolved { .. } => AgentProgressEventKindV1::InteractionResolved,
469        ProviderEvent::SubagentStarted { .. } => AgentProgressEventKindV1::SubagentStarted,
470        ProviderEvent::SubagentStopped { .. } => AgentProgressEventKindV1::SubagentStopped,
471        ProviderEvent::RateLimited { .. } => AgentProgressEventKindV1::RateLimited,
472        ProviderEvent::HostRequestObserved { .. } => AgentProgressEventKindV1::HostRequestObserved,
473        ProviderEvent::UnrecognizedNotification { .. } => {
474            AgentProgressEventKindV1::UnrecognizedNotification
475        }
476        ProviderEvent::ContextWindowUsage { .. } => unreachable!("handled above"),
477        // ACP session/update coverage beyond text/tool/turn streaming
478        // (`plan`, `available_commands_update`, `current_mode_update`,
479        // `session_info_update`, `usage_update`, `config_option_update`,
480        // `user_message_chunk`). `AgentProgressEventKindV1` is a versioned
481        // wire enum without a `#[serde(other)]` fallback; minting new
482        // variants for it is a deliberate wire-contract decision for
483        // whoever owns this protocol, not a side effect of parsing more of
484        // ACP's own wire -- until that decision is made, these fold into
485        // the same "we saw something, no dedicated kind for it yet" bucket
486        // as `UnrecognizedNotification`.
487        ProviderEvent::UserMessage { .. }
488        | ProviderEvent::Plan { .. }
489        | ProviderEvent::AvailableCommandsUpdated { .. }
490        | ProviderEvent::ModeChanged { .. }
491        | ProviderEvent::SessionInfoUpdated { .. }
492        | ProviderEvent::UsageUpdated { .. }
493        | ProviderEvent::ConfigOptionsUpdated { .. } => {
494            AgentProgressEventKindV1::UnrecognizedNotification
495        }
496    })
497}
498
499/// Maps this harness's own `HostDecisionAuthority` onto the wider
500/// Maps a host request decider onto the `BlockAuthorityV1` a `Blocked` stream
501/// chunk carries -- the four authorities a `Denied` decision is ever made
502/// under, one-for-one. The remaining `BlockAuthorityV1` variants
503/// (`ProviderClassifier`, `ProviderPermissionRule`, `ProviderSandbox`,
504/// `ProviderRefusal`, `UserRejected`, `ProviderQuota`, `Unknown`) describe a
505/// block the PROVIDER side of the wire reported -- this function never
506/// produces one, because `ProviderHostDecisionAuthority` has no vocabulary for
507/// them; the provider-side blocks below mint those directly from typed fields.
508/// Whether a provider source is a live session transport -- the only kind
509/// whose events reach the agent stream. History, resume, session-option and
510/// capability-probe sources are not sessions and stream nothing.
511fn provider_family_is_live(family: AdapterFamily) -> bool {
512    match family {
513        AdapterFamily::PtySemantic
514        | AdapterFamily::Hook
515        | AdapterFamily::ManagedHook
516        | AdapterFamily::Pipe
517        | AdapterFamily::OneShot
518        | AdapterFamily::Acp => true,
519        AdapterFamily::History
520        | AdapterFamily::Resume
521        | AdapterFamily::SessionOptions
522        | AdapterFamily::CapabilityProbe => false,
523    }
524}
525
526fn block_authority(by: ProviderHostDecisionAuthority) -> BlockAuthorityV1 {
527    match by {
528        ProviderHostDecisionAuthority::Gate => BlockAuthorityV1::HostGate,
529        ProviderHostDecisionAuthority::Policy => BlockAuthorityV1::HostPolicy,
530        ProviderHostDecisionAuthority::Operator => BlockAuthorityV1::Operator,
531        ProviderHostDecisionAuthority::DeadlinePolicy => BlockAuthorityV1::HostDeadline,
532    }
533}
534
535/// The `Blocked::reason` for a `Denied` decision this build's own
536/// `AgentEvent::RpcIncomingRequest::reason` did not attach text to --
537/// reachable today for `Operator`/`HostDeadline`: a deferred `session/
538/// request_permission` request resolved by `AcpSession::
539/// resolve_pending_request`/`expire_deadlines` (`src/acp/session.rs`),
540/// neither of which computes free text (an operator answers approve/reject,
541/// never a comment; an unattended deadline asks nobody anything). `reason`
542/// is a REQUIRED field on the wire (see `AgentStreamChunkKindV1::Blocked`)
543/// -- a categorical fallback naming the authority is the
544/// honest minimum, never a placeholder standing in for detail nobody
545/// reported.
546fn action_blocked_fallback_reason(authority: BlockAuthorityV1) -> &'static str {
547    match authority {
548        BlockAuthorityV1::HostGate => "denied by the dangerous-command gate",
549        BlockAuthorityV1::HostPolicy => "denied by host policy",
550        BlockAuthorityV1::HostDeadline => {
551            "denied by host policy after the operator deadline expired"
552        }
553        BlockAuthorityV1::Operator => "declined by the operator",
554        BlockAuthorityV1::ProviderClassifier => "denied by the provider's classifier",
555        BlockAuthorityV1::ProviderPermissionRule => "denied by a provider permission rule",
556        BlockAuthorityV1::ProviderSandbox => "denied by the provider's sandbox",
557        BlockAuthorityV1::ProviderRefusal => "refused by the provider",
558        BlockAuthorityV1::UserRejected => "rejected by the user",
559        BlockAuthorityV1::ProviderQuota => "the provider's quota or rate limit was exhausted",
560        BlockAuthorityV1::Unknown => "blocked; no reason was reported",
561    }
562}
563
564/// Computes `AgentStreamChunkKindV1::Blocked`'s `tool_class`/`authority`/`reason`
565/// for a `HostRequestObserved` `Denied` decision.
566fn host_request_denied_block(
567    method: &str,
568    by: ProviderHostDecisionAuthority,
569    reason: Option<&str>,
570) -> (String, BlockAuthorityV1, String) {
571    let authority = block_authority(by);
572    let reason_source = reason.unwrap_or_else(|| action_blocked_fallback_reason(authority));
573    // `AgentStreamChunkKindV1::validate` REJECTS an over-long reason outright
574    // rather than truncating -- this cut happens here, at the producer, before
575    // the validator ever sees the text.
576    let (reason_text, _) =
577        truncate_text(reason_source, MAX_ACP_BLOCKED_REASON_BYTES);
578    (tool_class_label(method), authority, reason_text)
579}
580
581// ---------------------------------------------------------------------------
582// K2 -- provider-side blocks read off ACP `tool_call_update._meta.
583// nonExecutionKind` (`ProviderEvent::ToolCompleted::non_execution_kind`) and
584// `SessionEnded::stop_reason` (`stopReason: "refusal"`, or a `session/
585// prompt` RPC error whose vendor code names a quota/limit/rate
586// exhaustion). See
587// `docs/gate4agent/plans/gate4agent-blocked-action-event-2026-09-02.md`
588// §3 slice K2.
589// ---------------------------------------------------------------------------
590
591/// Classifies ACP's `nonExecutionKind` into the typed authority a block
592/// this build mints under, or `None` when the tool truly did not run for a
593/// reason that is NOT a block -- `"interrupted"`/`"cancelled"` mean the
594/// TURN stopped (session closed, the operator cancelled), not that a
595/// policy/classifier/rule refused the call, so those (and any unrecognized
596/// future value) stay a plain `ToolCompleted { success: false }` only, per
597/// this plan's own rule that a tool failure is never inflated into a block
598/// this build cannot actually attribute.
599fn provider_tool_block_authority(kind: Option<&str>) -> Option<(BlockAuthorityV1, &'static str)> {
600    match kind? {
601        "user-rejected" => Some((BlockAuthorityV1::UserRejected, "user-rejected")),
602        "permission-rule" => Some((BlockAuthorityV1::ProviderPermissionRule, "permission-rule")),
603        _ => None,
604    }
605}
606
607/// Splits a provider's own denial sentence into `reason`/`help`, when it
608/// matches the fixed shape Claude Code's classifier/permission-rule denial
609/// uses -- verified live, the owner's sample fixture
610/// (`docs/gate4agent/research/gate4agent-blocked-action-signals-2026-09-02.md`
611/// appendix): "...denied by <authority>. Reason: <reason>. <help...>".
612/// `authority` is NEVER derived from this text (see this module's own rule
613/// that authority comes only from a typed field) -- only `reason`/`help`
614/// are. Falls back to the full text as `reason` with no `help` when the
615/// shape does not match (a provider whose text does not carry this exact
616/// wording) -- the honest minimum: the tool truly did not run, so SOME
617/// reason is always reported, never fabricated detail beyond what arrived.
618fn split_provider_denial_text(text: &str) -> (String, Option<String>) {
619    let trimmed = text.trim();
620    let Some((_, after_reason)) = trimmed.split_once("Reason:") else {
621        return (trimmed.to_owned(), None);
622    };
623    let tail = after_reason.trim_start();
624    let Some((reason, help)) = tail.split_once(". ") else {
625        // "Reason:" present but no ". " to split help off -- the whole
626        // tail is the reason, e.g. "Reason: Blocked by classifier." with
627        // no help text following on some providers/versions.
628        let reason = tail.strip_suffix('.').unwrap_or(tail).trim();
629        return (reason.to_owned(), None);
630    };
631    let help = help.trim();
632    if help.is_empty() {
633        (reason.trim().to_owned(), None)
634    } else {
635        (reason.trim().to_owned(), Some(help.to_owned()))
636    }
637}
638
639/// `tool_class` for a provider-side `ActionBlocked` this build has no typed
640/// tool name for -- `ProviderEvent::ToolCompleted` carries only `id`, never
641/// `name` (see `ProviderEvent::ToolStarted`, which does, but a tool's own
642/// name is never threaded through to its completion). Reads the class off
643/// the guidance tail's own "add a <Class> permission rule" sentence when
644/// present (the shape the owner's sample fixture uses) -- text matching
645/// fills `tool_class` here, same as `reason`/`help`, NEVER `authority` (see
646/// this module's own rule). Falls back to the same generic `"Tool"` class
647/// `ToolCompleted`'s own observation already uses when nothing names one.
648fn tool_class_from_denial_help(help: Option<&str>) -> String {
649    help.and_then(|text| text.split("add a ").nth(1))
650        .and_then(|tail| tail.split(' ').next())
651        .filter(|word| !word.is_empty())
652        .map(str::to_owned)
653        .unwrap_or_else(|| "Tool".to_owned())
654}
655
656/// Whether a `session/prompt` RPC error's vendor code (or, absent one, its
657/// message) names a quota/rate-limit exhaustion -- Codex's
658/// `data.codexErrorInfo: "usageLimitExceeded"` (measured live 2026-09-05)
659/// contains "limit", the word this build actually keys on; "quota"/"rate"
660/// are kept for whichever OTHER vendor's own code names one of those
661/// instead, never confirmed live, but the honest generalization of the
662/// one confirmed sample.
663fn provider_error_is_quota(vendor_code: Option<&str>, message: &str) -> bool {
664    fn names_quota(s: &str) -> bool {
665        let lower = s.to_ascii_lowercase();
666        lower.contains("limit") || lower.contains("quota") || lower.contains("rate")
667    }
668    vendor_code.is_some_and(names_quota) || names_quota(message)
669}
670
671/// The `Blocked` chunk this build mints when a turn stopped via a
672/// typed provider fact rather than a tool call -- `stopReason: "refusal"`
673/// (`authority: ProviderRefusal`) or a `session/prompt` RPC error whose
674/// vendor code (or message) names a quota/limit/rate exhaustion
675/// (`authority: ProviderQuota`, `reason` = the error message verbatim,
676/// `reason_kind` = the vendor code). `None` for every other stop --
677/// `end_turn`, `max_tokens`, `max_turn_requests`, `cancelled`, an untagged
678/// `Other`, or an RPC error whose vendor code/message names none of the
679/// three words: `SessionEnded` stays a plain `Exited`/turn-end fact for
680/// those, never inflated into a block this build cannot actually
681/// attribute. `ProviderRefusal`'s `reason` has no source text to read on
682/// `SessionEnded` itself (ACP's `refusal` stopReason carries none) -- the
683/// honest minimum names the fact that happened, never invented detail; a
684/// later slice that threads the turn's last agent text chunk through can
685/// replace it without changing this function's contract.
686fn provider_session_end_block(
687    stop_reason: &ProviderStopReason,
688) -> Option<(BlockAuthorityV1, String, String)> {
689    match stop_reason {
690        ProviderStopReason::Refusal => Some((
691            BlockAuthorityV1::ProviderRefusal,
692            "refusal".to_owned(),
693            "the provider ended the turn with stopReason: refusal".to_owned(),
694        )),
695        ProviderStopReason::ProviderError { message, vendor_code, .. }
696            if provider_error_is_quota(vendor_code.as_deref(), message) =>
697        {
698            let reason_kind = vendor_code.clone().unwrap_or_else(|| "provider-error".to_owned());
699            Some((BlockAuthorityV1::ProviderQuota, reason_kind, message.clone()))
700        }
701        _ => None,
702    }
703}
704
705/// Builds the `AgentStreamChunkV1` `event`'s `ProviderEvent` carries onto the
706/// `agent-stream-events-v1` channel -- see
707/// `docs/gate4agent/plans/gate4agent-acp-control-plane-on-the-wire-2026-09-02.md`
708/// §5. Two invariants keep the chunk correlatable with the `NodeEvent::Control`
709/// event it derives from, so a client joining the two sees one id and one
710/// ordering:
711///
712/// - `source_sequence` on the chunk is the `source_sequence` the provider
713///   event itself carries.
714/// - `InteractionPrompt::correlation_id` is minted by
715///   `correlation::interaction_correlation` with the
716///   `(instance_id, generation, provider_sequence)` triple -- never the
717///   provider's own `request_id`, whose opacity on the wire is pinned by a
718///   dedicated test.
719///
720/// Returns `None` for every `ControlEventKind` other than `ProviderEvent`
721/// (nothing else carries stream content), for a source that is not a live
722/// session transport, and for the `ProviderEvent` variants below with no
723/// declared chunk kind to carry them, or whose declared chunk kind this build
724/// has no source for yet:
725///
726/// - `ModelCatalog { current, available }` has no `ProviderEvent` source at
727///   all -- grok's vendor model switch is not bridged into `ProviderEvent`
728///   yet, the same gap the plan names explicitly.
729/// - `UserMessage { text, is_delta }` is an echo of the OPERATOR's own
730///   prompt, replayed on session resume -- `Text`/`Thinking` are documented
731///   as "what the agent is saying", so mapping the operator's words onto
732///   `Text` would misrepresent whose content it is. No dedicated
733///   user-message kind is declared, so this stays unproduced rather than
734///   mislabeled.
735///
736/// `HostRequestObserved { .. }` is its own arm below: a `Granted`/`Deferred`
737/// decision returns `None` (nothing for the operator to react to), a `Denied`
738/// decision returns `Some(Blocked { .. })`.
739///
740/// `ToolCompleted { .. }`/`SessionEnded { .. }` are likewise their own arms
741/// below (K2): each returns `None` unless
742/// `provider_tool_block_authority`/`provider_session_end_block` classifies the
743/// event as an actual block (ACP's `_meta.nonExecutionKind`, or `stopReason`/an
744/// RPC error naming a quota).
745fn agent_stream_chunk(event: &ControlEvent) -> Option<AgentStreamChunkV1> {
746    let ControlEventKind::ProviderEvent {
747        sequence: provider_sequence,
748        source,
749        source_sequence,
750        event: provider_event,
751    } = &event.event
752    else {
753        return None;
754    };
755    if !provider_family_is_live(source.family) {
756        return None;
757    }
758    let kind = match provider_event {
759        ProviderEvent::Text { text, is_delta } => AgentStreamChunkKindV1::Text {
760            text: text.clone(),
761            is_delta: *is_delta,
762        },
763        ProviderEvent::Thinking { text } => {
764            AgentStreamChunkKindV1::Thinking { text: text.clone() }
765        }
766        ProviderEvent::InteractionRequested {
767            interaction_kind,
768            tool_name,
769            title,
770            prompt,
771            options,
772            ..
773        } => AgentStreamChunkKindV1::InteractionPrompt {
774            correlation_id: interaction_correlation(
775                event.instance_id,
776                event.generation,
777                *provider_sequence,
778            ),
779            interaction_kind: *interaction_kind,
780            tool_name: tool_name.clone(),
781            // `title`/`options` are carried straight through from
782            // `ProviderEvent::InteractionRequested`, as the agent itself
783            // gave them on `session/request_permission`'s `PermissionTool
784            // Call.title`/`options` (see `gate4agent-shell-native`'s
785            // `provider_event`, the only ACP-transport producer). A PTY- or
786            // hook-sourced interaction carries `title: None` and
787            // `options: []` from its own producer because that source has
788            // none to offer, never because this mapping dropped one.
789            title: title.clone(),
790            prompt: prompt.clone(),
791            options: options
792                .iter()
793                .map(|option| AgentStreamInteractionOptionV1 {
794                    option_id: option.option_id.clone(),
795                    name: option.name.clone(),
796                    kind: option.kind.clone(),
797                })
798                .collect(),
799        },
800        ProviderEvent::ConfigOptionsUpdated { options } => {
801            AgentStreamChunkKindV1::ConfigOptions { options: options.clone() }
802        }
803        // `available` is the mode catalogue `AcpSession::available_modes()`
804        // read back at `session/new` and threaded through by
805        // `gate4agent-shell-native`'s `provider_event` (see that crate's
806        // `ProviderEvent::ModeChanged` arm) -- an empty list here is the
807        // agent's own honest "no modes announced", never "not read": ACP
808        // orders `session/new` before any `session/update`, and the
809        // catalogue is fixed at handshake, so a `ModeChanged` event cannot
810        // exist before the same session object's catalogue is already
811        // populated (see `gate4agent_types::ProviderEvent::ModeChanged`'s
812        // own doc comment for the ordering argument in full).
813        ProviderEvent::ModeChanged { mode_id, available } => AgentStreamChunkKindV1::ModeCatalog {
814            current: Some(mode_id.clone()),
815            available: available
816                .iter()
817                .map(|mode| AgentStreamNamedIdV1 {
818                    id: mode.id.clone(),
819                    name: mode.name.clone(),
820                    description: mode.description.clone(),
821                })
822                .collect(),
823        },
824        // A denied host request is a block -- see this function's own doc
825        // comment for why `HostRequestObserved` is not in the exclusion list
826        // below.
827        ProviderEvent::HostRequestObserved { method, decision, reason, .. } => {
828            let ProviderHostRequestDecision::Denied { by } = decision else {
829                return None;
830            };
831            let (tool_class, authority, reason_text) =
832                host_request_denied_block(method, *by, reason.as_deref());
833            AgentStreamChunkKindV1::Blocked {
834                correlation_id: None,
835                tool_class,
836                authority,
837                reason_kind: None,
838                reason: reason_text,
839                help: None,
840            }
841        }
842        // Same relationship as `HostRequestObserved` above, driven by ACP's
843        // own `_meta.nonExecutionKind` instead of the host's
844        // `HostRequestDecision`.
845        ProviderEvent::ToolCompleted { id, output, non_execution_kind, .. } => {
846            let (authority, reason_kind) =
847                provider_tool_block_authority(non_execution_kind.as_deref())?;
848            let (reason, help) = split_provider_denial_text(output);
849            let (reason, _) =
850                truncate_text(&reason, MAX_ACP_BLOCKED_REASON_BYTES);
851            let help = help.map(|help| {
852                truncate_text(&help, MAX_ACP_BLOCKED_HELP_BYTES).0
853            });
854            AgentStreamChunkKindV1::Blocked {
855                correlation_id: Some(tool_correlation(event, source, id)),
856                tool_class: tool_class_from_denial_help(help.as_deref()),
857                authority,
858                reason_kind: Some(reason_kind.to_owned()),
859                reason,
860                help,
861            }
862        }
863        // Same relationship, driven by `stopReason: "refusal"` or a
864        // `session/prompt` RPC error naming a quota/limit/rate exhaustion
865        // instead of a tool-level fact.
866        ProviderEvent::SessionEnded { stop_reason, .. } => {
867            let (authority, reason_kind, reason) =
868                stop_reason.as_ref().and_then(provider_session_end_block)?;
869            let (reason, _) =
870                truncate_text(&reason, MAX_ACP_BLOCKED_REASON_BYTES);
871            AgentStreamChunkKindV1::Blocked {
872                correlation_id: None,
873                tool_class: "Session".to_owned(),
874                authority,
875                reason_kind: Some(reason_kind),
876                reason,
877                help: None,
878            }
879        }
880        ProviderEvent::SessionStarted { .. }
881        | ProviderEvent::SessionIdentityObserved { .. }
882        | ProviderEvent::TurnStarted { .. }
883        | ProviderEvent::WorkingObserved
884        | ProviderEvent::ToolStarted { .. }
885        | ProviderEvent::TurnCompleted { .. }
886        | ProviderEvent::ContextWindowUsage { .. }
887        | ProviderEvent::TurnInterrupted
888        | ProviderEvent::Error { .. }
889        | ProviderEvent::Ready
890        | ProviderEvent::InteractionResolved { .. }
891        | ProviderEvent::SubagentStarted { .. }
892        | ProviderEvent::SubagentStopped { .. }
893        | ProviderEvent::RateLimited { .. }
894        | ProviderEvent::UnrecognizedNotification { .. }
895        | ProviderEvent::UserMessage { .. }
896        | ProviderEvent::Plan { .. }
897        | ProviderEvent::AvailableCommandsUpdated { .. }
898        | ProviderEvent::SessionInfoUpdated { .. }
899        | ProviderEvent::UsageUpdated { .. } => return None,
900    };
901    Some(AgentStreamChunkV1 { source_sequence: *source_sequence, kind })
902}
903
904async fn run_native_session_catalog_operation<T, E, F>(
905    catalog: Arc<Mutex<NativeSessionCatalogAuthority>>,
906    operation: &'static str,
907    execute: F,
908) -> Result<T, NodeFailure>
909where
910    T: Send + 'static,
911    E: std::fmt::Display + Send + 'static,
912    F: FnOnce(&mut NativeSessionCatalogAuthority) -> Result<T, E> + Send + 'static,
913{
914    let task = tokio::task::spawn_blocking(move || {
915        let mut catalog = match catalog.try_lock() {
916            Ok(catalog) => catalog,
917            Err(std::sync::TryLockError::Poisoned(poisoned)) => poisoned.into_inner(),
918            Err(std::sync::TryLockError::WouldBlock) => {
919                return Err(failure(
920                    NodeFailureCode::BackendBusy,
921                    "native session catalog operation is already running",
922                ));
923            }
924        };
925        execute(&mut catalog).map_err(|error| {
926            failure(
927                NodeFailureCode::BackendOperationFailed,
928                &format!("{operation} failed: {error}"),
929            )
930        })
931    });
932    timeout(Duration::from_millis(NATIVE_SESSION_CATALOG_TIMEOUT_MS), task)
933        .await
934        .map_err(|_| {
935            failure(
936                NodeFailureCode::BackendBusy,
937                &format!("{operation} exceeded its bounded deadline"),
938            )
939        })?
940        .map_err(|_| {
941            failure(
942                NodeFailureCode::BackendOperationFailed,
943                &format!("{operation} task failed"),
944            )
945        })?
946}
947
948fn windows_path_text(path: &OpaqueHostPath) -> &str {
949    path.as_utf8()
950        .expect("Windows node only creates UTF-8 opaque host paths")
951}
952
953fn require_windows_path(path: OpaqueHostPath) -> Result<String, NodeFailure> {
954    path.as_utf8()
955        .map(str::to_owned)
956        .ok_or_else(|| failure(
957            NodeFailureCode::InvalidRequest,
958            "this node requires UTF-8 host paths",
959        ))
960}
961
962fn protocol_worktree(mut worktree: NativeGitWorktreeSnapshot) -> GitWorktreeSnapshot {
963    GitWorktreeSnapshot {
964        path: opaque_windows_path(worktree.path),
965        head: worktree.head,
966        branch: worktree.branch,
967        is_bare: worktree.is_bare,
968        is_main: worktree.is_main,
969        locked: worktree.locked,
970        lock_reason: worktree.lock_reason,
971        prunable: worktree.prunable,
972        prunable_reason: worktree.prunable_reason,
973        workspace_id: worktree.workspace_id.take(),
974    }
975}
976
977#[derive(Clone, Debug, Eq, PartialEq)]
978pub struct WorkspaceConfig {
979    workspace_id: WorkspaceId,
980    canonical_root: String,
981    worktree_service_mode: WorktreeServiceMode,
982    managed_worktree_profiles: BTreeMap<WorktreeProfileId, ManagedWorktreeProfile>,
983}
984
985impl WorkspaceConfig {
986    pub fn new(
987        workspace_id: WorkspaceId,
988        root: impl AsRef<Path>,
989    ) -> Result<Self, NodeServerError> {
990        let root = root.as_ref();
991        if !root.is_absolute() {
992            return Err(NodeServerError::InvalidWorkspaceRoot {
993                workspace_id,
994                path: root.to_string_lossy().into_owned(),
995                message: "path is not absolute".to_owned(),
996            });
997        }
998        let canonical = std::fs::canonicalize(root).map_err(|error| {
999            NodeServerError::InvalidWorkspaceRoot {
1000                workspace_id: workspace_id.clone(),
1001                path: root.to_string_lossy().into_owned(),
1002                message: error.to_string(),
1003            }
1004        })?;
1005        if !canonical.is_dir() {
1006            return Err(NodeServerError::InvalidWorkspaceRoot {
1007                workspace_id,
1008                path: root.to_string_lossy().into_owned(),
1009                message: "path is not a directory".to_owned(),
1010            });
1011        }
1012        let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
1013            NodeServerError::InvalidWorkspaceRoot {
1014                workspace_id: workspace_id.clone(),
1015                path: path.to_string_lossy().into_owned(),
1016                message: "canonical path is not valid Unicode".to_owned(),
1017            }
1018        })?;
1019        let canonical_root = platform::normalize_canonical_root(canonical_root);
1020        validate_workspace_root(&workspace_id, &canonical_root)?;
1021        Ok(Self {
1022            workspace_id,
1023            canonical_root,
1024            worktree_service_mode: WorktreeServiceMode::Manual,
1025            managed_worktree_profiles: BTreeMap::new(),
1026        })
1027    }
1028
1029    pub fn workspace_id(&self) -> &WorkspaceId {
1030        &self.workspace_id
1031    }
1032
1033    pub fn canonical_root(&self) -> &str {
1034        &self.canonical_root
1035    }
1036
1037    pub fn with_worktree_service_mode(mut self, mode: WorktreeServiceMode) -> Self {
1038        self.worktree_service_mode = mode;
1039        self
1040    }
1041
1042    pub fn with_managed_worktree_profile(
1043        mut self,
1044        profile: ManagedWorktreeProfile,
1045    ) -> Result<Self, NodeServerError> {
1046        profile.validate_for_workspace(&self.canonical_root).map_err(|message| {
1047            NodeServerError::InvalidManagedWorktreeProfile {
1048                workspace_id: self.workspace_id.clone(),
1049                profile_id: profile.profile_id().clone(),
1050                message,
1051            }
1052        })?;
1053        if self.managed_worktree_profiles.contains_key(profile.profile_id()) {
1054            return Err(NodeServerError::DuplicateManagedWorktreeProfile {
1055                workspace_id: self.workspace_id,
1056                profile_id: profile.profile_id().clone(),
1057            });
1058        }
1059        if self.managed_worktree_profiles.len()
1060            == MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE
1061        {
1062            return Err(NodeServerError::ManagedWorktreeProfileCapacity {
1063                workspace_id: self.workspace_id,
1064                max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
1065            });
1066        }
1067        self.managed_worktree_profiles
1068            .insert(profile.profile_id().clone(), profile);
1069        Ok(self)
1070    }
1071}
1072
1073#[derive(Clone)]
1074pub struct NodeServerConfig {
1075    pub endpoint: String,
1076    api_listen: Option<std::net::SocketAddr>,
1077    /// Opt-in node-envelope HTTP+WS browser bridge (separate from `--api-listen`
1078    /// ops HTTP and from framed C2). Loopback only; default `None` = off.
1079    bridge_listen: Option<std::net::SocketAddr>,
1080    /// Optional bridge shared secret (`GATE4AGENT_BRIDGE_TOKEN`). Distinct from
1081    /// NODE_TOKEN; never logged. `None` = loopback bind is the only gate.
1082    bridge_token: Option<String>,
1083    /// Tip 6: optional mesh underlay UDP bind that relays to local `--bridge-listen`.
1084    /// Requires bridge_listen + bridge_token. HQ never accepts (NodePeer).
1085    bridge_underlay: Option<browser_bridge::BridgeUnderlayAccept>,
1086    /// A relay to CALL, rather than wait to be called by. `None` keeps the
1087    /// node purely a listener, which is what it has always been.
1088    call_home: Option<std::net::SocketAddr>,
1089    /// When set, call-home authenticates by signing the relay's challenge with this
1090    /// identity (enrolled node) and falls back to the token handshake against a relay
1091    /// that sends no challenge.
1092    call_home_identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
1093    pub node_id: NodeId,
1094    pub workspaces: Vec<WorkspaceConfig>,
1095    access_token: String,
1096    pub runtime: NativeRuntimeConfig,
1097    session_record_retention: SessionRecordRetentionConfig,
1098    state_path: Option<PathBuf>,
1099    spawn_profiles: SpawnProfileRegistry,
1100    session_environment: Option<NodeSessionEnvironmentConfig>,
1101    history: Option<NativeHistoryConfig>,
1102    harness_mcp_helper: Option<ReviewedHarnessMcpProgram>,
1103    /// Station network allowlist catalog (opaque ids). Empty default.
1104    /// Loaded at startup from `--network-allowlist-catalog` / env
1105    /// `GATE4AGENT_NODE_NETWORK_ALLOWLIST_CATALOG` file. Never secrets.
1106    network_allowlist_catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog,
1107    #[cfg(feature = "fixture")]
1108    fixture_raw_pty_runtime: bool,
1109}
1110
1111#[derive(Clone)]
1112struct NodeSessionEnvironmentConfig {
1113    root: PathBuf,
1114    resolver: Arc<dyn NodeSecretResolver>,
1115}
1116
1117impl NodeServerConfig {
1118    pub fn new(
1119        endpoint: impl Into<String>,
1120        access_token: impl Into<String>,
1121        node_id: NodeId,
1122        workspaces: impl IntoIterator<Item = WorkspaceConfig>,
1123    ) -> Result<Self, NodeServerError> {
1124        let endpoint = endpoint.into();
1125        let access_token = access_token.into();
1126        if !platform::validate_endpoint(&endpoint) {
1127            return Err(NodeServerError::InvalidEndpoint);
1128        }
1129        if access_token.is_empty() || access_token.len() > 4_096 {
1130            return Err(NodeServerError::InvalidAccessToken);
1131        }
1132        let workspaces = workspaces.into_iter().collect::<Vec<_>>();
1133        if workspaces.is_empty() {
1134            return Err(NodeServerError::NoWorkspaces);
1135        }
1136        let mut workspace_ids = BTreeMap::new();
1137        let mut workspace_roots = BTreeMap::new();
1138        for workspace in &workspaces {
1139            if workspace.worktree_service_mode == WorktreeServiceMode::Managed
1140                && workspace.managed_worktree_profiles.is_empty()
1141            {
1142                return Err(NodeServerError::ManagedWorktreeProfileRequired(
1143                    workspace.workspace_id.clone(),
1144                ));
1145            }
1146            if workspace.worktree_service_mode != WorktreeServiceMode::Managed
1147                && !workspace.managed_worktree_profiles.is_empty()
1148            {
1149                return Err(NodeServerError::ManagedWorktreeProfileModeMismatch(
1150                    workspace.workspace_id.clone(),
1151                ));
1152            }
1153            if workspace_ids
1154                .insert(workspace.workspace_id.clone(), ())
1155                .is_some()
1156            {
1157                return Err(NodeServerError::DuplicateWorkspaceId(
1158                    workspace.workspace_id.clone(),
1159                ));
1160            }
1161            let root_key = platform::root_identity(&workspace.canonical_root);
1162            if let Some(existing) = workspace_roots.insert(
1163                root_key,
1164                workspace.workspace_id.clone(),
1165            ) {
1166                return Err(NodeServerError::DuplicateWorkspaceRoot {
1167                    first: existing,
1168                    second: workspace.workspace_id.clone(),
1169                    root: workspace.canonical_root.clone(),
1170                });
1171            }
1172        }
1173        Ok(Self {
1174            endpoint,
1175            api_listen: None,
1176            bridge_listen: None,
1177            bridge_token: None,
1178            bridge_underlay: None,
1179            call_home: None,
1180            call_home_identity: None,
1181            node_id,
1182            workspaces,
1183            access_token,
1184            // PTY output is polled by the runtime's effect worker; the upstream
1185            // default (20ms) added ~10-20ms to every keystroke echo.
1186            runtime: NativeRuntimeConfig {
1187                worker_poll_interval_ms: 4,
1188                ..NativeRuntimeConfig::default()
1189            },
1190            session_record_retention: SessionRecordRetentionConfig::default(),
1191            state_path: None,
1192            spawn_profiles: SpawnProfileRegistry::default(),
1193            session_environment: None,
1194            history: None,
1195            harness_mcp_helper: None,
1196            network_allowlist_catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog::new(),
1197            #[cfg(feature = "fixture")]
1198            fixture_raw_pty_runtime: false,
1199        })
1200    }
1201
1202    pub fn with_api_listen(
1203        mut self,
1204        api_listen: std::net::SocketAddr,
1205    ) -> Result<Self, NodeServerError> {
1206        if !api_listen.ip().is_loopback() {
1207            return Err(NodeServerError::InvalidApiListen(api_listen));
1208        }
1209        self.api_listen = Some(api_listen);
1210        Ok(self)
1211    }
1212
1213    /// Opt-in node-envelope HTTP+WS bridge listener. Loopback only (same spirit
1214    /// as `with_api_listen`). Does not enable by default — libraries and the
1215    /// binary leave this `None` until `--bridge-listen` / `with_bridge_listen`.
1216    pub fn with_bridge_listen(
1217        mut self,
1218        bridge_listen: std::net::SocketAddr,
1219    ) -> Result<Self, NodeServerError> {
1220        if !bridge_listen.ip().is_loopback() {
1221            return Err(NodeServerError::InvalidBridgeListen(bridge_listen));
1222        }
1223        self.bridge_listen = Some(bridge_listen);
1224        Ok(self)
1225    }
1226
1227    /// Optional bridge shared secret, distinct from the C2/node access token.
1228    /// Never log the value. Empty / oversize refused.
1229    pub fn with_bridge_token(
1230        mut self,
1231        bridge_token: impl Into<String>,
1232    ) -> Result<Self, NodeServerError> {
1233        let bridge_token = bridge_token.into();
1234        if bridge_token.is_empty() || bridge_token.len() > 4_096 {
1235            return Err(NodeServerError::InvalidBridgeToken);
1236        }
1237        self.bridge_token = Some(bridge_token);
1238        Ok(self)
1239    }
1240
1241    /// Tip 6: point bridge reachability over mesh underlay (UDP+AEAD relay to
1242    /// local loopback `--bridge-listen`). Requires bridge listen + bridge token
1243    /// already configured. Transport key is 32 raw bytes (never logged).
1244    /// Underlay auth token is distinct from BRIDGE_TOKEN (crypto ≠ auth).
1245    pub fn with_bridge_underlay(
1246        mut self,
1247        bind: std::net::SocketAddr,
1248        transport_key: [u8; 32],
1249        underlay_token: impl Into<String>,
1250    ) -> Result<Self, NodeServerError> {
1251        if self.bridge_listen.is_none() {
1252            return Err(NodeServerError::BridgeUnderlayRequiresBridgeListen);
1253        }
1254        if self.bridge_token.is_none() {
1255            return Err(NodeServerError::BridgeUnderlayRequiresBridgeToken);
1256        }
1257        let underlay_token = underlay_token.into();
1258        if underlay_token.is_empty() || underlay_token.len() > 4_096 {
1259            return Err(NodeServerError::InvalidBridgeUnderlayToken);
1260        }
1261        // Port 0 is allowed (ephemeral). Role lock (no HQ accept) lives in underlay.
1262        self.bridge_underlay = Some(browser_bridge::BridgeUnderlayAccept {
1263            bind,
1264            transport_key,
1265            underlay_token,
1266        });
1267        Ok(self)
1268    }
1269
1270    /// Also dial `relay` and serve this wire over the connection, in
1271    /// addition to accepting on the local endpoint.
1272    ///
1273    /// Who opens the socket and who is the protocol's server are separate
1274    /// questions, and this is the node's half of separating them. The node
1275    /// stays the server either way -- it answers `NodeRequest`s and emits
1276    /// `NodeEvent`s exactly as it does for a relay that dialled it. All
1277    /// that changes is that a node with no address anyone can reach makes
1278    /// the connection itself, and names itself once it arrives (see
1279    /// `gate4agent_node_wire::write_call_home_announce`).
1280    ///
1281    /// Loopback only, and that is not an oversight to be flagged and
1282    /// worked around. This wire carries terminal contents, keystrokes and
1283    /// file bytes as plaintext JSON; its mutual challenge-response
1284    /// authenticates both ends and encrypts nothing. Every listener in
1285    /// this stack is loopback-guarded for that reason, and a dialler must
1286    /// hold the same line -- a node calling a public relay would put the
1287    /// whole control plane on the wire in the clear. Reaching a relay on
1288    /// another host is a transport question, and it stays unanswered here
1289    /// on purpose: this change is about direction, not distance.
1290    pub fn with_call_home(
1291        mut self,
1292        relay: std::net::SocketAddr,
1293    ) -> Result<Self, NodeServerError> {
1294        // Loopback, or the concrete C2 WireGuard tunnel address. Unspecified
1295        // and port 0 refused — plaintext wire must not dial every interface.
1296        if relay.port() == 0 || relay.ip().is_unspecified() {
1297            return Err(NodeServerError::InvalidCallHome(relay));
1298        }
1299        self.call_home = Some(relay);
1300        Ok(self)
1301    }
1302
1303    /// Authenticate call-home with a node-held identity instead of a standing token.
1304    pub fn with_call_home_identity(mut self, identity: Arc<hatchery_enroll::NodeIdentity>) -> Self {
1305        self.call_home_identity = Some(identity);
1306        self
1307    }
1308
1309    pub fn with_state_path(
1310        mut self,
1311        state_path: impl Into<PathBuf>,
1312    ) -> Result<Self, NodeServerError> {
1313        let state_path = state_path.into();
1314        if !state_path.is_absolute() || state_path.file_name().is_none() {
1315            return Err(NodeServerError::InvalidStatePath(
1316                state_path.to_string_lossy().into_owned(),
1317            ));
1318        }
1319        self.state_path = Some(state_path);
1320        Ok(self)
1321    }
1322
1323    pub fn with_spawn_profiles(mut self, spawn_profiles: SpawnProfileRegistry) -> Self {
1324        self.spawn_profiles = spawn_profiles;
1325        self
1326    }
1327
1328    /// Sets the age/keep-N policy the drive loop's retention sweep uses to
1329    /// retire dead `ManagedSessionState::Unavailable` records. Both fields
1330    /// default to `0` (disabled) via `SessionRecordRetentionConfig::default`
1331    /// when this is never called -- a node never deletes a durable record
1332    /// until an operator opts in with a real value.
1333    pub fn with_session_record_retention(
1334        mut self,
1335        retention: SessionRecordRetentionConfig,
1336    ) -> Self {
1337        self.session_record_retention = retention;
1338        self
1339    }
1340
1341    pub fn with_session_environment_materialization(
1342        mut self,
1343        root: impl Into<PathBuf>,
1344        resolver: Arc<dyn NodeSecretResolver>,
1345    ) -> Result<Self, NodeServerError> {
1346        let root = root.into();
1347        if !root.is_absolute() {
1348            return Err(NodeServerError::InvalidSessionEnvironmentRoot);
1349        }
1350        self.session_environment = Some(NodeSessionEnvironmentConfig { root, resolver });
1351        Ok(self)
1352    }
1353
1354    /// Enables bounded provider-history discovery and loading from explicit
1355    /// host-owned roots. Ambient vendor homes are never inferred here.
1356    pub fn with_history(mut self, history: NativeHistoryConfig) -> Self {
1357        self.history = Some(history);
1358        self
1359    }
1360
1361    /// Enables the H3B read proxy with one exact operator-reviewed helper file.
1362    /// Linux/macOS use owner-only UDS; Windows uses owner-only named pipes.
1363    /// Both platforms review the helper the same way (absolute regular file +
1364    /// identity hash) — the old `cfg(not(windows))` hard-refuse was a leftover
1365    /// gate after Unix listener/peer-cred support landed.
1366    pub fn with_harness_mcp_helper(
1367        mut self,
1368        helper_program: impl Into<PathBuf>,
1369    ) -> Result<Self, NodeServerError> {
1370        self.harness_mcp_helper = Some(
1371            ReviewedHarnessMcpProgram::review(helper_program.into())
1372                .map_err(|_| NodeServerError::InvalidHarnessMcpHelper)?,
1373        );
1374        Ok(self)
1375    }
1376
1377    /// Install an in-memory station network allowlist catalog (opaque ids +
1378    /// optional node-local permits / provider-native mapping). Empty remains
1379    /// the default — unknown spawn ids refuse at resolve. Never cookies /
1380    /// OAuth / proxy credentials. C2 / inventory still list ids only.
1381    pub fn with_network_allowlist_catalog(
1382        mut self,
1383        catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog,
1384    ) -> Result<Self, NodeServerError> {
1385        if catalog.len() > crate::network_allowlist_catalog::MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES {
1386            return Err(NodeServerError::NetworkAllowlistCatalogCapacity {
1387                max: crate::network_allowlist_catalog::MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES,
1388            });
1389        }
1390        self.network_allowlist_catalog = catalog;
1391        Ok(self)
1392    }
1393
1394    /// Load station network allowlist catalog from an absolute regular file
1395    /// (v1 id-list or v2 JSON schema). Unset path is not used here — callers
1396    /// that want env fallback use `resolve_network_allowlist_catalog`.
1397    pub fn with_network_allowlist_catalog_file(
1398        self,
1399        path: impl AsRef<std::path::Path>,
1400    ) -> Result<Self, NodeServerError> {
1401        let catalog = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(path)
1402            .map_err(NodeServerError::from)?;
1403        self.with_network_allowlist_catalog(catalog)
1404    }
1405}
1406
1407pub fn default_state_path(node_id: &NodeId) -> Result<PathBuf, NodeServerError> {
1408    platform::default_state_path(node_id.as_str())
1409        .ok_or(NodeServerError::LocalStateDirectoryUnavailable)
1410}
1411
1412pub fn default_node_endpoint() -> Result<PathBuf, NodeServerError> {
1413    platform::default_node_endpoint().ok_or(NodeServerError::LocalRuntimeDirectoryUnavailable)
1414}
1415
1416fn delivery_store_root_for_state_path(state_path: &Path) -> Option<PathBuf> {
1417    let parent = state_path.parent()?;
1418    let mut name = state_path.file_name()?.to_os_string();
1419    name.push(".delivery-store");
1420    Some(parent.join(name))
1421}
1422
1423fn context_pack_store_root_for_state_path(state_path: &Path) -> Option<PathBuf> {
1424    let parent = state_path.parent()?;
1425    let mut name = state_path.file_name()?.to_os_string();
1426    name.push(".context-pack-store");
1427    Some(parent.join(name))
1428}
1429
1430pub struct NodeServer {
1431    config: NodeServerConfig,
1432    runtime: NativeRuntime,
1433    shared: Arc<NodeShared>,
1434    events: EventSubscription,
1435    state_path_lock: Option<session_registry::StatePathLock>,
1436}
1437
1438#[cfg(feature = "fixture")]
1439#[derive(Clone, Default)]
1440pub struct SpawnManagedWorktreeV2FailureProbe {
1441    latest_code: Arc<Mutex<Option<NodeFailureCode>>>,
1442}
1443
1444#[cfg(feature = "fixture")]
1445impl SpawnManagedWorktreeV2FailureProbe {
1446    pub fn latest_code(&self) -> Option<NodeFailureCode> {
1447        *self
1448            .latest_code
1449            .lock()
1450            .unwrap_or_else(|poisoned| poisoned.into_inner())
1451    }
1452
1453    fn record(&self, code: Option<NodeFailureCode>) {
1454        *self
1455            .latest_code
1456            .lock()
1457            .unwrap_or_else(|poisoned| poisoned.into_inner()) = code;
1458    }
1459}
1460
1461#[cfg(all(feature = "fixture", windows))]
1462struct IsolatedCodexHomeFixtureEnvironment;
1463
1464#[cfg(all(feature = "fixture", windows))]
1465impl gate4agent_runtime_native::NativeChildEnvironmentResolver
1466    for IsolatedCodexHomeFixtureEnvironment
1467{
1468    fn resolve_child_environment(
1469        &self,
1470    ) -> Result<
1471        Vec<gate4agent_catalog::EnvMutation>,
1472        gate4agent_runtime_native::NativeChildEnvironmentResolveError,
1473    > {
1474        Ok(vec![gate4agent_catalog::EnvMutation {
1475            key: OsString::from("USERPROFILE"),
1476            value: None,
1477        }])
1478    }
1479}
1480
1481impl NodeServer {
1482    /// The letter-wake handle for the door, when m4a accounts are on. `token` is what callers
1483    /// must present.
1484    /// Turn on the m4a signer proxy for PTY spawns (replaces `M4A_SESSION_CAP`).
1485    #[cfg(unix)]
1486    pub fn set_signer_proxy(&self, proxy: Arc<crate::keystore_proxy::SignerProxy>) -> bool {
1487        self.shared.signer_proxy.set(proxy).is_ok()
1488    }
1489
1490    pub fn wake_handle(&self, token: String, policy: Arc<crate::wake_pipeline::WakePolicy>) -> Option<node_wake::NodeWake> {
1491        self.shared.m4a_accounts.as_ref()?;
1492        Some(node_wake::NodeWake::new(Arc::clone(&self.shared), token, policy))
1493    }
1494
1495    pub fn new(config: NodeServerConfig) -> Result<Self, NodeServerError> {
1496        let catalog = active_registry()?;
1497        Self::new_with_registry(config, catalog)
1498    }
1499
1500    #[cfg(feature = "fixture")]
1501    pub fn new_fixture(config: NodeServerConfig) -> Result<Self, NodeServerError> {
1502        let mut spec = gate4agent_testkit::interactive_agent_spec();
1503        spec.id = AgentId::new("claude").map_err(|error| NodeServerError::Registry(error.to_string()))?;
1504        spec.display_name = "Controlled Claude PTY fixture".to_owned();
1505        Self::new_fixture_with_spec(config, spec)
1506    }
1507
1508    #[cfg(feature = "fixture")]
1509    pub fn new_clean_exit_fixture(
1510        config: NodeServerConfig,
1511        fixture_root: PathBuf,
1512        started_marker: PathBuf,
1513        release_signal: PathBuf,
1514    ) -> Result<Self, NodeServerError> {
1515        let mut spec = gate4agent_testkit::controlled_clean_exit_agent_spec(
1516            &fixture_root,
1517            &started_marker,
1518            &release_signal,
1519        )
1520        .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1521        spec.id = AgentId::new("claude")
1522            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1523        spec.display_name = "Controlled Claude clean-exit fixture".to_owned();
1524        Self::new_fixture_with_spec(config, spec)
1525    }
1526
1527    /// A `claude` source whose session establishes a verified provider
1528    /// identity via an early `SessionStart` Hook post (so it is eligible for
1529    /// auto-export-at-exit and its native history is independently
1530    /// loadable), waits for `release_signal`, then exits cleanly — paired
1531    /// on the same Node with a plain `codex` target that never validates
1532    /// anything about what it receives. Built for A2 durable ContextPack
1533    /// E2E coverage: the test itself inspects materialized files and
1534    /// managed-session-record fields directly, rather than having the
1535    /// target script self-validate.
1536    #[cfg(all(feature = "fixture", windows))]
1537    pub fn new_durable_context_pack_clean_exit_fixture(
1538        config: NodeServerConfig,
1539        fixture_root: PathBuf,
1540        started_marker: PathBuf,
1541        release_signal: PathBuf,
1542        session_id: &str,
1543    ) -> Result<Self, NodeServerError> {
1544        let mut source_spec = gate4agent_testkit::identified_clean_exit_agent_spec(
1545            &fixture_root,
1546            &started_marker,
1547            &release_signal,
1548            session_id,
1549        )
1550        .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1551        let source_id = AgentId::new("claude")
1552            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1553        let source_provider = builtin_registry().get(&source_id).ok_or_else(|| {
1554            NodeServerError::Registry(
1555                "durable context pack clean-exit fixture source adapter is unavailable"
1556                    .to_owned(),
1557            )
1558        })?;
1559        source_spec.capabilities.adapters.history =
1560            source_provider.capabilities.adapters.history.clone();
1561        source_spec.id = source_id;
1562        source_spec.display_name =
1563            "Controlled Claude durable context-pack clean-exit fixture".to_owned();
1564        // Both specs launch via `powershell.exe`; the registry's ambiguity
1565        // check keys on `detection.command` (+ aliases), not `launch.program`
1566        // -- give each a distinct detection identity, same precedent as
1567        // `context_pack_fixture_catalog`'s per-provider
1568        // `gate4agent-{provider}-context-fixture.cmd` commands.
1569        source_spec.detection.command =
1570            "gate4agent-claude-durable-context-pack-fixture.cmd".to_owned();
1571        source_spec.detection.aliases.clear();
1572
1573        let mut target_spec = gate4agent_testkit::interactive_agent_spec();
1574        target_spec.id = AgentId::new("codex")
1575            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1576        target_spec.display_name =
1577            "Controlled Codex durable context-pack target fixture".to_owned();
1578        target_spec.detection.command =
1579            "gate4agent-codex-durable-context-pack-fixture.cmd".to_owned();
1580        target_spec.detection.aliases.clear();
1581
1582        let catalog = AgentRegistry::new([source_spec, target_spec])
1583            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1584        let mut server = Self::new_with_registry(config, catalog)?;
1585        Arc::get_mut(&mut server.shared)
1586            .expect("fixture Node shared state must remain uniquely owned before run")
1587            .fixture_semantic_hook_policy = true;
1588        Ok(server)
1589    }
1590
1591    #[cfg(feature = "fixture")]
1592    pub fn new_monitoring_hook_fixture(
1593        config: NodeServerConfig,
1594    ) -> Result<Self, NodeServerError> {
1595        let mut spec = gate4agent_testkit::monitoring_hook_agent_spec();
1596        spec.id = AgentId::new("claude")
1597            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1598        let mut server = Self::new_fixture_with_spec(config, spec)?;
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(all(feature = "fixture", windows))]
1606    pub fn new_provider_bundle_argv_fixture(
1607        config: NodeServerConfig,
1608        agent_id: AgentId,
1609        proof_path: PathBuf,
1610    ) -> Result<Self, NodeServerError> {
1611        let spec = Self::provider_bundle_argv_fixture_spec(agent_id, proof_path, None)?;
1612        Self::new_fixture_with_spec(config, spec)
1613    }
1614
1615    #[cfg(all(feature = "fixture", windows))]
1616    pub fn new_provider_bundle_argv_hold_fixture(
1617        config: NodeServerConfig,
1618        agent_id: AgentId,
1619        proof_path: PathBuf,
1620        release_signal: PathBuf,
1621    ) -> Result<Self, NodeServerError> {
1622        let spec = Self::provider_bundle_argv_fixture_spec(
1623            agent_id,
1624            proof_path,
1625            Some(release_signal),
1626        )?;
1627        Self::new_fixture_with_spec(config, spec)
1628    }
1629
1630    #[cfg(all(feature = "fixture", windows))]
1631    fn provider_bundle_argv_fixture_spec(
1632        agent_id: AgentId,
1633        proof_path: PathBuf,
1634        release_signal: Option<PathBuf>,
1635    ) -> Result<gate4agent_types::AgentSpec, NodeServerError> {
1636        if !matches!(agent_id.as_str(), "claude" | "codex" | "kimi") {
1637            return Err(NodeServerError::Registry(
1638                "provider bundle argv fixture requires Claude, Codex, or Kimi".to_owned(),
1639            ));
1640        }
1641        let mut spec = gate4agent_testkit::interactive_agent_spec();
1642        spec.id = agent_id.clone();
1643        spec.display_name = format!("Controlled {agent_id} bundle argv fixture");
1644        let provider = builtin_registry()
1645            .get(&agent_id)
1646            .ok_or_else(|| {
1647                NodeServerError::Registry(
1648                    "provider bundle argv fixture adapter is unavailable".to_owned(),
1649                )
1650            })?;
1651        spec.capabilities.adapters.history = provider.capabilities.adapters.history.clone();
1652        if !proof_path.is_absolute()
1653            || !proof_path
1654                .parent()
1655                .is_some_and(|parent| parent.is_dir())
1656        {
1657            return Err(NodeServerError::Registry(
1658                "provider bundle argv proof path requires an existing absolute parent"
1659                    .to_owned(),
1660            ));
1661        }
1662        if let Some(release_signal) = release_signal.as_ref() {
1663            if !release_signal.is_absolute()
1664                || !release_signal
1665                    .parent()
1666                    .is_some_and(|parent| parent.is_dir())
1667            {
1668                return Err(NodeServerError::Registry(
1669                    "provider bundle argv release path requires an existing absolute parent"
1670                        .to_owned(),
1671                ));
1672            }
1673            if release_signal.exists() {
1674                return Err(NodeServerError::Registry(
1675                    "provider bundle argv release path must not exist".to_owned(),
1676                ));
1677            }
1678            let proof_parent = std::fs::canonicalize(
1679                proof_path
1680                    .parent()
1681                    .expect("validated provider bundle proof parent"),
1682            )
1683            .map_err(|_| {
1684                NodeServerError::Registry(
1685                    "provider bundle argv proof parent is unavailable".to_owned(),
1686                )
1687            })?;
1688            let release_parent = std::fs::canonicalize(
1689                release_signal
1690                    .parent()
1691                    .expect("validated provider bundle release parent"),
1692            )
1693            .map_err(|_| {
1694                NodeServerError::Registry(
1695                    "provider bundle argv release parent is unavailable".to_owned(),
1696                )
1697            })?;
1698            if proof_parent != release_parent {
1699                return Err(NodeServerError::Registry(
1700                    "provider bundle argv release path must share the proof parent".to_owned(),
1701                ));
1702            }
1703        }
1704        let proof_path = proof_path.into_os_string().into_string().map_err(|_| {
1705            NodeServerError::Registry(
1706                "provider bundle argv proof path is not valid Unicode".to_owned(),
1707            )
1708        })?;
1709        let release_signal = release_signal.map(|release_signal| {
1710            release_signal.into_os_string().into_string().map_err(|_| {
1711                NodeServerError::Registry(
1712                    "provider bundle argv release path is not valid Unicode".to_owned(),
1713                )
1714            })
1715        }).transpose()?;
1716        let script = spec
1717            .launch
1718            .fixed_args
1719            .pop()
1720            .ok_or_else(|| NodeServerError::Registry("PTY fixture script is unavailable".to_owned()))?;
1721        let bundle_validation = if agent_id.as_str() == "codex" {
1722            "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') }"
1723        } else if agent_id.as_str() == "claude" {
1724            "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 }"
1725        } else {
1726            "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 }"
1727        };
1728        if let Some(release_signal) = release_signal {
1729            spec.launch.fixed_args.push(format!(
1730                "& {{ 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} }}",
1731            ));
1732            spec.launch.fixed_args.push(proof_path);
1733            spec.launch.fixed_args.push(release_signal);
1734        } else {
1735            spec.launch.fixed_args.push(format!(
1736                "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {bundle_validation}; {script} }}",
1737            ));
1738            spec.launch.fixed_args.push(proof_path);
1739        }
1740        Ok(spec)
1741    }
1742
1743    #[cfg(all(feature = "fixture", windows))]
1744    pub fn new_context_pack_fixture(
1745        config: NodeServerConfig,
1746        proof_path: PathBuf,
1747    ) -> Result<Self, NodeServerError> {
1748        let catalog = Self::context_pack_fixture_catalog(proof_path, None, false)?;
1749        Self::new_with_registry(config, catalog)
1750    }
1751
1752    #[cfg(all(feature = "fixture", windows))]
1753    pub fn new_monitoring_context_pack_fixture(
1754        config: NodeServerConfig,
1755        proof_path: PathBuf,
1756    ) -> Result<Self, NodeServerError> {
1757        let catalog = Self::context_pack_fixture_catalog(proof_path, None, true)?;
1758        let mut server = Self::new_with_registry(config, catalog)?;
1759        Arc::get_mut(&mut server.shared)
1760            .expect("fixture Node shared state must remain uniquely owned before run")
1761            .fixture_semantic_hook_policy = true;
1762        Ok(server)
1763    }
1764
1765    #[cfg(all(feature = "fixture", windows))]
1766    pub fn install_isolated_codex_home_environment_fixture(
1767        &mut self,
1768        profile_id: SpawnEnvironmentProfileId,
1769        profile_revision: crate::protocol::SpawnEnvironmentProfileRevision,
1770    ) -> Result<(), NodeServerError> {
1771        let provider = AgentId::new("codex")
1772            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1773        let native_profile = gate4agent_runtime_native::NativeLaunchProfile::new(
1774            gate4agent_runtime_native::NativeLaunchProfileId::new(format!(
1775                "{}-pty",
1776                profile_id.as_str(),
1777            ))
1778            .map_err(|error| NodeServerError::Registry(error.to_string()))?,
1779            provider.clone(),
1780            TransportKind::Pty,
1781            vec![OsString::from("USERPROFILE")],
1782            Arc::new(IsolatedCodexHomeFixtureEnvironment),
1783        )
1784        .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1785        let materialization = NodeSessionMaterializationProfile::new(
1786            Vec::new(),
1787            vec![crate::session_environment::NodeSessionPathBinding::new(
1788                "CODEX_HOME",
1789                crate::session_environment::NodeSessionPathClass::ProviderHome,
1790            )
1791            .map_err(|error| NodeServerError::Registry(error.to_string()))?],
1792            Vec::new(),
1793        )
1794        .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1795        self.install_environment_profile(
1796            NodeEnvironmentProfile::new_with_materialization(
1797                profile_id,
1798                profile_revision,
1799                provider,
1800                [native_profile],
1801                Some(materialization),
1802            )
1803            .map_err(|error| NodeServerError::Registry(error.to_string()))?,
1804        )
1805    }
1806
1807    #[cfg(all(feature = "fixture", windows))]
1808    pub fn new_context_only_proof_fixture(
1809        config: NodeServerConfig,
1810        target_provider: AgentId,
1811        proof_path: PathBuf,
1812    ) -> Result<Self, NodeServerError> {
1813        if target_provider.as_str() != "kimi" {
1814            return Err(NodeServerError::Registry(
1815                "context-only proof fixture requires Kimi".to_owned(),
1816            ));
1817        }
1818        let catalog = Self::context_pack_fixture_catalog(
1819            proof_path,
1820            Some(&target_provider),
1821            false,
1822        )?;
1823        Self::new_with_registry(config, catalog)
1824    }
1825
1826    #[cfg(all(feature = "fixture", windows))]
1827    fn context_pack_fixture_catalog(
1828        proof_path: PathBuf,
1829        context_only_target: Option<&AgentId>,
1830        monitoring_claude_source: bool,
1831    ) -> Result<AgentRegistry, NodeServerError> {
1832        if !proof_path.is_absolute()
1833            || !proof_path
1834                .parent()
1835                .is_some_and(|parent| parent.is_dir())
1836        {
1837            return Err(NodeServerError::Registry(
1838                "context pack proof path requires an existing absolute parent".to_owned(),
1839            ));
1840        }
1841        let proof_path = proof_path.into_os_string().into_string().map_err(|_| {
1842            NodeServerError::Registry(
1843                "context pack proof path is not valid Unicode".to_owned(),
1844            )
1845        })?;
1846        let providers = ["claude", "codex", "grok", "kimi"];
1847        let builtins = builtin_registry();
1848        let mut specs = Vec::with_capacity(providers.len());
1849        for provider_id in providers {
1850            let agent_id = AgentId::new(provider_id)
1851                .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1852            let provider = builtins.get(&agent_id).ok_or_else(|| {
1853                NodeServerError::Registry(
1854                    "context pack fixture provider adapter is unavailable".to_owned(),
1855                )
1856            })?;
1857            let monitored_provider =
1858                monitoring_claude_source && matches!(provider_id, "claude" | "codex");
1859            let mut spec = if monitored_provider {
1860                gate4agent_testkit::monitoring_hook_agent_spec()
1861            } else {
1862                gate4agent_testkit::interactive_agent_spec()
1863            };
1864            spec.id = agent_id.clone();
1865            spec.display_name = format!("Controlled {agent_id} context pack fixture");
1866            spec.detection.command = format!("gate4agent-{provider_id}-context-fixture.cmd");
1867            spec.detection.aliases.clear();
1868            spec.capabilities.adapters.history = provider.capabilities.adapters.history.clone();
1869            if provider_id == "codex" {
1870                let script = spec.launch.fixed_args.pop().ok_or_else(|| {
1871                    NodeServerError::Registry("PTY fixture script is unavailable".to_owned())
1872                })?;
1873                let validation = r#"
1874$bundleArgs = @($bundleArgs)
1875if ($bundleArgs.Count -ne 0) { exit 91 }
1876$codexHome = [Environment]::GetEnvironmentVariable('CODEX_HOME', 'Process')
1877$contextRoot = [Environment]::GetEnvironmentVariable('GATE4AGENT_CONTEXT_ROOT', 'Process')
1878$cwd = (Get-Location).ProviderPath
1879if ([string]::IsNullOrEmpty($codexHome) -or -not [IO.Path]::IsPathRooted($codexHome) -or -not (Test-Path -LiteralPath $codexHome -PathType Container)) { exit 92 }
1880$skillPath = Join-Path $codexHome 'skills/review-code/SKILL.md'
1881if (-not (Test-Path -LiteralPath $skillPath -PathType Leaf)) { exit 93 }
1882$homeFiles = @(Get-ChildItem -LiteralPath $codexHome -Recurse -Force -File)
1883if ($homeFiles.Count -ne 1 -or [IO.Path]::GetFullPath($homeFiles[0].FullName) -ine [IO.Path]::GetFullPath($skillPath)) { exit 94 }
1884$sha = [Security.Cryptography.SHA256]::Create()
1885try { $skillHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($skillPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1886if ($skillHash -cne 'd78fe03be106b673fcf5415c8359e99f0298b5071cd11822c58ebcb27e52a68d') { exit 95 }
1887if ([string]::IsNullOrEmpty($contextRoot) -or -not [IO.Path]::IsPathRooted($contextRoot) -or -not (Test-Path -LiteralPath $contextRoot -PathType Container)) { exit 96 }
1888$contextPath = Join-Path $contextRoot 'context-pack.json'
1889if (-not (Test-Path -LiteralPath $contextPath -PathType Leaf)) { exit 97 }
1890$contextFiles = @(Get-ChildItem -LiteralPath $contextRoot -Recurse -Force -File)
1891if ($contextFiles.Count -ne 1 -or [IO.Path]::GetFullPath($contextFiles[0].FullName) -ine [IO.Path]::GetFullPath($contextPath)) { exit 98 }
1892$contextFull = [IO.Path]::GetFullPath($contextRoot).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1893$codexFull = [IO.Path]::GetFullPath($codexHome).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1894$cwdFull = [IO.Path]::GetFullPath($cwd).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1895$separator = [IO.Path]::DirectorySeparatorChar
1896if ($contextFull.Equals($codexFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($codexFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 99 }
1897if ($contextFull.Equals($cwdFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($cwdFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 100 }
1898$rawContext = [IO.File]::ReadAllText($contextPath)
1899if ($rawContext.Contains('g4a-private-provider-session-canary') -or $rawContext.Contains('private-provider-login-identity')) { exit 101 }
1900try { $document = $rawContext | ConvertFrom-Json -ErrorAction Stop } catch { exit 102 }
1901$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')
1902$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')
1903foreach ($property in @($document.PSObject.Properties.Name)) {
1904    if ($allowedTopLevel -cnotcontains [string]$property) { exit 103 }
1905}
1906foreach ($property in $requiredTopLevel) {
1907    if ($null -eq $document.PSObject.Properties[$property]) { exit 104 }
1908}
1909if ($document.schema -cne 'g4a-context-pack-v1' -or $null -ne $document.PSObject.Properties['cwd']) { exit 105 }
1910if (@('claude', 'codex', 'grok', 'kimi') -cnotcontains [string]$document.source_provider) { exit 106 }
1911$messages = @($document.retained_messages)
1912if ($messages.Count -eq 0) { exit 107 }
1913foreach ($message in $messages) {
1914    $messageProperties = @($message.PSObject.Properties.Name)
1915    foreach ($property in $messageProperties) {
1916        if (@('role', 'text') -cnotcontains [string]$property) { exit 108 }
1917    }
1918    if ($messageProperties.Count -ne 2 -or @('user', 'assistant') -cnotcontains [string]$message.role -or [string]::IsNullOrEmpty([string]$message.text)) { exit 109 }
1919}
1920$sha = [Security.Cryptography.SHA256]::Create()
1921try { $contextHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($contextPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1922[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))
1923$rawProof = [IO.File]::ReadAllText($proofPath)
1924if ($rawProof.Contains('g4a-private-provider-session-canary') -or $rawProof.Contains('private-provider-login-identity')) { exit 110 }
1925[Console]::Out.WriteLine('F7_CODEX_BUNDLE_CONTEXT_VALIDATED')
1926"#;
1927                spec.launch.fixed_args.push(format!(
1928                    "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {validation}; {script} }}",
1929                ));
1930                spec.launch.fixed_args.push(proof_path.clone());
1931            } else if context_only_target.map(AgentId::as_str) == Some(provider_id) {
1932                let script = spec.launch.fixed_args.pop().ok_or_else(|| {
1933                    NodeServerError::Registry("PTY fixture script is unavailable".to_owned())
1934                })?;
1935                let validation = r#"
1936$bundleArgs = @($bundleArgs)
1937if ($bundleArgs.Count -ne 0) { exit 91 }
1938$contextRoot = [Environment]::GetEnvironmentVariable('GATE4AGENT_CONTEXT_ROOT', 'Process')
1939$cwd = (Get-Location).ProviderPath
1940if ([string]::IsNullOrEmpty($contextRoot) -or -not [IO.Path]::IsPathRooted($contextRoot) -or -not (Test-Path -LiteralPath $contextRoot -PathType Container)) { exit 92 }
1941$contextPath = Join-Path $contextRoot 'context-pack.json'
1942if (-not (Test-Path -LiteralPath $contextPath -PathType Leaf)) { exit 93 }
1943$entries = @(Get-ChildItem -LiteralPath $contextRoot -Force)
1944if ($entries.Count -ne 1 -or $entries[0] -isnot [IO.FileInfo] -or [IO.Path]::GetFullPath($entries[0].FullName) -ine [IO.Path]::GetFullPath($contextPath)) { exit 94 }
1945$contextFull = [IO.Path]::GetFullPath($contextRoot).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1946$cwdFull = [IO.Path]::GetFullPath($cwd).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1947$separator = [IO.Path]::DirectorySeparatorChar
1948if ($contextFull.Equals($cwdFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($cwdFull + $separator, [StringComparison]::OrdinalIgnoreCase) -or $cwdFull.StartsWith($contextFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 95 }
1949try { $document = [IO.File]::ReadAllText($contextPath) | ConvertFrom-Json -ErrorAction Stop } catch { exit 96 }
1950if ($document.schema -cne 'g4a-context-pack-v1' -or $null -ne $document.PSObject.Properties['cwd']) { exit 97 }
1951if (@('claude', 'codex', 'grok', 'kimi') -cnotcontains [string]$document.source_provider) { exit 98 }
1952$messages = @($document.retained_messages)
1953if ($messages.Count -eq 0) { exit 99 }
1954$hasUser = $false
1955$hasAssistant = $false
1956foreach ($message in $messages) {
1957    if ([string]::IsNullOrWhiteSpace([string]$message.text)) { exit 100 }
1958    if ([string]$message.role -ceq 'user') { $hasUser = $true }
1959    elseif ([string]$message.role -ceq 'assistant') { $hasAssistant = $true }
1960    else { exit 101 }
1961}
1962if (-not $hasUser -or -not $hasAssistant) { exit 102 }
1963$sha = [Security.Cryptography.SHA256]::Create()
1964try { $contextHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($contextPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1965[IO.File]::WriteAllLines($proofPath, [string[]]@('context-only', $contextRoot, $cwd, $contextHash, [string]$document.schema, [string]$document.source_provider, [string]$messages.Count))
1966[Console]::Out.WriteLine('F7_KIMI_CONTEXT_ONLY_VALIDATED')
1967"#;
1968                spec.launch.fixed_args.push(format!(
1969                    "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {validation}; {script} }}",
1970                ));
1971                spec.launch.fixed_args.push(proof_path.clone());
1972            }
1973            specs.push(spec);
1974        }
1975        let catalog = AgentRegistry::new(specs)
1976            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1977        Ok(catalog)
1978    }
1979
1980    #[cfg(feature = "fixture")]
1981    pub fn new_exact_launcher_fixture(
1982        config: NodeServerConfig,
1983        agent_id: AgentId,
1984        launcher: String,
1985    ) -> Result<Self, NodeServerError> {
1986        if !matches!(agent_id.as_str(), "claude" | "codex" | "grok" | "kimi") {
1987            return Err(NodeServerError::Registry(
1988                "exact launcher fixture requires a supported provider".to_owned(),
1989            ));
1990        }
1991        if launcher.contains('\0') {
1992            return Err(NodeServerError::Registry(
1993                "exact launcher fixture path contains NUL".to_owned(),
1994            ));
1995        }
1996        let launcher_path = Path::new(&launcher);
1997        if !launcher_path.is_absolute() {
1998            return Err(NodeServerError::Registry(
1999                "exact launcher fixture path must be absolute".to_owned(),
2000            ));
2001        }
2002        let metadata = std::fs::metadata(launcher_path).map_err(|_| {
2003            NodeServerError::Registry("exact launcher fixture path is unavailable".to_owned())
2004        })?;
2005        if !metadata.is_file() {
2006            return Err(NodeServerError::Registry(
2007                "exact launcher fixture path must be a regular file".to_owned(),
2008            ));
2009        }
2010        let mut spec = builtin_registry()
2011            .get(&agent_id)
2012            .ok_or_else(|| {
2013                NodeServerError::Registry(
2014                    "exact launcher fixture provider is unavailable".to_owned(),
2015                )
2016            })?
2017            .clone();
2018        spec.launch.program = launcher;
2019        Self::new_fixture_with_spec(config, spec)
2020    }
2021
2022    /// Shared H3B validation and spec construction for
2023    /// `new_harness_mcp_proxy_fixture` and
2024    /// `new_harness_mcp_acp_launcher_fixture`: an absolute, exact-regular-
2025    /// file provider launcher plus at most 32 Unicode, NUL-free fixed
2026    /// arguments (65,536 bytes total), replacing `spec.launch` on a clone of
2027    /// the named builtin catalog entry -- detection command and expected
2028    /// process matcher rebased on the launcher's own basename, exactly the
2029    /// same shape `new_harness_mcp_proxy_fixture` always built.
2030    #[cfg(feature = "fixture")]
2031    fn h3b_provider_spec_with_program_and_args(
2032        agent_id: AgentId,
2033        provider_program: PathBuf,
2034        fixed_args: Vec<OsString>,
2035    ) -> Result<AgentSpec, NodeServerError> {
2036        if !provider_program.is_absolute() || fixed_args.len() > 32 {
2037            return Err(NodeServerError::Registry(
2038                "H3B fixture requires an absolute provider and at most 32 arguments".to_owned(),
2039            ));
2040        }
2041        let metadata = std::fs::symlink_metadata(&provider_program).map_err(|_| {
2042            NodeServerError::Registry("H3B fixture provider is unavailable".to_owned())
2043        })?;
2044        if !metadata.file_type().is_file() || metadata.file_type().is_symlink() {
2045            return Err(NodeServerError::Registry(
2046                "H3B fixture provider must be an exact regular file".to_owned(),
2047            ));
2048        }
2049        let program = provider_program.into_os_string().into_string().map_err(|_| {
2050            NodeServerError::Registry("H3B fixture provider path is not Unicode".to_owned())
2051        })?;
2052        let fixed_args = fixed_args.into_iter().map(|argument| {
2053            argument.into_string().map_err(|_| NodeServerError::Registry(
2054                "H3B fixture argument is not Unicode".to_owned(),
2055            ))
2056        }).collect::<Result<Vec<_>, _>>()?;
2057        let argument_bytes = fixed_args.iter()
2058            .try_fold(0usize, |total, argument| total.checked_add(argument.len()));
2059        if program.contains('\0')
2060            || fixed_args.iter().any(|argument| argument.contains('\0'))
2061            || !matches!(argument_bytes, Some(total) if total <= 65_536)
2062        {
2063            return Err(NodeServerError::Registry(
2064                "H3B fixture provider or arguments are invalid".to_owned(),
2065            ));
2066        }
2067        let mut spec = builtin_registry().get(&agent_id).ok_or_else(|| {
2068            NodeServerError::Registry("H3B fixture provider is unavailable".to_owned())
2069        })?.clone();
2070        let process_name = Path::new(&program).file_name().and_then(|name| name.to_str())
2071            .ok_or_else(|| NodeServerError::Registry(
2072                "H3B fixture provider basename is unavailable".to_owned(),
2073            ))?.to_owned();
2074        spec.detection.command = process_name.clone();
2075        spec.expected_processes = vec![gate4agent_types::ProcessMatcher::Exact {
2076            name: process_name,
2077        }];
2078        spec.launch.program = program;
2079        spec.launch.fixed_args = fixed_args;
2080        Ok(spec)
2081    }
2082
2083    #[cfg(feature = "fixture")]
2084    pub fn new_harness_mcp_proxy_fixture(
2085        config: NodeServerConfig,
2086        provider_program: PathBuf,
2087        fixed_args: Vec<OsString>,
2088        harness_mcp_program: PathBuf,
2089    ) -> Result<Self, NodeServerError> {
2090        let mut config = config.with_harness_mcp_helper(harness_mcp_program)?;
2091        let agent_id = AgentId::new("codex")
2092            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2093        let mut spec =
2094            Self::h3b_provider_spec_with_program_and_args(agent_id, provider_program, fixed_args)?;
2095        spec.display_name = "Controlled H3B MCP proxy fixture".to_owned();
2096        config.fixture_raw_pty_runtime = true;
2097        Self::new_fixture_with_spec(config, spec)
2098    }
2099
2100    /// The ACP-mode sibling of `new_harness_mcp_proxy_fixture`: the same
2101    /// `--harness-mcp-helper` wiring and the same absolute-program-plus-
2102    /// fixed-args launcher replacement as `new_exact_launcher_fixture`, but
2103    /// admitting `ProviderRuntimeRequirement::Acp` the honest way instead of
2104    /// hardcoding it. `new_harness_mcp_proxy_fixture` forces `config.
2105    /// fixture_raw_pty_runtime = true`, which tears out the live
2106    /// `ProviderRuntimeMonitor` entirely (`NodeServer::new_with_registry`)
2107    /// and makes `NodeShared::admit_provider_runtime`'s no-monitor branch
2108    /// hardcode `acp_transport = false` for every provider, refusing
2109    /// `ProviderRuntimeRequirement::Acp` unconditionally regardless of what
2110    /// the catalog declares. This constructor leaves `fixture_raw_pty_
2111    /// runtime` at its default `false`, so a real `ProviderRuntimeMonitor`
2112    /// is built from the (cloned, launcher-replaced) catalog spec, and
2113    /// `ProviderRuntimeMonitor::supports_acp_transport` reads the honest,
2114    /// unmodified catalog fact: every builtin provider this accepts
2115    /// (`claude`, `codex`, `grok`, `kimi`) already declares `capabilities.
2116    /// transports.acp` in `gate4agent-catalog::builtin`, so no capability is
2117    /// invented here -- this constructor only removes the fixture-only
2118    /// bypass standing in its way. `provider_runtime::policy_for_transport`'s
2119    /// `TransportKind::Acp` arm (applied unconditionally at spawn-prep time
2120    /// for every ACP session, fixture or not) is what actually grants the
2121    /// ACP-shaped `ProviderRuntimePolicy` downstream of that admission --
2122    /// this constructor never calls it directly, and does not need to.
2123    #[cfg(feature = "fixture")]
2124    pub fn new_harness_mcp_acp_launcher_fixture(
2125        config: NodeServerConfig,
2126        helper_program: PathBuf,
2127        provider: AgentId,
2128        program: PathBuf,
2129        args: Vec<OsString>,
2130    ) -> Result<Self, NodeServerError> {
2131        if !matches!(provider.as_str(), "claude" | "codex" | "grok" | "kimi") {
2132            return Err(NodeServerError::Registry(
2133                "ACP harness-MCP launcher fixture requires a supported provider".to_owned(),
2134            ));
2135        }
2136        let config = config.with_harness_mcp_helper(helper_program)?;
2137        let mut spec = Self::h3b_provider_spec_with_program_and_args(provider, program, args)?;
2138        let launch = spec.launch.clone();
2139        let acp = spec.capabilities.transports.acp.as_mut().ok_or_else(|| {
2140            NodeServerError::Registry(
2141                "ACP harness-MCP launcher fixture provider does not declare an ACP transport"
2142                    .to_owned(),
2143            )
2144        })?;
2145        // Routes the ACP spawn itself through the exact replaced launcher
2146        // (`AcpSession::spawn_with_launch` -> a bare `Command::new(&launch.
2147        // program)`, `gate4agent`'s `src/acp/spawn.rs`) instead of the
2148        // catalog's own vendor-npm resolution (`acp_command`, same file) --
2149        // without this the ACP transport ignores `spec.launch` entirely and
2150        // tries to spawn the real vendor CLI (e.g. `npx -y @agentclientprotocol/
2151        // codex-acp@1.10.0`), which this fixture must never reach.
2152        acp.launch_override = Some(launch);
2153        spec.display_name = "Controlled ACP harness-MCP launcher fixture".to_owned();
2154        Self::new_fixture_with_spec(config, spec)
2155    }
2156
2157    #[cfg(feature = "fixture")]
2158    pub fn new_resume_fixture(config: NodeServerConfig) -> Result<Self, NodeServerError> {
2159        let mut spec = gate4agent_testkit::interactive_agent_spec();
2160        let claude_id = AgentId::new("claude")
2161            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2162        let claude = builtin_registry()
2163            .get(&claude_id)
2164            .ok_or_else(|| NodeServerError::Registry("Claude fixture adapter is unavailable".to_owned()))?;
2165        spec.id = claude_id;
2166        spec.display_name = "Resumable Claude PTY fixture".to_owned();
2167        spec.expected_processes = vec![gate4agent_types::ProcessMatcher::Exact {
2168            name: "claude".to_owned(),
2169        }];
2170        spec.capabilities.transports.pty_adapter = claude
2171            .capabilities
2172            .transports
2173            .pty_adapter
2174            .clone();
2175        spec.capabilities.adapters.resume = claude
2176            .capabilities
2177            .adapters
2178            .resume
2179            .clone();
2180        let script = spec
2181            .launch
2182            .fixed_args
2183            .pop()
2184            .ok_or_else(|| NodeServerError::Registry("PTY fixture script is unavailable".to_owned()))?;
2185        spec.launch.fixed_args.push(format!(
2186            "& {{ param([Parameter(ValueFromRemainingArguments=$true)][string[]]$ignored) {script} }}",
2187        ));
2188        Self::new_fixture_with_spec(config, spec)
2189    }
2190
2191    #[cfg(feature = "fixture")]
2192    pub fn new_cmd_cwd_fixture(
2193        config: NodeServerConfig,
2194        command: String,
2195    ) -> Result<Self, NodeServerError> {
2196        let mut spec = gate4agent_testkit::interactive_agent_spec();
2197        spec.id = AgentId::new("claude")
2198            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2199        spec.display_name = "Controlled cmd cwd fixture".to_owned();
2200        spec.detection.command = "gate4agent-cwd-fixture.cmd".to_owned();
2201        spec.launch.program = command;
2202        spec.launch.fixed_args.clear();
2203        Self::new_fixture_with_spec(config, spec)
2204    }
2205
2206    #[cfg(feature = "fixture")]
2207    fn new_fixture_with_spec(
2208        config: NodeServerConfig,
2209        spec: gate4agent_types::AgentSpec,
2210    ) -> Result<Self, NodeServerError> {
2211        let catalog = AgentRegistry::new([spec])
2212            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2213        Self::new_with_registry(config, catalog)
2214    }
2215
2216    fn new_with_registry(
2217        config: NodeServerConfig,
2218        catalog: AgentRegistry,
2219    ) -> Result<Self, NodeServerError> {
2220        for profile in config.spawn_profiles.iter() {
2221            if catalog.get(&profile.provider).is_none() {
2222                return Err(NodeServerError::Registry(
2223                    "spawn profile references an unavailable provider".to_owned(),
2224                ));
2225            }
2226        }
2227        let input_settle_timeout_ms = catalog
2228            .iter()
2229            .map(|spec| spec.readiness.timeout_ms)
2230            .max()
2231            .unwrap_or(MUTATION_SETTLE_TIMEOUT_MS)
2232            .saturating_add(READINESS_SETTLE_HEADROOM_MS)
2233            .max(MUTATION_SETTLE_TIMEOUT_MS);
2234        let (provider_contracts, provider_adapter_contracts) =
2235            provider_contract_manifest(&catalog)?;
2236        let compatibility_support = node_compatibility_support_for_manifest(
2237            &provider_contracts,
2238            &provider_adapter_contracts,
2239        )?;
2240        validate_node_negotiated_handshake_capacity(&compatibility_support)
2241            .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2242        let enabled_providers = provider_contracts
2243            .iter()
2244            .map(|contract| contract.provider.clone())
2245            .collect();
2246        #[cfg(feature = "fixture")]
2247        let fixture_raw_pty_runtime = config.fixture_raw_pty_runtime;
2248        #[cfg(not(feature = "fixture"))]
2249        let fixture_raw_pty_runtime = false;
2250        let (provider_runtime_monitor, provider_runtime_statuses) = if fixture_raw_pty_runtime {
2251            let statuses = ProviderRuntimeStatuses::new(catalog.iter().map(|spec| {
2252                crate::protocol::ProviderRuntimeStatus::raw_passthrough(spec.id.clone(), None)
2253            })).expect("fixture provider catalog remains bounded");
2254            (None, statuses)
2255        } else {
2256            let monitor = Arc::new(ProviderRuntimeMonitor::new(&catalog));
2257            let statuses = monitor.collect();
2258            (Some(monitor), statuses)
2259        };
2260        let state_path_lock =
2261            session_registry::StatePathLock::acquire(config.state_path.as_deref())
2262                .map_err(|error| {
2263                    durable_state_server_error(error, DURABLE_STATE_LOCK_FAILED_ERROR)
2264                })?;
2265        let (delivery_store, delivered_bundles) = match config.state_path.as_ref() {
2266            Some(state_path) => {
2267                let delivery_root = delivery_store_root_for_state_path(state_path)
2268                    .ok_or_else(|| {
2269                        NodeServerError::DeliveryStore(
2270                            "delivery store root could not be derived from state path".to_owned(),
2271                        )
2272                    })?;
2273                let (store, bundles) = DeliveryStore::open(delivery_root).map_err(|error| {
2274                    let detail = match &error {
2275                        DeliveryStoreError::Storage(io_error) => {
2276                            format!("{error}: {io_error}")
2277                        }
2278                        _ => error.to_string(),
2279                    };
2280                    NodeServerError::DeliveryStore(detail)
2281                })?;
2282                (Some(store), bundles)
2283            }
2284            None => (None, Vec::new()),
2285        };
2286        let delivered_catalog = BundleCatalog::new(delivered_bundles).map_err(|error| {
2287            NodeServerError::DeliveryStore(format!("bundle catalog: {error}"))
2288        })?;
2289        let (context_pack_store, durable_context_packs) = match config.state_path.as_ref() {
2290            Some(state_path) => {
2291                let context_pack_root = context_pack_store_root_for_state_path(state_path)
2292                    .ok_or(NodeServerError::ContextPackStore)?;
2293                let (store, packs) = ContextPackStore::open(context_pack_root)
2294                    .map_err(|_| NodeServerError::ContextPackStore)?;
2295                (Some(store), packs)
2296            }
2297            None => (None, Vec::new()),
2298        };
2299        let mut durable_context_catalog = ContextPackCatalog::default();
2300        for pack in durable_context_packs {
2301            durable_context_catalog
2302                .insert(pack)
2303                .map_err(|_| NodeServerError::ContextPackStore)?;
2304        }
2305        let session_environment_materializer = config
2306            .session_environment
2307            .as_ref()
2308            .map(|environment| {
2309                SessionEnvironmentMaterializer::new(
2310                    environment.root.clone(),
2311                    Arc::clone(&environment.resolver),
2312                )
2313                .map_err(|_| NodeServerError::SessionEnvironmentMaterializer)
2314            })
2315            .transpose()?;
2316        // Session launchers go through the unprivileged `exec-as` wrapper when run-as is set
2317        // (the provider version probe above already used the original catalog).
2318        #[cfg(unix)]
2319        let catalog = crate::isolation::wrap_registry(catalog).map_err(NodeServerError::Registry)?;
2320        let native_session_catalog = config.history.clone().map(|history| {
2321            Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)))
2322        });
2323        let (handle, runtime) = match config.history.clone() {
2324            Some(history) => NativeRuntime::new_with_history(catalog, config.runtime, history),
2325            None => NativeRuntime::new(catalog, config.runtime),
2326        };
2327        let native_launch_profile_control = runtime.native_launch_profile_control();
2328        let harness_mcp_registry = config
2329            .harness_mcp_helper
2330            .clone()
2331            .map(HarnessMcpProxyRegistry::new);
2332        let events = handle.subscribe(CONTROL_EVENT_SUBSCRIPTION_CAPACITY);
2333        let incarnation_id = random_incarnation_id()
2334            .map_err(NodeServerError::IncarnationIdentity)?;
2335        let mut loaded = session_registry::load(config.state_path.as_deref(), &config.node_id)
2336            .map_err(durable_state_load_error)?;
2337        let managed_worktrees = std::mem::take(&mut loaded.managed_worktrees);
2338        let managed_worktree_tombstones =
2339            std::mem::take(&mut loaded.managed_worktree_tombstones);
2340        let materializations = std::mem::take(&mut loaded.materializations);
2341        let managed_spawn_replays = std::mem::take(&mut loaded.managed_spawn_replays);
2342        let (workspaces, records, persistence_warning) =
2343            merge_durable_state(&config.workspaces, loaded)?;
2344        let mut shared = NodeShared::new_with_incarnation(
2345            handle,
2346            config.access_token.clone(),
2347            config.node_id.clone(),
2348            incarnation_id,
2349            workspaces,
2350            enabled_providers,
2351            provider_runtime_statuses,
2352            provider_runtime_monitor,
2353            provider_contracts,
2354            provider_adapter_contracts,
2355            config.spawn_profiles.clone(),
2356            Some(native_launch_profile_control),
2357            native_session_catalog,
2358            session_environment_materializer,
2359            config.state_path.clone(),
2360            records,
2361            managed_worktrees,
2362            managed_worktree_tombstones,
2363            materializations,
2364            persistence_warning,
2365            input_settle_timeout_ms,
2366        );
2367        shared.harness_mcp_registry = harness_mcp_registry.clone();
2368        shared.session_record_retention = config.session_record_retention;
2369        shared.network_allowlist_catalog =
2370            RwLock::new(config.network_allowlist_catalog.clone());
2371        shared.bundle_catalog = RwLock::new(delivered_catalog);
2372        shared.delivery_store = Mutex::new(delivery_store);
2373        shared.context_catalog = RwLock::new(durable_context_catalog);
2374        shared.context_pack_store = Mutex::new(context_pack_store);
2375        shared.managed_spawn_replays = Mutex::new(
2376            managed_spawn_replays
2377                .into_iter()
2378                .map(|record| (record.idempotency_key.clone(), record))
2379                .collect(),
2380        );
2381        let shared = Arc::new(shared);
2382        if let Some(registry) = harness_mcp_registry {
2383            let weak = Arc::downgrade(&shared);
2384            registry.set_event_sink(Arc::new(move |event| match weak.upgrade() {
2385                Some(shared) => shared.publish_transient(event),
2386                None => false,
2387            }));
2388        }
2389        if shared.persistence_error().is_none() {
2390            shared.persist_state().map_err(|error| {
2391                durable_state_server_error(error, DURABLE_STATE_COMMIT_FAILED_ERROR)
2392            })?;
2393        }
2394        Ok(Self {
2395            config,
2396            runtime,
2397            shared,
2398            events,
2399            state_path_lock,
2400        })
2401    }
2402
2403    pub fn shutdown_handle(&self) -> NodeShutdownHandle {
2404        NodeShutdownHandle {
2405            shared: Arc::clone(&self.shared),
2406        }
2407    }
2408
2409    #[cfg(feature = "fixture")]
2410    pub fn spawn_managed_worktree_v2_failure_probe_fixture(
2411        &self,
2412    ) -> SpawnManagedWorktreeV2FailureProbe {
2413        self.shared
2414            .fixture_spawn_managed_worktree_v2_failure_probe
2415            .clone()
2416    }
2417
2418    /// Installs one immutable host-local environment profile before the server runs.
2419    ///
2420    /// Native resolvers remain inside the runtime; the shared node registry keeps
2421    /// only public IDs, revisions, provider bindings, and opaque native profile IDs.
2422    pub fn install_environment_profile(
2423        &mut self,
2424        profile: NodeEnvironmentProfile,
2425    ) -> Result<(), NodeServerError> {
2426        if !self.shared.enabled_providers.contains(profile.provider()) {
2427            return Err(NodeServerError::EnvironmentProfileProviderUnavailable(
2428                profile.provider().clone(),
2429            ));
2430        }
2431        let (binding, native_profiles, materialization) = profile.into_parts();
2432        if materialization.is_some() && self.shared.session_environment_materializer.is_none() {
2433            return Err(NodeServerError::SessionEnvironmentMaterializerRequired);
2434        }
2435        let mut profiles = self
2436            .shared
2437            .environment_profiles
2438            .write()
2439            .unwrap_or_else(|poisoned| poisoned.into_inner());
2440        if profiles.contains_key(&binding.id) {
2441            return Err(NodeServerError::DuplicateEnvironmentProfile(
2442                binding.id.clone(),
2443            ));
2444        }
2445        if profiles.len() >= MAX_NODE_ENVIRONMENT_PROFILES {
2446            return Err(NodeServerError::EnvironmentProfileCapacity {
2447                max: MAX_NODE_ENVIRONMENT_PROFILES,
2448            });
2449        }
2450        for native_id in binding.native_profile_ids() {
2451            if profiles.values().any(|current| {
2452                current.native_profile_ids().any(|current_id| current_id == native_id)
2453            }) {
2454                return Err(NodeServerError::DuplicateNativeEnvironmentProfile(
2455                    native_id.to_string(),
2456                ));
2457            }
2458        }
2459
2460        let mut installed = Vec::with_capacity(native_profiles.len());
2461        for native_profile in native_profiles {
2462            let native_id = native_profile.id().clone();
2463            if let Err(error) = self.runtime.upsert_native_launch_profile(native_profile) {
2464                for installed_id in installed.into_iter().rev() {
2465                    let _ = self.runtime.remove_native_launch_profile(&installed_id);
2466                }
2467                return Err(NodeServerError::NativeEnvironmentProfile(error.to_string()));
2468            }
2469            installed.push(native_id);
2470        }
2471        if let Some(materialization) = materialization {
2472            self.shared
2473                .environment_materialization_profiles
2474                .write()
2475                .unwrap_or_else(|poisoned| poisoned.into_inner())
2476                .insert(binding.id.clone(), materialization);
2477        }
2478        profiles.insert(binding.id.clone(), binding);
2479        Ok(())
2480    }
2481
2482    /// Installs one immutable host-local bundle before the server runs.
2483    pub fn install_bundle(&mut self, bundle: NodeBundle) -> Result<(), NodeServerError> {
2484        if self.shared.session_environment_materializer.is_none() {
2485            return Err(NodeServerError::SessionEnvironmentMaterializerRequired);
2486        }
2487        let mut catalog = self
2488            .shared
2489            .bundle_catalog
2490            .write()
2491            .unwrap_or_else(|poisoned| poisoned.into_inner());
2492        catalog
2493            .insert_idempotent(bundle)
2494            .map_err(|error| NodeServerError::BundleCatalog(error.to_string()))?;
2495        Ok(())
2496    }
2497
2498    pub async fn run_until_ctrl_signal(self) -> Result<(), NodeServerError> {
2499        let shutdown = self.shutdown_handle();
2500        let run = self.run();
2501        tokio::pin!(run);
2502        tokio::select! {
2503            result = &mut run => result,
2504            signal = wait_for_ctrl_signal() => {
2505                signal?;
2506                let shutdown_result = shutdown.request_shutdown().await;
2507                let run_result = run.await;
2508                shutdown_result.and(run_result)
2509            }
2510        }
2511    }
2512
2513    pub async fn run(self) -> Result<(), NodeServerError> {
2514        let NodeServer {
2515            config,
2516            mut runtime,
2517            shared,
2518            events,
2519            state_path_lock: _state_path_lock,
2520        } = self;
2521        shared.reconcile_materializations();
2522        shared.reconcile_managed_worktrees().await;
2523        shared.retire_unavailable_session_records().await;
2524        shared.reconcile_context_pack_exports().await;
2525        // Lifecycle hooks are retired (owner ruling 2026-09-25): the node
2526        // observes sessions through ACP where a provider has it; there is no
2527        // hook ingress.
2528        let endpoint = config.endpoint.clone();
2529        let call_home = config.call_home;
2530        let call_home_identity = config.call_home_identity.clone();
2531        let api_listen = config.api_listen;
2532        let bridge_listen = config.bridge_listen;
2533        let bridge_underlay = config.bridge_underlay;
2534        let bridge_auth = browser_bridge::BridgeAuth::from_optional(config.bridge_token)
2535            .map_err(|_| NodeServerError::InvalidBridgeToken)?;
2536        let accept_shared = Arc::clone(&shared);
2537        let api_shared = Arc::clone(&shared);
2538        let bridge_shared = Arc::clone(&shared);
2539        let shutdown_shared = Arc::clone(&shared);
2540        let shutdown_timeout = Duration::from_millis(
2541            config.runtime.provider_shutdown_timeout_ms.max(1),
2542        );
2543        let result = {
2544            let runtime_loop = drive_runtime_until_shutdown(
2545                &mut runtime,
2546                events,
2547                Arc::clone(&shared),
2548                shutdown_timeout,
2549            );
2550            let accept_loop = accept_connections(&endpoint, call_home, call_home_identity, accept_shared);
2551            let api_loop = http_api::run(api_listen, api_shared);
2552            let bridge_loop = browser_bridge::run(bridge_listen, bridge_auth, bridge_shared, bridge_underlay);
2553            tokio::pin!(runtime_loop);
2554            tokio::pin!(accept_loop);
2555            tokio::pin!(api_loop);
2556            tokio::pin!(bridge_loop);
2557            tokio::select! {
2558                runtime_result = &mut runtime_loop => {
2559                    let shutdown_result = shutdown_shared
2560                        .begin_shutdown()
2561                        .await
2562                        .map_err(NodeServerError::ShutdownDispatch);
2563                    let (accept_result, api_result, bridge_result) =
2564                        tokio::join!(&mut accept_loop, &mut api_loop, &mut bridge_loop);
2565                    match runtime_result {
2566                        Err(error) => Err(error),
2567                        Ok(()) => shutdown_result
2568                            .and(accept_result)
2569                            .and(api_result.map_err(NodeServerError::HttpApi))
2570                            .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2571                    }
2572                }
2573                accept_result = &mut accept_loop => {
2574                    let shutdown_result = shutdown_shared
2575                        .begin_shutdown()
2576                        .await
2577                        .map_err(NodeServerError::ShutdownDispatch);
2578                    let (runtime_result, api_result, bridge_result) =
2579                        tokio::join!(&mut runtime_loop, &mut api_loop, &mut bridge_loop);
2580                    match accept_result {
2581                        Err(error) => Err(error),
2582                        Ok(()) => shutdown_result
2583                            .and(runtime_result)
2584                            .and(api_result.map_err(NodeServerError::HttpApi))
2585                            .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2586                    }
2587                }
2588                api_result = &mut api_loop => {
2589                    let shutdown_result = shutdown_shared
2590                        .begin_shutdown()
2591                        .await
2592                        .map_err(NodeServerError::ShutdownDispatch);
2593                    let (runtime_result, accept_result, bridge_result) =
2594                        tokio::join!(&mut runtime_loop, &mut accept_loop, &mut bridge_loop);
2595                    match api_result {
2596                        Err(error) => Err(NodeServerError::HttpApi(error)),
2597                        Ok(()) => shutdown_result
2598                            .and(runtime_result)
2599                            .and(accept_result)
2600                            .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2601                    }
2602                }
2603                bridge_result = &mut bridge_loop => {
2604                    let shutdown_result = shutdown_shared
2605                        .begin_shutdown()
2606                        .await
2607                        .map_err(NodeServerError::ShutdownDispatch);
2608                    let (runtime_result, accept_result, api_result) =
2609                        tokio::join!(&mut runtime_loop, &mut accept_loop, &mut api_loop);
2610                    match bridge_result {
2611                        Err(error) => Err(NodeServerError::BridgeApi(error)),
2612                        Ok(()) => shutdown_result
2613                            .and(runtime_result)
2614                            .and(accept_result)
2615                            .and(api_result.map_err(NodeServerError::HttpApi)),
2616                    }
2617                }
2618            }
2619        };
2620        // Persistence gap fix: durable DeliveryStore / ContextPackStore hold
2621        // exclusive flock (Unix) / share-mode locks (Windows) inside NodeShared.
2622        // NodeShutdownHandle also clones that Arc, so without an explicit release
2623        // here a supervisor that keeps the shutdown handle cannot reopen the same
2624        // state path after run() returns (EAGAIN / Resource temporarily unavailable).
2625        // StatePathLock already drops with this stack frame (`_state_path_lock`).
2626        if let Ok(mut guard) = shared.delivery_store.lock() {
2627            *guard = None;
2628        }
2629        if let Ok(mut guard) = shared.context_pack_store.lock() {
2630            *guard = None;
2631        }
2632        result
2633    }
2634}
2635
2636async fn drive_runtime_until_shutdown(
2637    runtime: &mut NativeRuntime,
2638    events: EventSubscription,
2639    shared: Arc<NodeShared>,
2640    shutdown_timeout: Duration,
2641) -> Result<(), NodeServerError> {
2642    // Published once, never per iteration. The worker loops inside
2643    // `NativeRuntime` fold into this profile continuously; turning it into
2644    // percentiles is what costs, and that is `GET /metrics`'s bill to pay.
2645    let _ = shared
2646        .shell_efficiency_profile
2647        .set(runtime.shell_efficiency_profile());
2648    let mut shutdown_started = None;
2649    let mut last_retention_sweep = Instant::now();
2650    let mut last_provisional_reap = Instant::now();
2651    loop {
2652        // Every phase below is timed unconditionally -- see
2653        // `drive_loop_profile`'s own doc comment for why an always-on,
2654        // allocation-free ring is cheap enough to never need a feature
2655        // flag. This is the only place these numbers can be taken: the
2656        // phases are calls made directly from this loop, not something a
2657        // callee could time on our behalf.
2658        let runtime_tick_start = Instant::now();
2659        let tick_result = runtime.tick().await;
2660        let runtime_tick_elapsed = runtime_tick_start.elapsed();
2661
2662        let event_drain_start = Instant::now();
2663        // Every command the kernel refused, named. `NativeRuntimeTick`
2664        // has carried these outcomes all along and nothing read them, so a
2665        // rejected command -- a hook event denied by runtime policy, a
2666        // stale generation, an unsupported capability -- vanished at the
2667        // last step of the loop that produced it. A refusal the operator
2668        // cannot see is indistinguishable from an event that never arrived,
2669        // and telling those two apart is most of debugging this stack.
2670        for outcome in &tick_result.command_outcomes {
2671            if let Err(error) = &outcome.result {
2672                shared.rejected_commands_total.fetch_add(1, Ordering::Relaxed);
2673                tracing::warn!(
2674                    command_id = outcome.command_id.0,
2675                    cause = %error,
2676                    "control command rejected"
2677                );
2678                shared.record_rejected_command(
2679                    outcome.command_id,
2680                    error.to_string(),
2681                    error.is_unsupported_transport(),
2682                );
2683            }
2684        }
2685        let mut events_drained: u64 = 0;
2686        while let Ok(event) = events.try_recv() {
2687            events_drained += 1;
2688            let clean_exit = matches!(
2689                event.event,
2690                ControlEventKind::Exited { exit_code: Some(0), forced: false },
2691            );
2692            let instance_id = event.instance_id;
2693            let generation = event.generation;
2694            // Resolve and spawn before publish_control: publish_control's own
2695            // managed-record bookkeeping (reconcile_managed_record) downgrades
2696            // this exact record from Live to Dormant and clears
2697            // active_session for every Exited event, inside the same
2698            // synchronous call. Capturing (record_id, session) here — before
2699            // that runs — is the only way to observe them still Live and
2700            // still populated; the export itself must still run on its own
2701            // detached task (never awaited inline on this loop) because it
2702            // needs NativeRuntime::tick() to keep running to ever settle, and
2703            // tick() is only ever driven by this same loop. See
2704            // reconcile_context_pack_export_for_record's doc comment.
2705            if clean_exit {
2706                if let Some((record_id, session)) =
2707                    shared.managed_record_export_target_for_instance(instance_id, generation)
2708                {
2709                    let export_shared = Arc::clone(&shared);
2710                    tokio::spawn(async move {
2711                        export_shared
2712                            .reconcile_context_pack_export_for_record(&record_id, session)
2713                            .await;
2714                    });
2715                }
2716            }
2717            shared.publish_control(event);
2718        }
2719        let event_drain_elapsed = event_drain_start.elapsed();
2720
2721        let publish_terminal_frames_start = Instant::now();
2722        shared.publish_terminal_frames();
2723        let publish_terminal_frames_elapsed = publish_terminal_frames_start.elapsed();
2724
2725        // Physical PTY count and the runtime's own per-phase tick timings
2726        // both live inside `NativeRuntime`, which `GET /metrics` (reading
2727        // only `NodeShared`) cannot reach directly -- this loop is the one
2728        // place holding `&mut NativeRuntime`, so it is the only place that
2729        // can copy either out.
2730        shared
2731            .native_session_gauge
2732            .store(runtime.active_native_sessions(), Ordering::Relaxed);
2733        *shared
2734            .runtime_tick_profile
2735            .lock()
2736            .unwrap_or_else(|poisoned| poisoned.into_inner()) = runtime.tick_profile_snapshot();
2737
2738        let remainder_start = Instant::now();
2739        if shared.shutdown.load(Ordering::Acquire) {
2740            let started = *shutdown_started.get_or_insert_with(Instant::now);
2741            let snapshot = shared.handle.snapshot();
2742            let sessions_settled = snapshot.sessions.iter().all(|session| {
2743                !matches!(
2744                    session.status,
2745                    gate4agent_types::SessionStatus::Starting
2746                        | gate4agent_types::SessionStatus::Running
2747                        | gate4agent_types::SessionStatus::Stopping
2748                )
2749            });
2750            if runtime.active_native_sessions() == 0 && sessions_settled {
2751                return Ok(());
2752            }
2753            if started.elapsed() >= shutdown_timeout {
2754                return Err(NodeServerError::ShutdownTimedOut {
2755                    active_native_sessions: runtime.active_native_sessions(),
2756                });
2757            }
2758        }
2759        let remainder_elapsed = remainder_start.elapsed();
2760
2761        {
2762            let mut profile = shared
2763                .drive_loop_profile
2764                .lock()
2765                .unwrap_or_else(|poisoned| poisoned.into_inner());
2766            profile.record_runtime_tick(runtime_tick_elapsed);
2767            profile.record_event_drain(event_drain_elapsed);
2768            profile.record_publish_terminal_frames(publish_terminal_frames_elapsed);
2769            profile.record_remainder(remainder_elapsed);
2770            profile.note_iteration(Instant::now());
2771        }
2772
2773        // "Produced nothing" means this iteration's `NativeRuntime::tick`
2774        // applied zero observations (`tick_result.observations_applied`,
2775        // which already folds in any terminal frames collected -- see
2776        // `NativeEffectDispatcher::drain_observations`) AND the
2777        // control-event subscription drained above forwarded zero events.
2778        // Command ingress and dispatched effects are deliberately NOT part
2779        // of this definition: an idle node still runs `step_control_plane`
2780        // and every provider supervisor's `tick` regardless, every 10ms --
2781        // so "nothing" here means nothing OBSERVABLE left this iteration,
2782        // matching what a reader would assume from `drive_loop_iterations`'s
2783        // own field name, not that the tick itself was free of work.
2784        shared.drive_loop_iterations_total.fetch_add(1, Ordering::Relaxed);
2785        if tick_result.observations_applied == 0 && events_drained == 0 {
2786            shared.drive_loop_iterations_idle.fetch_add(1, Ordering::Relaxed);
2787        }
2788
2789        // A sweep cadence, not a policy value -- see
2790        // `SESSION_RECORD_RETENTION_SWEEP_PERIOD`'s own doc comment. The
2791        // policy itself (whether this does anything at all) lives entirely
2792        // in `NodeShared::session_record_retention`.
2793        if last_retention_sweep.elapsed() >= SESSION_RECORD_RETENTION_SWEEP_PERIOD {
2794            shared.retire_unavailable_session_records().await;
2795            last_retention_sweep = Instant::now();
2796        }
2797        // Provisional Spawn grace: kill PTYs that never got an HQ attach.
2798        if last_provisional_reap.elapsed() >= Duration::from_secs(2) {
2799            for address in shared.take_due_provisional_spawns() {
2800                shared.reap_provisional_spawn(address).await;
2801            }
2802            last_provisional_reap = Instant::now();
2803        }
2804
2805        // Fast-poll for 300ms after PTY input so its echo is read and published
2806        // within ~1ms; otherwise nap 10ms but wake at once on new input.
2807        let hot = shared
2808            .pty_input_marks
2809            .lock()
2810            .unwrap_or_else(|poisoned| poisoned.into_inner())
2811            .values()
2812            .any(|(at, _)| at.elapsed() < Duration::from_millis(300));
2813        if hot {
2814            sleep(Duration::from_millis(1)).await;
2815        } else {
2816            tokio::select! {
2817                _ = sleep(Duration::from_millis(10)) => {}
2818                _ = shared.pty_input_wake.notified() => {}
2819            }
2820        }
2821    }
2822}
2823
2824#[derive(Clone)]
2825pub struct NodeShutdownHandle {
2826    shared: Arc<NodeShared>,
2827}
2828
2829impl NodeShutdownHandle {
2830    pub async fn request_shutdown(&self) -> Result<(), NodeServerError> {
2831        self.shared
2832            .begin_shutdown()
2833            .await
2834            .map_err(NodeServerError::ShutdownDispatch)
2835    }
2836}
2837
2838async fn wait_for_ctrl_signal() -> Result<(), NodeServerError> {
2839    #[cfg(windows)]
2840    {
2841    let mut ctrl_c = tokio::signal::windows::ctrl_c()?;
2842    let mut ctrl_break = tokio::signal::windows::ctrl_break()?;
2843    return tokio::select! {
2844        signal = ctrl_c.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2845        signal = ctrl_break.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2846    };
2847    }
2848    #[cfg(unix)]
2849    {
2850        let mut interrupt = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())?;
2851        let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
2852        tokio::select! {
2853            signal = interrupt.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2854            signal = terminate.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2855        }
2856    }
2857}
2858
2859/// The providers this node actually runs.
2860///
2861/// `builtin_registry()` is a reference catalog of everything the project has
2862/// ever described; the fleet is this list, and it is deliberately short. A
2863/// provider only belongs here if it can be exercised for real -- there is a
2864/// subscription behind it and a failure in it can be reproduced and
2865/// debugged. Carrying one that cannot is carrying code nobody can verify,
2866/// which is worse than not supporting it: it looks supported.
2867///
2868/// A provider dropped from the fleet by this rule is removed outright, not
2869/// carried dark: no catalog entry, no adapter, no dead code claiming support
2870/// for something nobody can verify runs.
2871/// Host interactive shell for HQ terminal attach (no provider CLI, no network).
2872fn shell_host_spec() -> AgentSpec {
2873    // Windows nodes (incl. MSYS-hosted) have no `/bin/bash` for Win32 Path::is_file;
2874    // fleet inventory on op-box resolves `bash` to the WSL stub at
2875    // `/c/Windows/system32/bash`, which is not a host shell. Use ComSpec/cmd.
2876    #[cfg(windows)]
2877    let (detect_command, program, fixed_args, process_name) = {
2878        let program = std::env::var("ComSpec").unwrap_or_else(|_| {
2879            r"C:\Windows\System32\cmd.exe".to_owned()
2880        });
2881        ("cmd".to_owned(), program, Vec::<String>::new(), "cmd".to_owned())
2882    };
2883    #[cfg(not(windows))]
2884    let (detect_command, program, fixed_args, process_name) = (
2885        "bash".to_owned(),
2886        "/bin/bash".to_owned(),
2887        vec!["-i".to_owned()],
2888        "bash".to_owned(),
2889    );
2890    AgentSpec {
2891        id: AgentId::new("shell").expect("shell agent id"),
2892        revision: "hatchery:shell-pty-1".to_owned(),
2893        display_name: "Host shell".to_owned(),
2894        detection: DetectionSpec {
2895            command: detect_command,
2896            aliases: vec!["sh".to_owned(), "shell".to_owned(), "cmd".to_owned()],
2897            required_commands: Vec::new(),
2898            unsupported_platforms: Vec::new(),
2899        },
2900        launch: LaunchSpec {
2901            program,
2902            fixed_args,
2903        },
2904        expected_processes: vec![ProcessMatcher::Exact {
2905            name: process_name,
2906        }],
2907        prompt: PromptSpec {
2908            initial: InitialPromptMode::None,
2909            native_draft: None,
2910        },
2911        readiness: AgentReadinessSpec::default(),
2912        capabilities: AgentCapabilities {
2913            agent_commands: None,
2914            transports: AgentTransportCapabilities {
2915                pty: true,
2916                pty_adapter: None,
2917                pipe: None,
2918                acp: None,
2919            },
2920            adapters: AgentAdapterCapabilities::default(),
2921        },
2922        verification: SpecVerification::Gate4AgentVerified,
2923    }
2924}
2925
2926fn active_registry() -> Result<AgentRegistry, NodeServerError> {
2927    let mut specs = vec![shell_host_spec()];
2928    specs.extend(
2929        builtin_registry()
2930            .iter()
2931            .filter(|spec| matches!(spec.id.as_str(), "claude" | "codex" | "grok" | "kimi"))
2932            .cloned(),
2933    );
2934    AgentRegistry::new(specs).map_err(|error| NodeServerError::Registry(error.to_string()))
2935}
2936
2937fn provider_contract_manifest(
2938    catalog: &AgentRegistry,
2939) -> Result<
2940    (
2941        Vec<ProviderContractSupport>,
2942        Vec<ProviderAdapterContractSupport>,
2943    ),
2944    NodeServerError,
2945> {
2946    let mut provider_contracts = Vec::new();
2947    let mut provider_adapter_contracts = Vec::new();
2948    for spec in catalog.iter() {
2949        let provider = spec.id.clone();
2950        let revision = ProviderContractRevision::new(spec.revision.clone())
2951            .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2952        provider_contracts.push(ProviderContractSupport {
2953            provider: provider.clone(),
2954            revision,
2955        });
2956        for (family, binding) in declared_adapter_bindings(spec)? {
2957            let revision = AdapterContractRevision::new(binding.revision.clone())
2958                .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2959            provider_adapter_contracts.push(ProviderAdapterContractSupport {
2960                provider: provider.clone(),
2961                family,
2962                adapter_id: binding.id.clone(),
2963                revision,
2964            });
2965        }
2966    }
2967    validate_provider_contract_manifest(&provider_contracts, &provider_adapter_contracts)
2968        .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2969    Ok((provider_contracts, provider_adapter_contracts))
2970}
2971
2972fn declared_adapter_bindings(
2973    spec: &AgentSpec,
2974) -> Result<Vec<(AdapterFamily, &AdapterBinding)>, NodeServerError> {
2975    let mut bindings = Vec::new();
2976    push_declared_binding(
2977        &mut bindings,
2978        AdapterFamily::PtySemantic,
2979        spec.capabilities.transports.pty_adapter.as_ref(),
2980    )?;
2981    push_declared_binding(
2982        &mut bindings,
2983        AdapterFamily::Pipe,
2984        spec.capabilities.adapters.pty_sidecar.as_ref(),
2985    )?;
2986    let pipe_transport = spec.capabilities.transports.pipe.as_ref();
2987    let pipe_family = pipe_transport.map(|transport| match transport.protocol {
2988        gate4agent_types::PipeProtocol::SemanticNdjson
2989        | gate4agent_types::PipeProtocol::StructuredJsonl => AdapterFamily::Pipe,
2990        gate4agent_types::PipeProtocol::OneShotText => AdapterFamily::OneShot,
2991    });
2992    push_declared_binding(
2993        &mut bindings,
2994        AdapterFamily::Pipe,
2995        pipe_transport
2996            .filter(|_| pipe_family == Some(AdapterFamily::Pipe))
2997            .map(|transport| &transport.adapter),
2998    )?;
2999    push_declared_binding(
3000        &mut bindings,
3001        AdapterFamily::Acp,
3002        spec.capabilities
3003            .transports
3004            .acp
3005            .as_ref()
3006            .map(|transport| &transport.adapter),
3007    )?;
3008    push_declared_binding(
3009        &mut bindings,
3010        AdapterFamily::Hook,
3011        spec.capabilities.adapters.hook.as_ref(),
3012    )?;
3013    push_declared_binding(
3014        &mut bindings,
3015        AdapterFamily::ManagedHook,
3016        spec.capabilities.adapters.managed_hook.as_ref(),
3017    )?;
3018    push_declared_binding(
3019        &mut bindings,
3020        AdapterFamily::OneShot,
3021        pipe_transport
3022            .filter(|_| pipe_family == Some(AdapterFamily::OneShot))
3023            .map(|transport| &transport.adapter),
3024    )?;
3025    push_declared_binding(
3026        &mut bindings,
3027        AdapterFamily::OneShot,
3028        spec.capabilities.adapters.one_shot.as_ref(),
3029    )?;
3030    push_declared_binding(
3031        &mut bindings,
3032        AdapterFamily::History,
3033        spec.capabilities.adapters.history.as_ref(),
3034    )?;
3035    push_declared_binding(
3036        &mut bindings,
3037        AdapterFamily::Resume,
3038        spec.capabilities.adapters.resume.as_ref(),
3039    )?;
3040    push_declared_binding(
3041        &mut bindings,
3042        AdapterFamily::SessionOptions,
3043        spec.capabilities.adapters.session_options.as_ref(),
3044    )?;
3045    push_declared_binding(
3046        &mut bindings,
3047        AdapterFamily::CapabilityProbe,
3048        spec.capabilities.adapters.capability_probe.as_ref(),
3049    )?;
3050    Ok(bindings)
3051}
3052
3053fn push_declared_binding<'a>(
3054    bindings: &mut Vec<(AdapterFamily, &'a AdapterBinding)>,
3055    family: AdapterFamily,
3056    binding: Option<&'a AdapterBinding>,
3057) -> Result<(), NodeServerError> {
3058    let Some(binding) = binding else {
3059        return Ok(());
3060    };
3061    if let Some((_, existing)) = bindings
3062        .iter()
3063        .find(|(existing_family, _)| *existing_family == family)
3064    {
3065        if *existing == binding {
3066            return Ok(());
3067        }
3068        return Err(NodeServerError::ProviderContractManifest(format!(
3069            "provider declares conflicting {family:?} adapter bindings"
3070        )));
3071    }
3072    bindings.push((family, binding));
3073    Ok(())
3074}
3075
3076fn merge_durable_state(
3077    configured: &[WorkspaceConfig],
3078    mut loaded: LoadedNodeState,
3079) -> Result<(Vec<WorkspaceConfig>, Vec<ManagedSessionRecord>, Option<String>), NodeServerError> {
3080    let mut workspaces = configured
3081        .iter()
3082        .cloned()
3083        .map(|workspace| (workspace.workspace_id.clone(), workspace))
3084        .collect::<BTreeMap<_, _>>();
3085    let mut roots = workspaces
3086        .values()
3087        .map(|workspace| {
3088            (
3089                platform::root_identity(&workspace.canonical_root),
3090                workspace.workspace_id.clone(),
3091            )
3092        })
3093        .collect::<BTreeMap<_, _>>();
3094    let mut warning = loaded
3095        .warning
3096        .take()
3097        .map(|message| session_registry::sanitized_persistence_summary(&message));
3098
3099    for (workspace_id, canonical_root) in loaded.workspaces {
3100        if let Some(existing) = workspaces.get(&workspace_id) {
3101            if !platform::roots_equal(&existing.canonical_root, &canonical_root) {
3102                return Err(NodeServerError::DurableState(io::Error::new(
3103                    io::ErrorKind::InvalidData,
3104                    DURABLE_STATE_CONFLICT_ERROR,
3105                )));
3106            }
3107            continue;
3108        }
3109        if roots.get(&platform::root_identity(&canonical_root)).is_some() {
3110            return Err(NodeServerError::DurableState(io::Error::new(
3111                io::ErrorKind::InvalidData,
3112                DURABLE_STATE_CONFLICT_ERROR,
3113            )));
3114        }
3115        if !Path::new(&canonical_root).is_dir() {
3116            warning.get_or_insert_with(|| {
3117                session_registry::DURABLE_STATE_WORKSPACE_WARNING.to_owned()
3118            });
3119            continue;
3120        }
3121        let workspace = WorkspaceConfig::new(workspace_id.clone(), &canonical_root).map_err(|_| {
3122            NodeServerError::DurableState(io::Error::new(
3123                io::ErrorKind::InvalidData,
3124                DURABLE_STATE_CONFLICT_ERROR,
3125            ))
3126        })?;
3127        roots.insert(
3128            platform::root_identity(&workspace.canonical_root),
3129            workspace.workspace_id.clone(),
3130        );
3131        workspaces.insert(workspace_id, workspace);
3132    }
3133
3134    for record in &mut loaded.records {
3135        match workspaces.get(&record.workspace_id) {
3136            Some(workspace)
3137                if platform::roots_equal(
3138                    &workspace.canonical_root,
3139                    windows_path_text(&record.canonical_root),
3140                ) => {}
3141            Some(_) => {
3142                return Err(NodeServerError::DurableState(io::Error::new(
3143                    io::ErrorKind::InvalidData,
3144                    DURABLE_STATE_CONFLICT_ERROR,
3145                )));
3146            }
3147            None => {
3148                record.state = ManagedSessionState::Unavailable;
3149                record.active_session = None;
3150                record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
3151            }
3152        }
3153    }
3154    Ok((workspaces.into_values().collect(), loaded.records, warning))
3155}
3156
3157/// One entry of `NodeShared::rejected_command_reasons` -- see that field's
3158/// own doc for the mechanism this exists to close.
3159struct RejectedCommandReason {
3160    message: String,
3161    /// True when the kernel refused this command specifically because the
3162    /// agent does not declare the requested transport (`KernelCommandError::
3163    /// UnsupportedTransport`) -- lets the reader choose the named
3164    /// `NodeFailureCode::UnsupportedTransport` over the generic
3165    /// `BackendOperationFailed` bucket, so this one well-known rejection
3166    /// keeps its identity all the way to the operator wire.
3167    unsupported_transport: bool,
3168    expires_at: Instant,
3169}
3170
3171struct NodeShared {
3172    handle: Gate4AgentHandle,
3173    access_token: String,
3174    node_id: NodeId,
3175    incarnation_id: NodeIncarnationId,
3176    started_at_unix_ms: u64,
3177    workspaces: RwLock<BTreeMap<WorkspaceId, String>>,
3178    worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode>,
3179    managed_worktree_profiles:
3180        BTreeMap<WorkspaceId, BTreeMap<WorktreeProfileId, ManagedWorktreeProfile>>,
3181    managed_worktrees: Mutex<ManagedWorktreeRegistry>,
3182    enabled_providers: Vec<AgentId>,
3183    provider_runtime_statuses: ProviderRuntimeStatuses,
3184    provider_runtime_status_updates:
3185        RwLock<BTreeMap<AgentId, crate::protocol::ProviderRuntimeStatus>>,
3186    provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
3187    provider_contracts: Vec<ProviderContractSupport>,
3188    provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
3189    spawn_profiles: SpawnProfileRegistry,
3190    environment_profiles:
3191        RwLock<BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>>,
3192    environment_materialization_profiles:
3193        RwLock<BTreeMap<SpawnEnvironmentProfileId, NodeSessionMaterializationProfile>>,
3194    /// Station **network** allowlist catalog (opaque policy ids only).
3195    /// Empty by default — any `network_allowlist` on spawn must be registered
3196    /// here or resolve refuses with `UnknownNetworkAllowlist`. Optional
3197    /// startup load via `NodeServerConfig::with_network_allowlist_catalog(_file)`
3198    /// / env `GATE4AGENT_NODE_NETWORK_ALLOWLIST_CATALOG`. Plan
3199    /// `station-network-and-browser-profile-knobs-2026-10-02.md` §2.1 / §4.
3200    /// Never holds cookies / OAuth / proxy credentials.
3201    network_allowlist_catalog: RwLock<crate::network_allowlist_catalog::NetworkAllowlistCatalog>,
3202    /// Node-local exclusive dig2browser station profile leases (feature
3203    /// `dig2-station-probe`). Opaque `browser_profile_id` ↔ session holder;
3204    /// cleared on session end. Never ImportSession / cookies on C2.
3205    #[cfg(feature = "dig2-station-probe")]
3206    browser_station_leases: crate::dig2_station_lease::BrowserStationLeaseTable,
3207    bundle_catalog: RwLock<BundleCatalog>,
3208    delivery_store: Mutex<Option<DeliveryStore>>,
3209    context_pack_store: Mutex<Option<ContextPackStore>>,
3210    context_catalog: RwLock<ContextPackCatalog>,
3211    native_launch_profile_control: Option<NativeLaunchProfileControl>,
3212    harness_mcp_registry: Option<HarnessMcpProxyRegistry>,
3213    native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
3214    session_environment_materializer: Option<SessionEnvironmentMaterializer>,
3215    /// Per-session m4a accounts assigned at spawn (see [`crate::m4a_accounts`]).
3216    m4a_accounts: Option<crate::m4a_accounts::M4aSessionAccounts>,
3217    /// nick -> the session that holds that m4a account (for letter wakes).
3218    m4a_live: Mutex<BTreeMap<String, (AgentInstanceId, AgentId)>>,
3219    /// Per-session grok leader sockets (never the operator's shared leader).
3220    grok_leaders: Mutex<BTreeMap<AgentInstanceId, std::path::PathBuf>>,
3221    /// m4a signer proxy (option A): when set, PTY sessions get a process-bound handle
3222    /// instead of a bearer capability.
3223    #[cfg(unix)]
3224    signer_proxy: std::sync::OnceLock<Arc<crate::keystore_proxy::SignerProxy>>,
3225    materializations: Mutex<BTreeMap<MaterializationId, MaterializationOwnershipRecord>>,
3226    spawn_idempotency: Mutex<SpawnIdempotencyCache>,
3227    managed_spawn_replays:
3228        Mutex<BTreeMap<SpawnIdempotencyKey, ManagedWorktreeSpawnReplayRecordV10>>,
3229    controller: Mutex<Option<ControllerLease>>,
3230    /// Fresh spawns waiting for their first successful HQ attach. Removed on
3231    /// Resize/TerminalBytes (sessionize) or when the grace reaper stops them.
3232    provisional_spawns: Mutex<BTreeMap<AgentInstanceId, (SessionAddress, Instant)>>,
3233    /// ACP launch options waiting for the agent to advertise its config options.
3234    pending_acp_options: Mutex<BTreeMap<AgentInstanceId, (Option<String>, Option<String>)>>,
3235    history: Mutex<NodeEventHistory>,
3236    event_tx: broadcast::Sender<NodeEventEnvelope>,
3237    terminal_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3238    /// The outbound `NodeEvent::AgentStream` push channel -- its own
3239    /// dedicated, capability-gated broadcast, mirroring `terminal_event_tx`
3240    /// exactly rather than riding the durable `event_tx` history (see
3241    /// `publish`'s own assertion): a subscriber who never negotiated
3242    /// `NODE_AGENT_STREAM_EVENTS_CAPABILITY` must never receive one of
3243    /// these, the same guarantee `include_terminal_frame_events` already
3244    /// gives terminal frames.
3245    agent_stream_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3246    /// The outbound `NodeEvent::HarnessMcpReadCall` push channel -- mirrors
3247    /// `agent_stream_event_tx` exactly, its own dedicated, capability-gated
3248    /// broadcast rather than a ride on the durable `event_tx` history. A
3249    /// `HarnessMcpReadCall` is a request/response call a live proxy call is
3250    /// waiting on (`harness_mcp_proxy.rs`'s `begin_call`); the shared bus's
3251    /// finite capacity and its willingness to silently evict a lagging
3252    /// subscriber's backlog is exactly how the call vanished with neither
3253    /// side ever logging it (see `publish_transient`'s own doc).
3254    harness_mcp_proxy_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3255    terminal_frame_watermarks:
3256        Mutex<BTreeMap<AgentInstanceId, (SessionAddress, u64)>>,
3257    /// Wakes the drive loop as soon as PTY input is queued (no 10ms nap).
3258    pty_input_wake: tokio::sync::Notify,
3259    /// Per session: when the last PTY input was dispatched (latency probe +
3260    /// a short fast-poll window so the echo is published within ~1ms).
3261    pty_input_marks: Mutex<BTreeMap<AgentInstanceId, (Instant, bool)>>,
3262    next_connection_id: AtomicU64,
3263    next_instance_id: AtomicU64,
3264    next_command_id: AtomicU64,
3265    shutdown: AtomicBool,
3266    shutdown_notify: Notify,
3267    preauth_slots: Arc<Semaphore>,
3268    authenticated_slots: Arc<Semaphore>,
3269    inspection_slots: Arc<Semaphore>,
3270    mutation_gate: AsyncMutex<()>,
3271    session_bindings: Mutex<BTreeMap<AgentInstanceId, SessionBinding>>,
3272    session_records: Mutex<SessionRecords>,
3273    state_path: Option<PathBuf>,
3274    persistence_error: RwLock<Option<String>>,
3275    state_transaction: Mutex<()>,
3276    input_settle_timeout_ms: u64,
3277    /// Drive-loop phase/rate timings -- see `drive_loop_profile`'s own doc
3278    /// comment. Written once per iteration by
3279    /// `drive_runtime_until_shutdown`, read by `GET /metrics`.
3280    drive_loop_profile: Mutex<DriveLoopProfiler>,
3281    /// Latest copy of `NativeRuntime::tick_profile_snapshot()` -- `Copy`,
3282    /// so an overwrite each iteration is cheap. The runtime itself is not
3283    /// reachable from here (it is owned locally by
3284    /// `drive_runtime_until_shutdown`), so this is how its own per-phase
3285    /// tick timings reach `GET /metrics`.
3286    runtime_tick_profile: Mutex<TickProfileSnapshot>,
3287    /// The live shell-efficiency profile, shared with the per-instance
3288    /// worker loops that fold into it.
3289    ///
3290    /// Deliberately NOT the per-iteration snapshot copy `runtime_tick_
3291    /// profile` above keeps: producing a snapshot sorts every ring behind
3292    /// it, and that sort belongs on the read. A `OnceLock` rather than a
3293    /// field set at construction because `NodeShared` is built before the
3294    /// `NativeRuntime` that owns the profile exists -- the drive loop
3295    /// publishes it once, before its first iteration. Unset means the
3296    /// runtime has not started driving yet, which `GET /metrics` reports as
3297    /// empty series rather than as zeroes it never measured.
3298    shell_efficiency_profile: OnceLock<Arc<Mutex<ShellEfficiencyProfile>>>,
3299    /// Physical PTY sessions the native runtime currently owns
3300    /// (`NativeRuntime::active_native_sessions`), refreshed once per drive
3301    /// loop iteration -- deliberately a separate count from
3302    /// `handle.snapshot().sessions.len()` (the control-plane kernel's own
3303    /// session bookkeeping), since the two can disagree and the gap is
3304    /// itself a diagnostic signal `GET /metrics` exposes.
3305    native_session_gauge: AtomicUsize,
3306    /// Lifetime totals for the per-connection serve loop, summed across
3307    /// every live connection.
3308    ///
3309    /// The drive loop's own phase timings account for about ten
3310    /// microseconds an iteration on an idle node, yet the process burns a
3311    /// quarter of a core — and burns it ONLY while a c2 is attached (0.5%
3312    /// with none, 23% with two, measured). That gap is somewhere in
3313    /// serving a connection, and there was no way to see it: iterations
3314    /// says whether the loop is spinning rather than sleeping, and events
3315    /// says whether it is spinning over real work or over nothing.
3316    connection_loop_iterations: AtomicU64,
3317    connection_events_sent: AtomicU64,
3318    /// Lifetime totals for `drive_runtime_until_shutdown`'s own iterations
3319    /// -- `drive_loop_iterations_idle` counts iterations that applied zero
3320    /// `NativeRuntime::tick` observations AND forwarded zero control
3321    /// events; see the exact "produced nothing" definition at the site in
3322    /// `drive_runtime_until_shutdown` that increments these. Read twice
3323    /// and divide by the interval, same rule as `connections` above --
3324    /// `idle / total` is backlog item 5's "wake on work" number.
3325    /// Lifetime count of control commands the kernel refused.
3326    rejected_commands_total: AtomicU64,
3327    /// The named reason behind a recent kernel rejection, by the
3328    /// `CommandId` `dispatch_bounded` handed back when it enqueued that
3329    /// command -- populated by `drive_runtime_until_shutdown` for every
3330    /// `Err` in a tick's `command_outcomes`, and drained by whichever
3331    /// spawn-dispatch waiter recognizes its own `Register`/`Start`
3332    /// `CommandId` among them.
3333    ///
3334    /// A `ControlCommand` only ever fails synchronously (a `NodeFailure`
3335    /// out of `dispatch`/`dispatch_bounded` itself) for a port-level ingress
3336    /// problem -- full or disconnected -- never for the kernel refusing the
3337    /// command's content (an unsupported transport, an unknown agent, ...);
3338    /// that verdict exists only once `NativeRuntime::tick` has processed the
3339    /// command, one drive-loop iteration later, in a different task than
3340    /// whichever caller is awaiting the spawn. Without this map, a spawn
3341    /// wait loop that polls `self.handle.snapshot()` for a session that
3342    /// will now never appear has no way to learn why -- it can only spin
3343    /// until its own commit deadline and report a generic "did not commit"
3344    /// timeout, discarding a reason the kernel already named precisely (see
3345    /// `KernelCommandError`'s variants, e.g. `UnsupportedTransport`).
3346    rejected_command_reasons: Mutex<BTreeMap<CommandId, RejectedCommandReason>>,
3347    drive_loop_iterations_total: AtomicU64,
3348    drive_loop_iterations_idle: AtomicU64,
3349    /// The operator-chosen age/keep-N policy the retention sweep applies to
3350    /// `ManagedSessionState::Unavailable` records. Both `0` by default --
3351    /// see `SessionRecordRetentionConfig`'s own doc comment.
3352    session_record_retention: SessionRecordRetentionConfig,
3353    /// Lifetime count of managed session records the retention sweep has
3354    /// retired. Same tier as `rejected_commands_total` above: a live,
3355    /// process-lifetime instrument, never persisted and never reloaded from
3356    /// `state-v1.json` -- a restart legitimately resets it, matching every
3357    /// other `*_total` counter here.
3358    retired_records_total: AtomicU64,
3359    #[cfg(feature = "fixture")]
3360    fixture_semantic_hook_policy: bool,
3361    #[cfg(feature = "fixture")]
3362    fixture_spawn_managed_worktree_v2_failure_probe: SpawnManagedWorktreeV2FailureProbe,
3363}
3364
3365impl Drop for NodeShared {
3366    fn drop(&mut self) {
3367        SessionLifecycleHook::on_shutdown(self);
3368    }
3369}
3370
3371#[derive(Clone, Debug, Eq, PartialEq)]
3372struct SessionBinding {
3373    workspace_id: WorkspaceId,
3374    generation: SessionGeneration,
3375    runtime_policy: ProviderRuntimePolicy,
3376    pending_resume: Option<(SessionGeneration, CommandId, ProviderRuntimePolicy)>,
3377    record_id: Option<SessionRecordId>,
3378    managed_worktree_lease_id: Option<ManagedWorktreeLeaseId>,
3379    environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
3380    bundle: Option<ResolvedBundleReceipt>,
3381    context: Option<ResolvedContextPackReceipt>,
3382    materialization_id: Option<MaterializationId>,
3383}
3384
3385#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3386enum SpawnRecordPolicy {
3387    ProviderIdentityOnly,
3388    Always,
3389}
3390
3391struct NativeEnvironmentSelectionGuard {
3392    control: Option<NativeLaunchProfileControl>,
3393    instance_id: AgentInstanceId,
3394}
3395
3396struct NativeInstanceOverlayGuard {
3397    control: Option<NativeLaunchProfileControl>,
3398    instance_id: AgentInstanceId,
3399}
3400
3401struct NativeHarnessMcpOverlayGuard {
3402    control: NativeLaunchProfileControl,
3403    instance_id: AgentInstanceId,
3404}
3405
3406impl NativeHarnessMcpOverlayGuard {
3407    fn retain(self) { std::mem::forget(self); }
3408}
3409
3410impl Drop for NativeHarnessMcpOverlayGuard {
3411    fn drop(&mut self) {
3412        self.control
3413            .clear_native_mcp_server_launch_overlay(self.instance_id);
3414    }
3415}
3416
3417impl NativeInstanceOverlayGuard {
3418    fn retain(mut self) {
3419        self.control = None;
3420    }
3421}
3422
3423impl Drop for NativeInstanceOverlayGuard {
3424    fn drop(&mut self) {
3425        if let Some(control) = self.control.as_ref() {
3426            control.clear_native_instance_launch_overlay(self.instance_id);
3427        }
3428    }
3429}
3430
3431enum PreparedNativeLaunchOverlay {
3432    Environment(NativeLaunchEnvironmentOverlay),
3433    Instance(NativeInstanceLaunchOverlay),
3434}
3435
3436impl NativeEnvironmentSelectionGuard {
3437    fn retain(mut self) {
3438        self.control = None;
3439    }
3440}
3441
3442impl Drop for NativeEnvironmentSelectionGuard {
3443    fn drop(&mut self) {
3444        if let Some(control) = self.control.as_ref() {
3445            control.clear_native_launch_profile_selection(self.instance_id);
3446        }
3447    }
3448}
3449
3450struct SessionMaterializationGuard<'a> {
3451    shared: &'a NodeShared,
3452    id: Option<MaterializationId>,
3453    /// Absolute ProviderHome (`home/`) under the session materialization root.
3454    /// Used for Claude station network `--settings` sidecar writes.
3455    provider_home: Option<PathBuf>,
3456}
3457
3458impl SessionMaterializationGuard<'_> {
3459    fn id(&self) -> Option<&MaterializationId> {
3460        self.id.as_ref()
3461    }
3462
3463    fn provider_home(&self) -> Option<&Path> {
3464        self.provider_home.as_deref()
3465    }
3466
3467    fn retain(mut self) {
3468        self.id = None;
3469    }
3470}
3471
3472impl Drop for SessionMaterializationGuard<'_> {
3473    fn drop(&mut self) {
3474        if let Some(id) = self.id.take() {
3475            let _ = self.shared.cleanup_materialization(&id);
3476        }
3477    }
3478}
3479
3480
3481/// Port: product-side reactions to session lifecycle transitions
3482/// (station lease, harness MCP overlay/registry, environment launch overlays).
3483trait SessionLifecycleHook {
3484    /// A binding was removed (unbind or spawn rollback).
3485    fn on_binding_removed(&self, address: &SessionAddress, removed: Option<&SessionBinding>);
3486    /// A session reached a terminal control event that revokes its harness MCP door.
3487    fn on_session_ended(&self, address: &SessionAddress);
3488    /// Node shutdown (or drop): revoke every harness MCP reservation.
3489    fn on_shutdown(&self);
3490    /// A harness MCP reservation was aborted before activation.
3491    fn on_harness_reservation_aborted(&self, instance_id: AgentInstanceId);
3492    /// Release session-owned materialization for a removed binding.
3493    fn cleanup_session_materialization(
3494        &self,
3495        address: &SessionAddress,
3496        binding: &SessionBinding,
3497    ) -> Result<(), NodeFailure>;
3498}
3499
3500impl SessionLifecycleHook for NodeShared {
3501    fn on_session_ended(&self, address: &SessionAddress) {
3502        self.retire_session_grok_leader(address.session.instance_id);
3503        if let Some(accounts) = self.m4a_accounts.as_ref() {
3504            let instance = address.session.instance_id;
3505            let live = self.m4a_live.lock().unwrap_or_else(|p| p.into_inner());
3506            for (nick, _) in live.iter().filter(|(_, (i, _))| *i == instance) {
3507                accounts.remove_session_file(nick);
3508            }
3509        }
3510        if let Some(registry) = self.harness_mcp_registry.as_ref() {
3511            registry.revoke_session(address);
3512        }
3513        if let Some(control) = self.native_launch_profile_control.as_ref() {
3514            control.clear_native_mcp_server_launch_overlay(address.session.instance_id);
3515        }
3516    }
3517
3518    fn on_shutdown(&self) {
3519        let leaders: Vec<_> = std::mem::take(&mut *self.grok_leaders.lock().unwrap_or_else(|p| p.into_inner()))
3520            .into_values()
3521            .collect();
3522        for socket in leaders {
3523            crate::grok_leader::retire(&socket);
3524        }
3525        if let Some(registry) = self.harness_mcp_registry.as_ref() {
3526            let instances = registry.shutdown();
3527            if let Some(control) = self.native_launch_profile_control.as_ref() {
3528                for instance_id in instances {
3529                    control.clear_native_mcp_server_launch_overlay(instance_id);
3530                }
3531            }
3532        }
3533    }
3534
3535    fn on_harness_reservation_aborted(&self, instance_id: AgentInstanceId) {
3536        if let Some(control) = self.native_launch_profile_control.as_ref() {
3537            control.clear_native_mcp_server_launch_overlay(instance_id);
3538        }
3539    }
3540
3541    fn cleanup_session_materialization(
3542        &self,
3543        address: &SessionAddress,
3544        binding: &SessionBinding,
3545    ) -> Result<(), NodeFailure> {
3546        self.cleanup_session_owned_materialization(address, binding)
3547    }
3548
3549    fn on_binding_removed(&self, address: &SessionAddress, removed: Option<&SessionBinding>) {
3550        // Exclusive dig2browser station profile lease is node-local; clear
3551        // on every session unbind / rollback. Never ImportSession/cookies.
3552        #[cfg(feature = "dig2-station-probe")]
3553        {
3554            crate::dig2_station_lease::release_lease_for_holder(
3555                &self.browser_station_leases,
3556                address.session.instance_id,
3557            );
3558        }
3559        if let Some(control) = self.native_launch_profile_control.as_ref() {
3560            control.clear_native_mcp_server_launch_overlay(
3561                address.session.instance_id,
3562            );
3563        }
3564        if let Some(registry) = self.harness_mcp_registry.as_ref() {
3565            registry.revoke_session(address);
3566        }
3567        if removed
3568            .is_some_and(|binding| {
3569                binding.environment_profile.is_some()
3570                    || binding.bundle.is_some()
3571                    || binding.context.is_some()
3572            })
3573        {
3574            if let Some(control) = self.native_launch_profile_control.as_ref() {
3575                control.clear_native_launch_profile_selection(
3576                    address.session.instance_id,
3577                );
3578                control.clear_native_instance_launch_overlay(
3579                    address.session.instance_id,
3580                );
3581            }
3582        }
3583    }
3584}
3585
3586struct SpawnPrepareRequest<'r> {
3587    instance_id: AgentInstanceId,
3588    address: &'r SessionAddress,
3589    provider: &'r AgentId,
3590    mode: SessionMode,
3591    environment_profile: Option<&'r ResolvedEnvironmentProfileReceipt>,
3592    bundle: Option<&'r ResolvedBundleReceipt>,
3593    context: Option<&'r ResolvedContextPackReceipt>,
3594    managed_authority: Option<&'r ManagedWorktreeLeaseId>,
3595    harness_mcp: Option<&'r PreparedHarnessMcpSpawn>,
3596    approval_level: ApprovalLevel,
3597    network_allowlist: Option<&'r crate::protocol::SpawnNetworkAllowlistId>,
3598    browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
3599}
3600
3601/// Guards held between "session identity minted" and "launch committed".
3602/// Field order is the drop order and matches the pre-refactor local order
3603/// (instance overlay, environment selection, materialization, harness, station lease).
3604struct PreparedSpawn<'a> {
3605    instance_overlay: Option<NativeInstanceOverlayGuard>,
3606    environment_selection: Option<NativeEnvironmentSelectionGuard>,
3607    materialization_guard: Option<SessionMaterializationGuard<'a>>,
3608    harness_mcp_overlay: Option<NativeHarnessMcpOverlayGuard>,
3609    #[cfg(feature = "dig2-station-probe")]
3610    browser_station_lease_guard: Option<crate::dig2_station_lease::BrowserStationLeaseGuard>,
3611    materialization_id: Option<MaterializationId>,
3612}
3613
3614impl PreparedSpawn<'_> {
3615    /// Session-owned launch state now belongs to the binding.
3616    fn retain_session_guards(&mut self) {
3617        if let Some(selection) = self.environment_selection.take() {
3618            selection.retain();
3619        }
3620        if let Some(overlay) = self.instance_overlay.take() {
3621            overlay.retain();
3622        }
3623        if let Some(guard) = self.materialization_guard.take() {
3624            guard.retain();
3625        }
3626    }
3627
3628    /// Launch committed: keep harness overlay and station lease for the session lifetime.
3629    fn retain_launch_guards(&mut self) {
3630        if let Some(overlay) = self.harness_mcp_overlay.take() {
3631            overlay.retain();
3632        }
3633        #[cfg(feature = "dig2-station-probe")]
3634        if let Some(guard) = self.browser_station_lease_guard.take() {
3635            guard.retain();
3636        }
3637    }
3638}
3639
3640/// Port: everything product-side that must be prepared before a session is
3641/// bound and started (station lease, harness MCP overlay, materialization,
3642/// environment selection, network overlays, launch overlay).
3643trait SpawnPreparer {
3644    fn prepare<'a>(&'a self, request: SpawnPrepareRequest<'_>) -> Result<PreparedSpawn<'a>, NodeFailure>;
3645    /// Select the native launch profile for an environment-profile session.
3646    fn select_environment(
3647        &self,
3648        instance_id: AgentInstanceId,
3649        provider: &AgentId,
3650        mode: SessionMode,
3651        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
3652    ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure>;
3653    /// Install a prepared launch overlay for the instance.
3654    fn install_launch_overlay(
3655        &self,
3656        instance_id: AgentInstanceId,
3657        overlay: PreparedNativeLaunchOverlay,
3658    ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure>;
3659}
3660
3661impl SpawnPreparer for NodeShared {
3662    fn select_environment(
3663        &self,
3664        instance_id: AgentInstanceId,
3665        provider: &AgentId,
3666        mode: SessionMode,
3667        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
3668    ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
3669        self.select_environment_profile(instance_id, provider, mode, environment_profile)
3670    }
3671
3672    fn install_launch_overlay(
3673        &self,
3674        instance_id: AgentInstanceId,
3675        overlay: PreparedNativeLaunchOverlay,
3676    ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
3677        self.install_prepared_launch_overlay(instance_id, overlay)
3678    }
3679
3680    fn prepare<'a>(&'a self, request: SpawnPrepareRequest<'_>) -> Result<PreparedSpawn<'a>, NodeFailure> {
3681        let SpawnPrepareRequest {
3682            instance_id,
3683            address,
3684            provider,
3685            mode,
3686            environment_profile,
3687            bundle,
3688            context,
3689            managed_authority,
3690            harness_mcp,
3691            approval_level,
3692            network_allowlist,
3693            browser_profile_id,
3694        } = request;
3695        // Track A follow-on: exclusive node-local BrowserStationLease after
3696        // dig2 probe (feature dig2-station-probe). Opaque profile id only —
3697        // never ImportSession / cookies on C2. Linux/feature-off: probe
3698        // already refused or stub; this block is cfg'd out when feature off.
3699        #[cfg(feature = "dig2-station-probe")]
3700        let mut browser_station_lease_guard = if let Some(profile_id) = browser_profile_id {
3701            use crate::dig2_station_lease::{acquire_lease, BrowserStationLeaseError};
3702            match acquire_lease(
3703                &self.browser_station_leases,
3704                profile_id,
3705                instance_id,
3706                unix_time_ms(),
3707            ) {
3708                Ok(guard) => Some(guard),
3709                Err(BrowserStationLeaseError::ProfileBusy { .. }) => {
3710                    return Err(failure(
3711                        NodeFailureCode::BrowserStationProfileBusy,
3712                        "dig2browser station profile is already leased by another session",
3713                    ));
3714                }
3715                Err(BrowserStationLeaseError::CapacityExceeded) => {
3716                    return Err(failure(
3717                        NodeFailureCode::BrowserStationProfileBusy,
3718                        "dig2browser station profile lease capacity exhausted",
3719                    ));
3720                }
3721                Err(BrowserStationLeaseError::HolderAlreadyLeased) => {
3722                    return Err(failure(
3723                        NodeFailureCode::BrowserStationProfileBusy,
3724                        "session already holds a dig2browser station profile lease",
3725                    ));
3726                }
3727            }
3728        } else {
3729            None
3730        };
3731        #[cfg(not(feature = "dig2-station-probe"))]
3732        let _ = browser_profile_id;
3733        let mut harness_mcp_overlay = if let Some(prepared) = harness_mcp {
3734            // Slice A(ii) of gate4agent-arc-mailbox-and-task-layer: the door
3735            // exists for PTY (env vars) and ACP (`session/new.mcpServers`,
3736            // `gate4agent-shell-native`'s `mcp_server_acp_entry`) alike --
3737            // both read the identical overlay resolved below, one as child
3738            // environment, the other translated into one stdio MCP server
3739            // entry. `Inline`/`Pipe` stays refused: this arc's proof is ACP
3740            // only, and nothing downstream reads a harness-MCP overlay for
3741            // that transport yet.
3742            if !matches!(mode, SessionMode::Pty | SessionMode::Acp) || prepared.provider != *provider
3743            {
3744                return Err(failure(
3745                    NodeFailureCode::BindingMismatch,
3746                    "harness MCP reservation does not match the exact PTY/ACP provider",
3747                ));
3748            }
3749            prepared.verify_helper_program().map_err(harness_mcp_failure)?;
3750            let control = self.native_launch_profile_control.clone().ok_or_else(|| {
3751                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP launch control is unavailable")
3752            })?;
3753            // Every name below is the caller's, carried in the reservation's
3754            // launch description: which environment variables receive the
3755            // endpoint, token and helper program, what the MCP server is called,
3756            // which argv it gets and which stale variables are erased.
3757            let launch = prepared.launch();
3758            let mut env_entries = vec![
3759                (
3760                    OsString::from(launch.endpoint_env.as_str()),
3761                    prepared.endpoint().as_os_str().to_os_string(),
3762                ),
3763                (
3764                    OsString::from(launch.token_env.as_str()),
3765                    prepared.token().expose().into(),
3766                ),
3767                (
3768                    OsString::from(launch.program_env.as_str()),
3769                    prepared.helper_program().as_os_str().to_os_string(),
3770                ),
3771            ];
3772            if let Some(trace_entry) = harness_mcp_trace_env_entry(launch) {
3773                env_entries.push(trace_entry);
3774            }
3775            if let Some(door) = crate::door::door_listen_addr() {
3776                env_entries.push((
3777                    OsString::from("HATCHERY_NODE_DOOR"),
3778                    OsString::from(door.to_string()),
3779                ));
3780                // Same key shape as HQ browser/draft ids:
3781                // `pty-{incarnation}-{workspace}-{instance}` — never reuse a
3782                // slot across node boots when the instance counter resets.
3783                let session_key = format!(
3784                    "pty-{}-{}-{}",
3785                    self.incarnation_id,
3786                    address.workspace_id.as_str(),
3787                    address.session.instance_id.0
3788                );
3789                env_entries.push((
3790                    OsString::from("HATCHERY_LOCAL_SERVER_SESSION"),
3791                    OsString::from(session_key),
3792                ));
3793            }
3794            let overlay = NativeMcpServerLaunchOverlay::new(
3795                provider.clone(),
3796                launch.server_name.as_str(),
3797                prepared.helper_program().as_os_str().to_os_string(),
3798                launch.args.iter().map(OsString::from).collect(),
3799                env_entries,
3800                launch.scrub_env.iter().map(OsString::from).collect(),
3801            ).map_err(|_| failure(
3802                NodeFailureCode::HarnessMcpUnavailable,
3803                "harness MCP launch overlay is unavailable",
3804            ))?;
3805            control
3806                .install_native_mcp_server_launch_overlay(instance_id, overlay)
3807                .map_err(|_| failure(
3808                    NodeFailureCode::HarnessMcpUnavailable,
3809                    "harness MCP launch overlay could not be installed",
3810                ))?;
3811            // State-change line: the overlay is installed and will be read
3812            // back by the PTY child's environment or, for ACP,
3813            // `gate4agent-shell-native`'s `mcp_server_acp_entry` at spawn
3814            // time -- no secrets here, only which instance/provider/mode
3815            // now carries a harness-MCP door.
3816            tracing::info!(
3817                node_id = %self.node_id,
3818                instance_id = ?instance_id,
3819                provider = %provider,
3820                mode = ?mode,
3821                "harness MCP overlay installed for spawn",
3822            );
3823            Some(NativeHarnessMcpOverlayGuard { control, instance_id })
3824        } else {
3825            None
3826        };
3827        let prepared_materialization = self.prepare_session_materialization(
3828            &address,
3829            &provider,
3830            mode,
3831            environment_profile,
3832            bundle,
3833            context,
3834            managed_authority.cloned(),
3835        )?;
3836        let (environment_overlay, mut materialization_guard) = match prepared_materialization {
3837            Some((overlay, guard)) => (Some(overlay), Some(guard)),
3838            None => (None, None),
3839        };
3840        let materialization_id = materialization_guard
3841            .as_ref()
3842            .and_then(SessionMaterializationGuard::id)
3843            .cloned();
3844        let mut environment_selection = SpawnPreparer::select_environment(self, 
3845            instance_id,
3846            &provider,
3847            mode,
3848            environment_profile,
3849        )?;
3850        // Station network overlays: Codex `-c network_access` and/or Claude
3851        // ProviderHome `--settings` sidecar (permits → allowedDomains).
3852        let provider_home = materialization_guard
3853            .as_ref()
3854            .and_then(SessionMaterializationGuard::provider_home);
3855        let network_overlay_args = self.resolve_network_allowlist_overlay_args(
3856            &provider,
3857            mode,
3858            approval_level,
3859            network_allowlist,
3860            provider_home,
3861        )?;
3862        let mut network_overlay_args = network_overlay_args;
3863        if let Some(socket) = self.session_grok_leader_socket(&provider, mode, instance_id) {
3864            network_overlay_args.extend(crate::grok_leader::leader_socket_args(&socket));
3865        }
3866        let environment_overlay = match (environment_overlay.flatten(), network_overlay_args) {
3867            (prepared, args) if args.is_empty() => prepared,
3868            (Some(PreparedNativeLaunchOverlay::Instance(mut overlay)), args) => {
3869                overlay.append_extra_args(args).map_err(|_| {
3870                    failure(
3871                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3872                        "network allowlist overlay could not merge with instance launch args",
3873                    )
3874                })?;
3875                Some(PreparedNativeLaunchOverlay::Instance(overlay))
3876            }
3877            (Some(PreparedNativeLaunchOverlay::Environment(overlay)), args) => {
3878                Some(PreparedNativeLaunchOverlay::Instance(
3879                    overlay
3880                        .into_instance_with_extra_args(args)
3881                        .map_err(|_| {
3882                            failure(
3883                                NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3884                                "network allowlist overlay could not merge with environment overlay",
3885                            )
3886                        })?,
3887                ))
3888            }
3889            (None, args) => Some(PreparedNativeLaunchOverlay::Instance(
3890                NativeInstanceLaunchOverlay::new(
3891                    provider.clone(),
3892                    match mode {
3893                        SessionMode::Pty => TransportKind::Pty,
3894                        SessionMode::Acp => TransportKind::Acp,
3895                        SessionMode::Inline => TransportKind::Pipe,
3896                    },
3897                    Vec::new(),
3898                    args,
3899                )
3900                .map_err(|_| {
3901                    failure(
3902                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3903                        "network allowlist overlay is invalid for this transport",
3904                    )
3905                })?,
3906            )),
3907        };
3908        // Per-session m4a account: own nick + capability for this PTY child only, on its own
3909        // overlay or appended to the overlay the spawn already carries.
3910        #[cfg(unix)]
3911        // `HATCHERY_M4A_SIGNER_SHELL=1` also gives operator shells an identity (live canaries).
3912        let signer_provider = crate::m4a_accounts::m4a_provider(provider.as_str()).or_else(|| {
3913            (provider.as_str() == "shell" && std::env::var_os("HATCHERY_M4A_SIGNER_SHELL").is_some_and(|v| v == "1")).then_some("shell")
3914        });
3915        #[cfg(unix)]
3916        let signer_identity = match (mode, self.signer_proxy.get(), signer_provider) {
3917            (SessionMode::Pty, Some(proxy), Some(m4a_provider)) => {
3918                let session_key = format!("{}-{}", self.incarnation_id, instance_id.0);
3919                let uid = unsafe { libc_geteuid() };
3920                let handle = proxy.reserve(&session_key, m4a_provider, uid);
3921                let sock = std::env::var_os(crate::keystore_proxy::SOCKET_ENV).unwrap_or_default();
3922                tracing::info!(provider = %provider, instance_id = ?instance_id, "m4a signer handle reserved at spawn");
3923                Some(vec![
3924                    gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from(crate::keystore_proxy::HANDLE_ENV), value: Some(std::ffi::OsString::from(handle)) },
3925                    gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from(crate::keystore_proxy::SOCKET_ENV), value: Some(sock) },
3926                    gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from("M4A_SESSION_ID"), value: Some(std::ffi::OsString::from(session_key)) },
3927                ])
3928            }
3929            _ => None,
3930        };
3931        #[cfg(not(unix))]
3932        let signer_identity: Option<Vec<gate4agent_catalog::EnvMutation>> = None;
3933        let environment_overlay = match (mode, self.m4a_accounts.as_ref(), crate::m4a_accounts::m4a_provider(provider.as_str()), signer_identity) {
3934            (SessionMode::Pty, _, _, Some(identity)) => {
3935                let merged = match environment_overlay {
3936                    None => NativeInstanceLaunchOverlay::new_session_identity(provider.clone(), identity).map(PreparedNativeLaunchOverlay::Instance),
3937                    Some(PreparedNativeLaunchOverlay::Instance(mut overlay)) => {
3938                        overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
3939                    }
3940                    Some(PreparedNativeLaunchOverlay::Environment(mut overlay)) => {
3941                        overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Environment(overlay))
3942                    }
3943                }
3944                .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a signer handle overlay is invalid"))?;
3945                Some(merged)
3946            }
3947            (SessionMode::Pty, Some(accounts), Some(m4a_provider), None) => {
3948                let session_key = format!("{}-{}", self.incarnation_id, instance_id.0);
3949                match accounts.provision(m4a_provider, &session_key) {
3950                    Ok(account) => {
3951                        let identity = account.env_mutations();
3952                        let merged = match environment_overlay {
3953                            None => NativeInstanceLaunchOverlay::new_session_identity(provider.clone(), identity)
3954                                .map(PreparedNativeLaunchOverlay::Instance),
3955                            Some(PreparedNativeLaunchOverlay::Instance(mut overlay)) => {
3956                                overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
3957                            }
3958                            Some(PreparedNativeLaunchOverlay::Environment(mut overlay)) => {
3959                                overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Environment(overlay))
3960                            }
3961                        }
3962                        .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a session account overlay is invalid"))?;
3963                        tracing::info!(provider = %provider, instance_id = ?instance_id, nick = %account.nick, "m4a session account assigned at spawn");
3964                        let mut merged = merged;
3965                        match accounts.write_session_file(&account) {
3966                            Err(error) => tracing::warn!(nick = %account.nick, %error, "m4a session file not written"),
3967                            Ok(_) => match accounts.mcp_launch_args(provider.as_str(), &account.nick) {
3968                                Ok(args) if !args.is_empty() => {
3969                                    merged = match merged {
3970                                        PreparedNativeLaunchOverlay::Instance(mut overlay) => overlay
3971                                            .append_extra_args(args)
3972                                            .map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
3973                                            .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a mcp overlay is invalid"))?,
3974                                        PreparedNativeLaunchOverlay::Environment(overlay) => overlay
3975                                            .into_instance_with_extra_args(args)
3976                                            .map(PreparedNativeLaunchOverlay::Instance)
3977                                            .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a mcp overlay is invalid"))?,
3978                                    };
3979                                    tracing::info!(nick = %account.nick, "m4a mcp server delivered at spawn");
3980                                }
3981                                Ok(_) => {}
3982                                Err(error) => tracing::warn!(nick = %account.nick, %error, "m4a mcp server not delivered"),
3983                            },
3984                        }
3985                        self.m4a_live
3986                            .lock()
3987                            .unwrap_or_else(|p| p.into_inner())
3988                            .insert(account.nick.clone(), (instance_id, provider.clone()));
3989                        Some(merged)
3990                    }
3991                    Err(error) => {
3992                        tracing::warn!(provider = %provider, instance_id = ?instance_id, %error, "m4a session account not assigned");
3993                        environment_overlay
3994                    }
3995                }
3996            }
3997            (SessionMode::Pty, Some(_), None, _) => {
3998                tracing::warn!(provider = %provider, instance_id = ?instance_id, "m4a session account not assigned: provider has no m4a mapping");
3999                environment_overlay
4000            }
4001            _ => environment_overlay,
4002        };
4003        let mut instance_overlay = environment_overlay
4004            .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
4005            .transpose()?
4006            .flatten();
4007        Ok(PreparedSpawn {
4008            instance_overlay,
4009            environment_selection,
4010            materialization_guard,
4011            harness_mcp_overlay,
4012            #[cfg(feature = "dig2-station-probe")]
4013            browser_station_lease_guard,
4014            materialization_id,
4015        })
4016    }
4017}
4018
4019struct SessionRecords {
4020    records: BTreeMap<SessionRecordId, ManagedSessionRecord>,
4021}
4022
4023struct SpawnIdempotencyCache {
4024    entries: BTreeMap<SpawnIdempotencyKey, SpawnIdempotencyEntry>,
4025}
4026
4027struct SpawnIdempotencyEntry {
4028    value: SpawnIdempotencyValue,
4029    expires_at: Instant,
4030}
4031
4032enum SpawnIdempotencyValue {
4033    Standard {
4034        spec: SpawnSpec,
4035        result: Result<ResolvedSpawnReceipt, NodeFailure>,
4036    },
4037    Managed {
4038        request: ManagedWorktreeSpawnRequest,
4039        result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
4040    },
4041    HarnessMcp,
4042}
4043
4044enum DurableManagedSpawnReplayDecision {
4045    Reserved,
4046    PendingLinked,
4047    Committed(ManagedWorktreeSpawnReceipt),
4048}
4049
4050impl SpawnIdempotencyValue {
4051    fn references_context(&self, context_id: &SpawnContextId) -> bool {
4052        match self {
4053            Self::Standard { result, .. } => result
4054                .as_ref()
4055                .ok()
4056                .and_then(|receipt| receipt.context.as_ref()),
4057            Self::Managed { result, .. } => result
4058                .as_ref()
4059                .ok()
4060                .and_then(|receipt| receipt.spawn.context.as_ref()),
4061            Self::HarnessMcp => None,
4062        }
4063        .is_some_and(|receipt| &receipt.id == context_id)
4064    }
4065}
4066
4067#[derive(Clone, Copy)]
4068struct ControllerLease {
4069    connection_id: u64,
4070    expires_at: Instant,
4071}
4072
4073struct NodeEventHistory {
4074    last_sequence: u64,
4075    replay_floor_sequence: u64,
4076    events: VecDeque<NodeEventEnvelope>,
4077    record_providers: BTreeMap<SessionRecordId, AgentId>,
4078    removed_record_providers: BTreeMap<u64, AgentId>,
4079}
4080
4081impl NodeEventHistory {
4082    fn new(record_providers: BTreeMap<SessionRecordId, AgentId>) -> Self {
4083        Self {
4084            last_sequence: 0,
4085            replay_floor_sequence: 1,
4086            events: VecDeque::with_capacity(NODE_EVENT_HISTORY_MAX),
4087            record_providers,
4088            removed_record_providers: BTreeMap::new(),
4089        }
4090    }
4091}
4092
4093impl NodeShared {
4094    /// A node-spawned grok PTY session gets its own leader socket, so its agent backend runs
4095    /// as a descendant of this session's process (with the session's own environment) and
4096    /// never attaches to an operator's shared leader. Same grok home, same login.
4097    fn session_grok_leader_socket(
4098        &self,
4099        provider: &AgentId,
4100        mode: SessionMode,
4101        instance_id: AgentInstanceId,
4102    ) -> Option<std::path::PathBuf> {
4103        if provider.as_str() != "grok" || mode != SessionMode::Pty || crate::grok_leader::disabled() {
4104            return None;
4105        }
4106        let root = self
4107            .m4a_accounts
4108            .as_ref()
4109            .and_then(|a| a.base().parent().map(std::path::Path::to_path_buf))
4110            .unwrap_or_else(std::env::temp_dir);
4111        let socket = crate::grok_leader::socket_path(&root, &format!("{}-{}", self.incarnation_id, instance_id.0));
4112        if let Some(previous) = self
4113            .grok_leaders
4114            .lock()
4115            .unwrap_or_else(|p| p.into_inner())
4116            .insert(instance_id, socket.clone())
4117        {
4118            crate::grok_leader::retire(&previous);
4119        }
4120        Some(socket)
4121    }
4122
4123    /// Stops this session's own grok leader, if it had one. Never touches any other leader.
4124    fn retire_session_grok_leader(&self, instance_id: AgentInstanceId) {
4125        let socket = self.grok_leaders.lock().unwrap_or_else(|p| p.into_inner()).remove(&instance_id);
4126        if let Some(socket) = socket {
4127            crate::grok_leader::retire(&socket);
4128        }
4129    }
4130
4131    fn new_with_incarnation(
4132        handle: Gate4AgentHandle,
4133        access_token: String,
4134        node_id: NodeId,
4135        incarnation_id: NodeIncarnationId,
4136        workspaces: Vec<WorkspaceConfig>,
4137        enabled_providers: Vec<AgentId>,
4138        provider_runtime_statuses: ProviderRuntimeStatuses,
4139        provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
4140        provider_contracts: Vec<ProviderContractSupport>,
4141        provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
4142        spawn_profiles: SpawnProfileRegistry,
4143        native_launch_profile_control: Option<NativeLaunchProfileControl>,
4144        native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
4145        session_environment_materializer: Option<SessionEnvironmentMaterializer>,
4146        state_path: Option<PathBuf>,
4147        records: Vec<ManagedSessionRecord>,
4148        managed_worktrees: Vec<ManagedWorktreeLeaseRecord>,
4149        managed_worktree_tombstones: Vec<ManagedWorktreeLeaseRecord>,
4150        materializations: Vec<MaterializationOwnershipRecord>,
4151        persistence_warning: Option<String>,
4152        input_settle_timeout_ms: u64,
4153    ) -> Self {
4154        let (event_tx, _) = broadcast::channel(NODE_BROADCAST_CAPACITY);
4155        let (terminal_event_tx, _) = broadcast::channel(NODE_TERMINAL_BROADCAST_CAPACITY);
4156        let (agent_stream_event_tx, _) = broadcast::channel(NODE_AGENT_STREAM_BROADCAST_CAPACITY);
4157        let (harness_mcp_proxy_event_tx, _) =
4158            broadcast::channel(NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY);
4159        let record_providers = records
4160            .iter()
4161            .map(|record| (record.record_id.clone(), record.provider.clone()))
4162            .collect();
4163        let mut worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode> = workspaces
4164            .iter()
4165            .map(|workspace| {
4166                (workspace.workspace_id.clone(), workspace.worktree_service_mode)
4167            })
4168            .collect();
4169        for lease in &managed_worktrees {
4170            worktree_service_modes.insert(lease.workspace_id.clone(), WorktreeServiceMode::Off);
4171        }
4172        let managed_worktree_profiles = workspaces.iter()
4173            .map(|workspace| (
4174                workspace.workspace_id.clone(),
4175                workspace.managed_worktree_profiles.clone(),
4176            ))
4177            .collect();
4178        let mut managed_worktrees = ManagedWorktreeRegistry::from_records(
4179            managed_worktrees,
4180            managed_worktree_tombstones,
4181        ).expect("durable managed worktree registry was validated while loading");
4182        managed_worktrees.clear_stale_session_holders(incarnation_id, unix_time_ms());
4183        managed_worktrees.reattach_record_holders(
4184            &managed_worktree_record_holders(&records),
4185            unix_time_ms(),
4186        );
4187        Self {
4188            handle,
4189            access_token,
4190            node_id,
4191            incarnation_id,
4192            started_at_unix_ms: std::time::SystemTime::now()
4193                .duration_since(std::time::UNIX_EPOCH)
4194                .unwrap_or_default()
4195                .as_millis()
4196                .min(u64::MAX as u128) as u64,
4197            workspaces: RwLock::new(
4198                workspaces
4199                    .into_iter()
4200                    .map(|workspace| (workspace.workspace_id, workspace.canonical_root))
4201                    .collect(),
4202            ),
4203            worktree_service_modes,
4204            managed_worktree_profiles,
4205            managed_worktrees: Mutex::new(managed_worktrees),
4206            enabled_providers,
4207            provider_runtime_statuses,
4208            provider_runtime_status_updates: RwLock::new(BTreeMap::new()),
4209            provider_runtime_monitor,
4210            provider_contracts,
4211            provider_adapter_contracts,
4212            spawn_profiles,
4213            environment_profiles: RwLock::new(BTreeMap::new()),
4214            environment_materialization_profiles: RwLock::new(BTreeMap::new()),
4215            network_allowlist_catalog: RwLock::new(crate::network_allowlist_catalog::NetworkAllowlistCatalog::new()),
4216            #[cfg(feature = "dig2-station-probe")]
4217            browser_station_leases: crate::dig2_station_lease::new_lease_table(),
4218            bundle_catalog: RwLock::new(BundleCatalog::default()),
4219            delivery_store: Mutex::new(None),
4220            context_pack_store: Mutex::new(None),
4221            context_catalog: RwLock::new(ContextPackCatalog::default()),
4222            native_launch_profile_control,
4223            harness_mcp_registry: None,
4224            native_session_catalog,
4225            session_environment_materializer,
4226            m4a_accounts: crate::m4a_accounts::M4aSessionAccounts::from_env(),
4227            m4a_live: Mutex::new(BTreeMap::new()),
4228            grok_leaders: Mutex::new(BTreeMap::new()),
4229            #[cfg(unix)]
4230            signer_proxy: std::sync::OnceLock::new(),
4231            materializations: Mutex::new(
4232                materializations
4233                    .into_iter()
4234                    .map(|record| (record.id().clone(), record))
4235                    .collect(),
4236            ),
4237            spawn_idempotency: Mutex::new(SpawnIdempotencyCache {
4238                entries: BTreeMap::new(),
4239            }),
4240            managed_spawn_replays: Mutex::new(BTreeMap::new()),
4241            controller: Mutex::new(None),
4242            provisional_spawns: Mutex::new(BTreeMap::new()),
4243            pending_acp_options: Mutex::new(BTreeMap::new()),
4244            history: Mutex::new(NodeEventHistory::new(record_providers)),
4245            event_tx,
4246            terminal_event_tx,
4247            agent_stream_event_tx,
4248            harness_mcp_proxy_event_tx,
4249            terminal_frame_watermarks: Mutex::new(BTreeMap::new()),
4250            pty_input_wake: tokio::sync::Notify::new(),
4251            pty_input_marks: Mutex::new(BTreeMap::new()),
4252            next_connection_id: AtomicU64::new(1),
4253            next_instance_id: AtomicU64::new(1),
4254            next_command_id: AtomicU64::new(1),
4255            shutdown: AtomicBool::new(false),
4256            shutdown_notify: Notify::new(),
4257            preauth_slots: Arc::new(Semaphore::new(MAX_PREAUTH_CONNECTIONS)),
4258            authenticated_slots: Arc::new(Semaphore::new(MAX_AUTHENTICATED_CONNECTIONS)),
4259            inspection_slots: Arc::new(Semaphore::new(WORKSPACE_INSPECTION_MAX_CONCURRENCY)),
4260            mutation_gate: AsyncMutex::new(()),
4261            session_bindings: Mutex::new(BTreeMap::new()),
4262            session_records: Mutex::new(SessionRecords {
4263                records: records
4264                    .into_iter()
4265                    .map(|record| (record.record_id.clone(), record))
4266                    .collect(),
4267            }),
4268            state_path,
4269            persistence_error: RwLock::new(
4270                persistence_warning
4271                    .map(|message| session_registry::sanitized_persistence_summary(&message)),
4272            ),
4273            state_transaction: Mutex::new(()),
4274            input_settle_timeout_ms,
4275            drive_loop_profile: Mutex::new(DriveLoopProfiler::default()),
4276            runtime_tick_profile: Mutex::new(TickProfileSnapshot::default()),
4277            shell_efficiency_profile: OnceLock::new(),
4278            native_session_gauge: AtomicUsize::new(0),
4279            connection_loop_iterations: AtomicU64::new(0),
4280            connection_events_sent: AtomicU64::new(0),
4281            rejected_commands_total: AtomicU64::new(0),
4282            rejected_command_reasons: Mutex::new(BTreeMap::new()),
4283            drive_loop_iterations_total: AtomicU64::new(0),
4284            drive_loop_iterations_idle: AtomicU64::new(0),
4285            session_record_retention: SessionRecordRetentionConfig::default(),
4286            retired_records_total: AtomicU64::new(0),
4287            #[cfg(feature = "fixture")]
4288            fixture_semantic_hook_policy: false,
4289            #[cfg(feature = "fixture")]
4290            fixture_spawn_managed_worktree_v2_failure_probe:
4291                SpawnManagedWorktreeV2FailureProbe::default(),
4292        }
4293    }
4294
4295    #[cfg(test)]
4296    fn new(
4297        handle: Gate4AgentHandle,
4298        access_token: String,
4299        node_id: NodeId,
4300        workspaces: Vec<WorkspaceConfig>,
4301        enabled_providers: Vec<AgentId>,
4302    ) -> Self {
4303        Self::new_with_incarnation(
4304            handle,
4305            access_token,
4306            node_id,
4307            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
4308            workspaces,
4309            enabled_providers,
4310            ProviderRuntimeStatuses::default(),
4311            None,
4312            Vec::new(),
4313            Vec::new(),
4314            SpawnProfileRegistry::default(),
4315            None,
4316            None,
4317            None,
4318            None,
4319            Vec::new(),
4320            Vec::new(),
4321            Vec::new(),
4322            Vec::new(),
4323            None,
4324            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
4325        )
4326    }
4327
4328    fn persistence_error(&self) -> Option<String> {
4329        self.persistence_error
4330            .read()
4331            .unwrap_or_else(|poisoned| poisoned.into_inner())
4332            .clone()
4333    }
4334
4335    async fn admit_provider_runtime(
4336        &self,
4337        provider: &AgentId,
4338        requirement: ProviderRuntimeRequirement,
4339    ) -> Result<ProviderRuntimePolicy, NodeFailure> {
4340        let admission = if let Some(monitor) = self.provider_runtime_monitor.clone() {
4341            let pipe_transport = monitor.supports_pipe_transport(provider);
4342            let acp_transport = monitor.supports_acp_transport(provider);
4343            let provider = provider.clone();
4344            let refresh = tokio::task::spawn_blocking(move || monitor.evaluate(&provider));
4345            let (status, admission) = timeout(
4346                Duration::from_millis(PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS),
4347                refresh,
4348            )
4349            .await
4350            .map_err(|_| failure(
4351                NodeFailureCode::BackendBusy,
4352                "provider runtime probe exceeded its bounded deadline",
4353            ))?
4354            .map_err(|_| failure(
4355                NodeFailureCode::BackendOperationFailed,
4356                "provider runtime probe task failed",
4357            ))?;
4358            if let Some(status) = status {
4359                self
4360                    .provider_runtime_status_updates
4361                    .write()
4362                    .unwrap_or_else(|poisoned| poisoned.into_inner())
4363                    .insert(status.provider().clone(), status);
4364            }
4365            admission.and_then(|policy| {
4366                require_policy(policy, pipe_transport, acp_transport, requirement).map(|()| policy)
4367            })
4368        } else {
4369            // No live monitor means no catalog to consult -- there is
4370            // nothing here to confirm this provider declares a Pipe or ACP
4371            // transport, so Inline/Acp are rejected the same conservative
4372            // way an undeclared provider always was, rather than admitted
4373            // on trust.
4374            crate::provider_runtime::admit_status(
4375                &self.provider_runtime_statuses,
4376                provider,
4377                false,
4378                false,
4379                requirement,
4380            )
4381        };
4382        let admission = admission.map(|policy| self.admit_declared_hook_semantics(provider, policy));
4383        admission.map_err(|error| match error {
4384            ProviderRuntimeAdmissionError::LauncherUnavailable => failure(
4385                NodeFailureCode::BackendOperationFailed,
4386                "provider launcher is unavailable",
4387            ),
4388            ProviderRuntimeAdmissionError::SemanticCapabilityUnverified => failure(
4389                NodeFailureCode::UnsupportedCapability,
4390                "provider semantic capability is not verified",
4391            ),
4392            ProviderRuntimeAdmissionError::ProbeBusy => failure(
4393                NodeFailureCode::BackendBusy,
4394                "provider runtime probe is already in progress",
4395            ),
4396        })
4397    }
4398
4399    /// Whether the catalog declares a Pipe transport for `provider` --
4400    /// the fact `require_policy` needs to admit or reject
4401    /// `ProviderRuntimeRequirement::Inline`. With no live monitor there is no
4402    /// catalog to consult, so this stays conservative and rejects, matching
4403    /// `admit_provider_runtime`'s own no-monitor branch.
4404    fn provider_supports_pipe_transport(&self, provider: &AgentId) -> bool {
4405        match self.provider_runtime_monitor.as_deref() {
4406            Some(monitor) => monitor.supports_pipe_transport(provider),
4407            None => false,
4408        }
4409    }
4410
4411    /// Whether the catalog declares an ACP transport for `provider` -- the
4412    /// fact `require_policy` needs to admit or reject
4413    /// `ProviderRuntimeRequirement::Acp`. Mirrors
4414    /// `provider_supports_pipe_transport` exactly, including the
4415    /// conservative no-monitor default.
4416    fn provider_supports_acp_transport(&self, provider: &AgentId) -> bool {
4417        match self.provider_runtime_monitor.as_deref() {
4418            Some(monitor) => monitor.supports_acp_transport(provider),
4419            None => false,
4420        }
4421    }
4422
4423    /// Grants hook ingestion to a provider whose catalog entry declares a
4424    /// Hook adapter, whatever runtime mode its launcher probe landed in.
4425    ///
4426    /// `admit_status`'s `RawPassthrough` arm hands back
4427    /// `ProviderRuntimePolicy::raw_pty()`, which grants nothing semantic --
4428    /// correct for everything it was written to answer, and wrong for hooks
4429    /// specifically. A hook event is not inferred from terminal behaviour,
4430    /// so it has nothing to do with whether a launcher probe verified this
4431    /// CLI version's PTY contract; the authority is the catalog declaring
4432    /// the adapter plus the ingress authenticating the route. Without this
4433    /// the node installs a provider's hooks, the CLI faithfully calls them,
4434    /// and every event is refused -- which is exactly the state grok was in.
4435    ///
4436    /// Applied to both admission paths so the monitor's own derivation and
4437    /// this coarse fallback cannot disagree about the same provider.
4438    fn admit_declared_hook_semantics(
4439        &self,
4440        provider: &AgentId,
4441        policy: ProviderRuntimePolicy,
4442    ) -> ProviderRuntimePolicy {
4443        if policy.hook_semantics || !policy.raw_pty_lifecycle {
4444            return policy;
4445        }
4446        let declares_hook = self.provider_adapter_contracts.iter().any(|contract| {
4447            contract.provider == *provider && contract.family == AdapterFamily::Hook
4448        });
4449        if !declares_hook {
4450            return policy;
4451        }
4452        ProviderRuntimePolicy::new(
4453            policy.raw_pty_lifecycle,
4454            policy.semantic_readiness,
4455            policy.structured_prompt,
4456            policy.provider_session_identity,
4457            policy.semantic_resume,
4458            true,
4459        )
4460        .expect("granting hook semantics over a raw-PTY policy is internally valid")
4461    }
4462
4463    /// Station network allowlist → provider launch overlay argv.
4464    ///
4465    /// - **Codex**: first-slice `-c sandbox_workspace_write.network_access` from
4466    ///   `provider_native` under Moderate (PTY or ACP).
4467    /// - **Claude**: catalog `permits` → ProviderHome sidecar settings JSON +
4468    ///   documented `--settings <path>` on **PTY** only (Linux/macOS/WSL2).
4469    ///   ACP refuses (no measured ACP settings-path surface). Native Windows
4470    ///   refuses (no vendor Bash sandbox). Membership-only (empty permits) =
4471    ///   no overlay.
4472    /// - **Kimi / Grok**: no first-party settings/argv network surface here —
4473    ///   empty overlay when no `provider_native`; invented native keys already
4474    ///   refuse at load / resolve.
4475    ///
4476    /// Empty when no allowlist / nothing to map. Never cookies / secrets.
4477    fn resolve_network_allowlist_overlay_args(
4478        &self,
4479        provider: &AgentId,
4480        mode: SessionMode,
4481        approval_level: ApprovalLevel,
4482        network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4483        provider_home: Option<&Path>,
4484    ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4485        let Some(allowlist_id) = network_allowlist else {
4486            return Ok(Vec::new());
4487        };
4488        let catalog = self
4489            .network_allowlist_catalog
4490            .read()
4491            .unwrap_or_else(|poisoned| poisoned.into_inner());
4492        let Some(entry) = catalog.get(allowlist_id) else {
4493            return Err(failure(
4494                NodeFailureCode::UnknownNetworkAllowlist,
4495                "spawn network allowlist is unavailable on this node",
4496            ));
4497        };
4498
4499        // Claude: settings-shaped overlay from permits (not provider_native).
4500        if provider.as_str() == "claude" && !entry.permits.is_empty() {
4501            crate::network_allowlist_catalog::claude_bash_sandbox_network_os_supported().map_err(
4502                |message| {
4503                    failure(
4504                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4505                        &message,
4506                    )
4507                },
4508            )?;
4509            if !matches!(mode, SessionMode::Pty) {
4510                return Err(failure(
4511                    NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4512                    "Claude sandbox.network --settings overlay requires PTY transport; ACP has no measured settings-path surface (refuse rather than invent RPC)",
4513                ));
4514            }
4515            let Some(home) = provider_home else {
4516                return Err(failure(
4517                    NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4518                    "Claude sandbox.network --settings overlay requires ProviderHome materialization (CLAUDE_CONFIG_DIR)",
4519                ));
4520            };
4521            let domains =
4522                crate::network_allowlist_catalog::claude_allowed_domains_from_permits(&entry.permits);
4523            if domains.is_empty() {
4524                return Err(failure(
4525                    NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4526                    "Claude sandbox.network overlay could not map catalog permits to allowedDomains",
4527                ));
4528            }
4529            let settings_path = home.join(
4530                crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
4531            );
4532            let json =
4533                crate::network_allowlist_catalog::claude_station_network_settings_json(&domains);
4534            crate::session_environment::secure_create_file(&settings_path, json.as_bytes()).map_err(
4535                |_| {
4536                    failure(
4537                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4538                        "Claude station network settings sidecar could not be written under ProviderHome",
4539                    )
4540                },
4541            )?;
4542            let overlay =
4543                crate::network_allowlist_catalog::claude_settings_network_overlay_args(&settings_path);
4544            return Ok(overlay.into_iter().map(std::ffi::OsString::from).collect());
4545        }
4546
4547        let Some(native) = entry.provider_native.as_ref() else {
4548            return Ok(Vec::new());
4549        };
4550        let overlay = crate::network_allowlist_catalog::resolve_provider_native_launch_overlay(
4551            provider.as_str(),
4552            approval_level,
4553            native,
4554        )
4555        .map_err(|message| {
4556            failure(
4557                NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4558                &message,
4559            )
4560        })?;
4561        if overlay.is_empty() {
4562            return Ok(Vec::new());
4563        }
4564        if !matches!(mode, SessionMode::Pty | SessionMode::Acp) {
4565            return Err(failure(
4566                NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4567                "Codex network_access -c overlay requires PTY or ACP transport",
4568            ));
4569        }
4570        Ok(overlay.into_iter().map(std::ffi::OsString::from).collect())
4571    }
4572
4573    /// Backward-compatible alias used by Codex-focused unit tests.
4574    fn resolve_codex_network_access_overlay_args(
4575        &self,
4576        provider: &AgentId,
4577        mode: SessionMode,
4578        approval_level: ApprovalLevel,
4579        network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4580    ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4581        self.resolve_network_allowlist_overlay_args(
4582            provider,
4583            mode,
4584            approval_level,
4585            network_allowlist,
4586            None,
4587        )
4588    }
4589
4590    fn resolve_environment_profile(
4591        &self,
4592        resolved: &ResolvedSpawnSpec,
4593    ) -> Result<Option<ResolvedEnvironmentProfileReceipt>, NodeFailure> {
4594        // Station network allowlist catalog enforce (plan
4595        // station-network-and-browser-profile-knobs §2.1 / §4): empty-default
4596        // catalog — unknown ids refuse rather than silent ambient. Runs even
4597        // when no environment_profile_id is present so a bare allowlist still
4598        // cannot bypass the catalog. Never cookies / OAuth / proxy credentials.
4599        if let Some(allowlist_id) = resolved.network_allowlist.as_ref() {
4600            let catalog = self
4601                .network_allowlist_catalog
4602                .read()
4603                .unwrap_or_else(|poisoned| poisoned.into_inner());
4604            let Some(entry) = catalog.get(allowlist_id) else {
4605                return Err(failure(
4606                    NodeFailureCode::UnknownNetworkAllowlist,
4607                    "spawn network allowlist is unavailable on this node",
4608                ));
4609            };
4610            // Track B: refuse provider-native mappings this provider cannot
4611            // honor (e.g. codex_network_access on Claude). Never silent ambient.
4612            if let Some(native) = entry.provider_native.as_ref() {
4613                if let Err(message) = crate::network_allowlist_catalog::provider_native_mapping_supported(
4614                    resolved.provider.as_str(),
4615                    resolved.approval_level,
4616                    native,
4617                ) {
4618                    return Err(failure(
4619                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4620                        &message,
4621                    ));
4622                }
4623            }
4624        }
4625        // Station knobs Track A (dig2browser-station-probe-and-network-permit-set):
4626        // when feature `dig2-station-probe` is on and browser_profile_id is set,
4627        // cheap named-pipe path reachability must succeed (Windows
4628        // `\\.\pipe\{suffix}`); platform-unsupported or unreachable → clear
4629        // NodeFailureCode. Runs even without environment_profile_id (same
4630        // placement spirit as allowlist catalog). Feature off keeps stub echo.
4631        // Never cookies / OAuth / proxy credentials. Exclusive lease is
4632        // acquired later in spawn_session_with_deadline (after instance id).
4633        #[cfg(feature = "dig2-station-probe")]
4634        if resolved.browser_profile_id.is_some() {
4635            use crate::dig2_station_probe::{probe_station_reachable, StationProbeError};
4636            match probe_station_reachable() {
4637                Ok(()) => {}
4638                Err(StationProbeError::UnsupportedPlatform)
4639                | Err(StationProbeError::InvalidPipeSuffix) => {
4640                    return Err(failure(
4641                        NodeFailureCode::BrowserStationProbeUnavailable,
4642                        "dig2browser station probe unavailable on this platform",
4643                    ));
4644                }
4645                Err(StationProbeError::Unreachable) => {
4646                    return Err(failure(
4647                        NodeFailureCode::BrowserStationUnreachable,
4648                        "dig2browser station named-pipe is unreachable",
4649                    ));
4650                }
4651            }
4652        }
4653        let Some(profile_id) = resolved.environment_profile_id.as_ref() else {
4654            return Ok(None);
4655        };
4656        let profiles = self
4657            .environment_profiles
4658            .read()
4659            .unwrap_or_else(|poisoned| poisoned.into_inner());
4660        let binding = profiles.get(profile_id).ok_or_else(|| {
4661            failure(
4662                NodeFailureCode::UnknownEnvironmentProfile,
4663                "spawn environment profile is unavailable on this node",
4664            )
4665        })?;
4666        if binding.provider != resolved.provider
4667            || binding.native_profile_id(resolved.mode).is_none()
4668        {
4669            return Err(failure(
4670                NodeFailureCode::EnvironmentProfileBindingMismatch,
4671                "spawn environment profile does not match the provider and mode",
4672            ));
4673        }
4674        // Echo opaque network_allowlist / browser_profile_id onto the receipt
4675        // after catalog + optional dig2 probe above. Empty/whitespace ids
4676        // already refuse at Spawn*Id construction.
4677        Ok(Some(ResolvedEnvironmentProfileReceipt {
4678            profile_id: binding.id.clone(),
4679            profile_revision: binding.revision.clone(),
4680            network_allowlist: resolved.network_allowlist.clone(),
4681            browser_profile_id: resolved.browser_profile_id.clone(),
4682        }))
4683    }
4684
4685    fn resolve_bundle(
4686        &self,
4687        resolved: &ResolvedSpawnSpec,
4688        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4689    ) -> Result<Option<ResolvedBundleReceipt>, NodeFailure> {
4690        let Some(bundle_id) = resolved.bundle_id.as_ref() else {
4691            return Ok(None);
4692        };
4693        let catalog = self.bundle_catalog
4694            .read()
4695            .unwrap_or_else(|poisoned| poisoned.into_inner());
4696        let bundle = catalog.get(bundle_id).ok_or_else(|| {
4697                failure(
4698                    NodeFailureCode::UnknownBundle,
4699                    "spawn bundle is unavailable on this node",
4700                )
4701            })?;
4702        self.bundle_layout(
4703            &resolved.provider,
4704            resolved.mode,
4705            environment_profile,
4706            bundle,
4707        )?;
4708        Ok(Some(bundle.receipt()))
4709    }
4710
4711    fn begin_delivery_stage(
4712        &self,
4713        manifest: DeliveryBundleManifestV2,
4714    ) -> Result<NodeResponse, NodeFailure> {
4715        let mut store = self
4716            .delivery_store
4717            .lock()
4718            .unwrap_or_else(|poisoned| poisoned.into_inner());
4719        let store = store.as_mut().ok_or_else(|| {
4720            failure(
4721                NodeFailureCode::DeliveryStageStorageFailed,
4722                "node delivery store is unavailable",
4723            )
4724        })?;
4725        let begun = store.begin(manifest).map_err(delivery_failure)?;
4726        Ok(NodeResponse::DeliveryStageBegun {
4727            stage_id: begun.stage_id,
4728            manifest_digest: begun.manifest_digest,
4729            missing_blobs: begun.missing_blobs,
4730        })
4731    }
4732
4733    fn put_delivery_blob_chunk(
4734        &self,
4735        stage_id: DeliveryStageId,
4736        blob_digest: DeliveryBlobDigestV1,
4737        offset: u64,
4738        chunk_hex: DeliveryBlobChunkHexV1,
4739    ) -> Result<NodeResponse, NodeFailure> {
4740        let mut store = self
4741            .delivery_store
4742            .lock()
4743            .unwrap_or_else(|poisoned| poisoned.into_inner());
4744        let store = store.as_mut().ok_or_else(|| {
4745            failure(
4746                NodeFailureCode::DeliveryStageStorageFailed,
4747                "node delivery store is unavailable",
4748            )
4749        })?;
4750        let accepted = store
4751            .put_chunk(&stage_id, &blob_digest, offset, &chunk_hex.decode())
4752            .map_err(delivery_failure)?;
4753        Ok(NodeResponse::DeliveryBlobChunkAccepted {
4754            stage_id: accepted.stage_id,
4755            blob_digest: accepted.blob_digest,
4756            next_offset: accepted.next_offset,
4757        })
4758    }
4759
4760    fn commit_delivery_stage(
4761        &self,
4762        stage_id: DeliveryStageId,
4763    ) -> Result<DeliveryCommitReceiptV1, NodeFailure> {
4764        let mut store = self
4765            .delivery_store
4766            .lock()
4767            .unwrap_or_else(|poisoned| poisoned.into_inner());
4768        let store = store.as_mut().ok_or_else(|| {
4769            failure(
4770                NodeFailureCode::DeliveryStageStorageFailed,
4771                "node delivery store is unavailable",
4772            )
4773        })?;
4774        let prepared = store.prepare_commit(&stage_id).map_err(delivery_failure)?;
4775        let mut catalog = self
4776            .bundle_catalog
4777            .write()
4778            .unwrap_or_else(|poisoned| poisoned.into_inner());
4779        let mut next_catalog = catalog.clone();
4780        next_catalog
4781            .insert_idempotent(prepared.bundle().clone())
4782            .map_err(|_| {
4783                failure(
4784                    NodeFailureCode::DeliveryStageConflict,
4785                    "delivery bundle identity conflicts with installed content",
4786                )
4787            })?;
4788        let receipt = store.publish_commit(prepared).map_err(delivery_failure)?;
4789        *catalog = next_catalog;
4790        Ok(receipt)
4791    }
4792
4793    fn abort_delivery_stage(
4794        &self,
4795        stage_id: &DeliveryStageId,
4796    ) -> Result<(), NodeFailure> {
4797        let mut store = self
4798            .delivery_store
4799            .lock()
4800            .unwrap_or_else(|poisoned| poisoned.into_inner());
4801        let store = store.as_mut().ok_or_else(|| {
4802            failure(
4803                NodeFailureCode::DeliveryStageStorageFailed,
4804                "node delivery store is unavailable",
4805            )
4806        })?;
4807        store.abort(stage_id).map_err(delivery_failure)
4808    }
4809
4810    fn resolve_context(
4811        &self,
4812        resolved: &ResolvedSpawnSpec,
4813    ) -> Result<Option<ResolvedContextPackReceipt>, NodeFailure> {
4814        let Some(context_id) = resolved.context_id.as_ref() else {
4815            return Ok(None);
4816        };
4817        self.context_catalog
4818            .read()
4819            .unwrap_or_else(|poisoned| poisoned.into_inner())
4820            .get(context_id)
4821            .map(|context| context.receipt().clone())
4822            .ok_or_else(|| {
4823                failure(
4824                    NodeFailureCode::UnknownContextPack,
4825                    "spawn context pack is unavailable on this node",
4826                )
4827            })
4828            .map(Some)
4829    }
4830
4831    fn bundle_layout(
4832        &self,
4833        provider: &AgentId,
4834        mode: SessionMode,
4835        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4836        bundle: &NodeBundle,
4837    ) -> Result<BundleProviderLayout, NodeFailure> {
4838        let layout = validate_bundle_binding(provider, mode, bundle).map_err(|_| {
4839            failure(
4840                NodeFailureCode::BundleBindingMismatch,
4841                "spawn bundle does not match the provider and mode",
4842            )
4843        })?;
4844        if layout != BundleProviderLayout::Codex {
4845            return Ok(layout);
4846        }
4847        let profile = environment_profile.and_then(|receipt| {
4848            let profiles = self
4849                .environment_profiles
4850                .read()
4851                .unwrap_or_else(|poisoned| poisoned.into_inner());
4852            let binding = profiles.get(&receipt.profile_id)?;
4853            if binding.revision != receipt.profile_revision
4854                || &binding.provider != provider
4855                || binding.native_profile_id(mode).is_none()
4856            {
4857                return None;
4858            }
4859            self.environment_materialization_profiles
4860                .read()
4861                .unwrap_or_else(|poisoned| poisoned.into_inner())
4862                .get(&receipt.profile_id)
4863                .cloned()
4864        });
4865        if !profile.is_some_and(|profile| profile.supports_bundle_layout(layout)) {
4866            return Err(failure(
4867                NodeFailureCode::BundleBindingMismatch,
4868                "Codex bundle requires an exact isolated CODEX_HOME profile",
4869            ));
4870        }
4871        Ok(layout)
4872    }
4873
4874    fn select_environment_profile(
4875        &self,
4876        instance_id: AgentInstanceId,
4877        provider: &AgentId,
4878        mode: SessionMode,
4879        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4880    ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
4881        let Some(environment_profile) = environment_profile else {
4882            return Ok(None);
4883        };
4884        let native_profile_id = {
4885            let profiles = self
4886                .environment_profiles
4887                .read()
4888                .unwrap_or_else(|poisoned| poisoned.into_inner());
4889            let binding = profiles
4890                .get(&environment_profile.profile_id)
4891                .ok_or_else(|| {
4892                    failure(
4893                        NodeFailureCode::UnknownEnvironmentProfile,
4894                        "session environment profile is unavailable on this node",
4895                    )
4896                })?;
4897            if &binding.revision != &environment_profile.profile_revision
4898                || &binding.provider != provider
4899            {
4900                return Err(failure(
4901                    NodeFailureCode::EnvironmentProfileBindingMismatch,
4902                    "session environment profile revision or provider changed",
4903                ));
4904            }
4905            binding.native_profile_id(mode).cloned().ok_or_else(|| {
4906                failure(
4907                    NodeFailureCode::EnvironmentProfileBindingMismatch,
4908                    "session environment profile does not support this mode",
4909                )
4910            })?
4911        };
4912        let control = self.native_launch_profile_control.clone().ok_or_else(|| {
4913            failure(
4914                NodeFailureCode::BackendOperationFailed,
4915                "native environment profile controller is unavailable",
4916            )
4917        })?;
4918        control
4919            .select_native_launch_profile(instance_id, native_profile_id)
4920            .map_err(|_| {
4921                failure(
4922                    NodeFailureCode::BackendOperationFailed,
4923                    "native environment profile selection failed",
4924                )
4925            })?;
4926        Ok(Some(NativeEnvironmentSelectionGuard {
4927            control: Some(control),
4928            instance_id,
4929        }))
4930    }
4931
4932    fn install_prepared_launch_overlay(
4933        &self,
4934        instance_id: AgentInstanceId,
4935        overlay: PreparedNativeLaunchOverlay,
4936    ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
4937        let control = self.native_launch_profile_control.clone().ok_or_else(|| {
4938            failure(
4939                NodeFailureCode::BackendOperationFailed,
4940                "native launch profile controller is unavailable",
4941            )
4942        })?;
4943        match overlay {
4944            PreparedNativeLaunchOverlay::Environment(overlay) => {
4945                control
4946                    .install_native_launch_environment_overlay(instance_id, overlay)
4947                    .map_err(|_| failure(
4948                        NodeFailureCode::BackendOperationFailed,
4949                        "session environment overlay installation failed",
4950                    ))?;
4951                Ok(None)
4952            }
4953            PreparedNativeLaunchOverlay::Instance(overlay) => {
4954                control
4955                    .install_native_instance_launch_overlay(instance_id, overlay)
4956                    .map_err(|_| failure(
4957                        NodeFailureCode::BundleBindingMismatch,
4958                        "session bundle launch overlay installation failed",
4959                    ))?;
4960                Ok(Some(NativeInstanceOverlayGuard {
4961                    control: Some(control),
4962                    instance_id,
4963                }))
4964            }
4965        }
4966    }
4967
4968    fn environment_materialization_profile(
4969        &self,
4970        environment_profile: &ResolvedEnvironmentProfileReceipt,
4971    ) -> Option<NodeSessionMaterializationProfile> {
4972        self.environment_materialization_profiles
4973            .read()
4974            .unwrap_or_else(|poisoned| poisoned.into_inner())
4975            .get(&environment_profile.profile_id)
4976            .cloned()
4977    }
4978
4979    fn materialization_profile(
4980        &self,
4981        provider: &AgentId,
4982        mode: SessionMode,
4983        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4984        bundle: Option<&ResolvedBundleReceipt>,
4985        context: Option<&ResolvedContextPackReceipt>,
4986    ) -> Result<Option<(
4987        NodeSessionMaterializationProfile,
4988        bool,
4989        Option<BundleProviderLayout>,
4990    )>, NodeFailure> {
4991        let environment = environment_profile
4992            .and_then(|receipt| self.environment_materialization_profile(receipt));
4993        let (bundle, bundle_layout) = match bundle {
4994            Some(receipt) => {
4995                let catalog = self.bundle_catalog.read()
4996                    .unwrap_or_else(|poisoned| poisoned.into_inner());
4997                let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
4998                    NodeFailureCode::UnknownBundle,
4999                    "session bundle is unavailable on this node",
5000                ))?;
5001                if installed.receipt() != *receipt {
5002                    return Err(failure(
5003                        NodeFailureCode::BundleBindingMismatch,
5004                        "session bundle revision or digest changed",
5005                    ));
5006                }
5007                let layout = self.bundle_layout(
5008                    provider,
5009                    mode,
5010                    environment_profile,
5011                    installed,
5012                )?;
5013                (Some(installed.clone()), Some(layout))
5014            }
5015            None => (None, None),
5016        };
5017        let has_environment_overlay = environment.is_some() || context.is_some();
5018        let profile = match (environment, bundle.as_ref(), bundle_layout) {
5019            (None, None, None) => None,
5020            (Some(profile), None, None) => Some(Ok(profile)),
5021            (None, Some(bundle), Some(layout)) => {
5022                Some(NodeSessionMaterializationProfile::from_bundle(bundle, layout))
5023            }
5024            (Some(profile), Some(bundle), Some(layout)) => {
5025                Some(profile.with_bundle(bundle, layout))
5026            }
5027            _ => {
5028                return Err(failure(
5029                    NodeFailureCode::BundleBindingMismatch,
5030                    "session bundle provider layout is unavailable",
5031                ));
5032            }
5033        };
5034        let profile = profile
5035            .transpose()
5036            .map_err(|_| failure(
5037                NodeFailureCode::BundleMaterializationFailed,
5038                "session bundle materialization profile is invalid",
5039            ))?;
5040        let profile = match (profile, context) {
5041            (Some(profile), Some(receipt)) => {
5042                let catalog = self.context_catalog
5043                    .read()
5044                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5045                let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
5046                    NodeFailureCode::UnknownContextPack,
5047                    "session context pack is unavailable on this node",
5048                ))?;
5049                if pack.receipt() != receipt {
5050                    return Err(failure(
5051                        NodeFailureCode::ContextPackMaterializationFailed,
5052                        "session context pack identity changed",
5053                    ));
5054                }
5055                profile.with_context(pack)
5056            }
5057            (None, Some(receipt)) => {
5058                let catalog = self.context_catalog
5059                    .read()
5060                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5061                let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
5062                    NodeFailureCode::UnknownContextPack,
5063                    "session context pack is unavailable on this node",
5064                ))?;
5065                if pack.receipt() != receipt {
5066                    return Err(failure(
5067                        NodeFailureCode::ContextPackMaterializationFailed,
5068                        "session context pack identity changed",
5069                    ));
5070                }
5071                NodeSessionMaterializationProfile::from_context(pack)
5072            }
5073            (Some(profile), None) => Ok(profile),
5074            (None, None) => return Ok(None),
5075        }
5076        .map_err(|_| failure(
5077            NodeFailureCode::ContextPackMaterializationFailed,
5078            "session context pack materialization profile is invalid",
5079        ))?;
5080        Ok(Some((profile, has_environment_overlay, bundle_layout)))
5081    }
5082
5083    fn prepare_session_materialization<'a>(
5084        &'a self,
5085        address: &SessionAddress,
5086        provider: &AgentId,
5087        mode: SessionMode,
5088        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5089        bundle: Option<&ResolvedBundleReceipt>,
5090        context: Option<&ResolvedContextPackReceipt>,
5091        managed_lease_id: Option<ManagedWorktreeLeaseId>,
5092    ) -> Result<Option<(Option<PreparedNativeLaunchOverlay>, SessionMaterializationGuard<'a>)>, NodeFailure> {
5093        let Some((profile, has_environment_overlay, bundle_layout)) =
5094            self.materialization_profile(provider, mode, environment_profile, bundle, context)?
5095        else {
5096            return Ok(None);
5097        };
5098        let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5099            failure(
5100                NodeFailureCode::BackendOperationFailed,
5101                "session environment materializer is unavailable",
5102            )
5103        })?;
5104        let materialization_failure_code = if context.is_some() {
5105            NodeFailureCode::ContextPackMaterializationFailed
5106        } else if bundle.is_some() {
5107            NodeFailureCode::BundleMaterializationFailed
5108        } else {
5109            NodeFailureCode::BackendOperationFailed
5110        };
5111        let id = MaterializationId::new(format!(
5112            "mat-{}-{}-{}",
5113            self.incarnation_id,
5114            address.session.instance_id.0,
5115            address.session.generation.0,
5116        ))
5117        .map_err(|_| {
5118            failure(
5119                NodeFailureCode::BackendOperationFailed,
5120                "session environment materialization identity failed",
5121            )
5122        })?;
5123        let owner = MaterializationOwner::Session {
5124            incarnation_id: self.incarnation_id,
5125            instance_id: address.session.instance_id,
5126            generation: address.session.generation,
5127        };
5128        let mut ownership = materializer
5129            .begin(
5130                id.clone(),
5131                environment_profile.cloned(),
5132                bundle.cloned(),
5133                context.cloned(),
5134                owner,
5135                managed_lease_id,
5136                &profile,
5137                unix_time_ms(),
5138            )
5139            .map_err(|_| {
5140                failure(
5141                    materialization_failure_code,
5142                    "session materialization preparation failed",
5143                )
5144            })?;
5145        {
5146            let _transaction = self
5147                .state_transaction
5148                .lock()
5149                .unwrap_or_else(|poisoned| poisoned.into_inner());
5150            let duplicate = self
5151                .materializations
5152                .lock()
5153                .unwrap_or_else(|poisoned| poisoned.into_inner())
5154                .insert(id.clone(), ownership.clone());
5155            if duplicate.is_some() {
5156                return Err(failure(
5157                    NodeFailureCode::BackendOperationFailed,
5158                    "session environment materialization identity conflict",
5159                ));
5160            }
5161            if let Err(error) = self.persist_state_locked() {
5162                self.materializations
5163                    .lock()
5164                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5165                    .remove(&id);
5166                return Err(persistence_failure(error));
5167            }
5168        }
5169        let environment = match materializer.materialize(&mut ownership, &profile, unix_time_ms()) {
5170            Ok(environment) => environment,
5171            Err(_) => {
5172                let transaction = self
5173                    .state_transaction
5174                    .lock()
5175                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5176                self.materializations
5177                    .lock()
5178                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5179                    .insert(id.clone(), ownership.clone());
5180                if self.persist_state_locked().is_err() {
5181                    self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5182                }
5183                drop(transaction);
5184                if ownership.state() == MaterializationState::CleanupRequired {
5185                    let _ = self.cleanup_materialization(&id);
5186                }
5187                return Err(failure(
5188                    materialization_failure_code,
5189                    "session materialization failed",
5190                ));
5191            }
5192        };
5193        {
5194            let _transaction = self
5195                .state_transaction
5196                .lock()
5197                .unwrap_or_else(|poisoned| poisoned.into_inner());
5198            self.materializations
5199                .lock()
5200                .unwrap_or_else(|poisoned| poisoned.into_inner())
5201                .insert(id.clone(), ownership.clone());
5202            if let Err(error) = self.persist_state_locked() {
5203                drop(_transaction);
5204                let _ = self.cleanup_materialization(&id);
5205                return Err(persistence_failure(error));
5206            }
5207        }
5208        let guard = SessionMaterializationGuard {
5209            shared: self,
5210            id: Some(id),
5211            provider_home: Some(ownership.provider_home().to_path_buf()),
5212        };
5213        let bundle_arguments = match bundle {
5214            Some(receipt) => {
5215                let catalog = self.bundle_catalog.read()
5216                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5217                let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
5218                    NodeFailureCode::UnknownBundle,
5219                    "session bundle is unavailable on this node",
5220                ))?;
5221                if installed.receipt() != *receipt {
5222                    return Err(failure(
5223                        NodeFailureCode::BundleBindingMismatch,
5224                        "session bundle revision or digest changed",
5225                    ));
5226                }
5227                let layout = bundle_layout.ok_or_else(|| failure(
5228                    NodeFailureCode::BundleBindingMismatch,
5229                    "materialized bundle provider layout is unavailable",
5230                ))?;
5231                materializer.revalidate_bundle(&ownership, installed, layout).map_err(|_| {
5232                    failure(
5233                        NodeFailureCode::BundleMaterializationFailed,
5234                        "session bundle byte revalidation failed",
5235                    )
5236                })?;
5237                Some(bundle_launch_arguments(
5238                    layout,
5239                    ownership.bundle_root(),
5240                    ownership.provider_home(),
5241                ).map_err(|_| failure(
5242                    NodeFailureCode::BundleBindingMismatch,
5243                    "session bundle launch binding is invalid",
5244                ))?)
5245            }
5246            None => None,
5247        };
5248        if let Some(receipt) = context {
5249            materializer
5250                .revalidate_context(&ownership, receipt)
5251                .map_err(|_| failure(
5252                    NodeFailureCode::ContextPackMaterializationFailed,
5253                    "session context pack byte revalidation failed",
5254                ))?;
5255        }
5256        let transport = match mode {
5257            SessionMode::Pty => TransportKind::Pty,
5258            SessionMode::Inline => TransportKind::Pipe,
5259            SessionMode::Acp => TransportKind::Acp,
5260        };
5261        let overlay = if let Some(extra_args) = bundle_arguments {
5262            Some(PreparedNativeLaunchOverlay::Instance(
5263                NativeInstanceLaunchOverlay::new(
5264                    provider.clone(),
5265                    transport,
5266                    if has_environment_overlay {
5267                        environment
5268                    } else {
5269                        Vec::new()
5270                    },
5271                    extra_args,
5272                )
5273                .map_err(|_| {
5274                    failure(
5275                        NodeFailureCode::BundleBindingMismatch,
5276                        "session bundle launch overlay is invalid",
5277                    )
5278                })?,
5279            ))
5280        } else if has_environment_overlay {
5281            let context_only = environment_profile.is_none()
5282                && bundle.is_none()
5283                && context.is_some();
5284            let overlay = if context_only {
5285                NativeLaunchEnvironmentOverlay::new_context_root(
5286                    provider.clone(),
5287                    transport,
5288                    environment,
5289                )
5290                .map_err(|_| {
5291                    failure(
5292                        NodeFailureCode::ContextPackMaterializationFailed,
5293                        "context-only launch overlay is invalid",
5294                    )
5295                })?
5296            } else {
5297                NativeLaunchEnvironmentOverlay::new(
5298                    provider.clone(),
5299                    transport,
5300                    environment,
5301                )
5302                .map_err(|_| {
5303                    failure(
5304                        NodeFailureCode::BackendOperationFailed,
5305                        "session environment overlay is invalid",
5306                    )
5307                })?
5308            };
5309            Some(PreparedNativeLaunchOverlay::Environment(overlay))
5310        } else {
5311            None
5312        };
5313        Ok(Some((overlay, guard)))
5314    }
5315
5316    fn cleanup_materialization(&self, id: &MaterializationId) -> Result<(), NodeFailure> {
5317        let Some(mut ownership) = self
5318            .materializations
5319            .lock()
5320            .unwrap_or_else(|poisoned| poisoned.into_inner())
5321            .get(id)
5322            .cloned()
5323        else {
5324            return Ok(());
5325        };
5326        if ownership.state() != MaterializationState::CleanupRequired {
5327            ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5328                failure(
5329                    NodeFailureCode::BackendOperationFailed,
5330                    "session environment cleanup state is invalid",
5331                )
5332            })?;
5333        }
5334        {
5335            let _transaction = self
5336                .state_transaction
5337                .lock()
5338                .unwrap_or_else(|poisoned| poisoned.into_inner());
5339            let previous = self
5340                .materializations
5341                .lock()
5342                .unwrap_or_else(|poisoned| poisoned.into_inner())
5343                .insert(id.clone(), ownership.clone());
5344            if let Err(error) = self.persist_state_locked() {
5345                let mut materializations = self
5346                    .materializations
5347                    .lock()
5348                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5349                if let Some(previous) = previous {
5350                    materializations.insert(id.clone(), previous);
5351                }
5352                return Err(persistence_failure(error));
5353            }
5354        }
5355        let cleanup = self
5356            .session_environment_materializer
5357            .as_ref()
5358            .ok_or(())
5359            .and_then(|materializer| materializer.cleanup(&ownership).map_err(|_| ()));
5360        if cleanup.is_err() {
5361            let _ = ownership.mark_recovery_required(unix_time_ms());
5362            self.materializations
5363                .lock()
5364                .unwrap_or_else(|poisoned| poisoned.into_inner())
5365                .insert(id.clone(), ownership);
5366            if self.persist_state().is_err() {
5367                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5368            }
5369            return Err(failure(
5370                NodeFailureCode::BackendOperationFailed,
5371                "session environment cleanup requires recovery",
5372            ));
5373        }
5374        let _transaction = self
5375            .state_transaction
5376            .lock()
5377            .unwrap_or_else(|poisoned| poisoned.into_inner());
5378        self.materializations
5379            .lock()
5380            .unwrap_or_else(|poisoned| poisoned.into_inner())
5381            .remove(id);
5382        if let Err(error) = self.persist_state_locked() {
5383            self.materializations
5384                .lock()
5385                .unwrap_or_else(|poisoned| poisoned.into_inner())
5386                .insert(id.clone(), ownership);
5387            return Err(persistence_failure(error));
5388        }
5389        Ok(())
5390    }
5391
5392    fn resolve_record_materialization(
5393        &self,
5394        record_id: &SessionRecordId,
5395        provider: &AgentId,
5396        mode: SessionMode,
5397        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5398        bundle: Option<&ResolvedBundleReceipt>,
5399        context: Option<&ResolvedContextPackReceipt>,
5400    ) -> Result<Option<(MaterializationId, Option<PreparedNativeLaunchOverlay>)>, NodeFailure> {
5401        let ownership = {
5402            let materializations = self
5403                .materializations
5404                .lock()
5405                .unwrap_or_else(|poisoned| poisoned.into_inner());
5406            let mut matches = materializations.values().filter(|record| {
5407                record.owner()
5408                    == &MaterializationOwner::Record {
5409                        record_id: record_id.clone(),
5410                    }
5411            });
5412            let ownership = matches.next().cloned();
5413            if matches.next().is_some() {
5414                return Err(failure(
5415                    NodeFailureCode::BackendOperationFailed,
5416                    "managed session has conflicting environment ownership",
5417                ));
5418            }
5419            ownership
5420        };
5421        if let (Some(ownership), Some(receipt)) = (ownership.as_ref(), context) {
5422            let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5423                failure(
5424                    NodeFailureCode::ContextPackMaterializationFailed,
5425                    "session context materializer is unavailable",
5426                )
5427            })?;
5428            let pack = materializer
5429                .revalidate_context(ownership, receipt)
5430                .map_err(|_| {
5431                    self.mark_materialization_recovery_required(ownership.id());
5432                    failure(
5433                        NodeFailureCode::ContextPackMaterializationFailed,
5434                        "managed session context pack revalidation failed",
5435                    )
5436                })?;
5437            self.context_catalog
5438                .write()
5439                .unwrap_or_else(|poisoned| poisoned.into_inner())
5440                .insert(pack)
5441                .map_err(|_| failure(
5442                    NodeFailureCode::ContextPackMaterializationFailed,
5443                    "managed session context pack catalog restore failed",
5444                ))?;
5445        }
5446        let materialization = self.materialization_profile(
5447            provider,
5448            mode,
5449            environment_profile,
5450            bundle,
5451            context,
5452        )?;
5453        match (ownership, materialization) {
5454            (None, None) => Ok(None),
5455            (None, Some(_)) | (Some(_), None) => Err(failure(
5456                NodeFailureCode::BackendOperationFailed,
5457                "managed session materialization is unavailable",
5458            )),
5459            (Some(ownership), Some((profile, has_environment_materialization, bundle_layout))) => {
5460                if ownership.environment_profile() != environment_profile
5461                    || ownership.bundle() != bundle
5462                    || ownership.context() != context
5463                    || ownership.state() != MaterializationState::Ready
5464                {
5465                    self.mark_materialization_recovery_required(ownership.id());
5466                    return Err(failure(
5467                        NodeFailureCode::BackendOperationFailed,
5468                        "managed session environment ownership requires recovery",
5469                    ));
5470                }
5471                let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5472                    failure(
5473                        NodeFailureCode::BackendOperationFailed,
5474                        "session environment materializer is unavailable",
5475                    )
5476                })?;
5477                if materializer.revalidate(&ownership).is_err() {
5478                    self.mark_materialization_recovery_required(ownership.id());
5479                    return Err(failure(
5480                        NodeFailureCode::BackendOperationFailed,
5481                        "managed session environment revalidation failed",
5482                    ));
5483                }
5484                let bundle_arguments = if let Some(bundle_receipt) = bundle {
5485                    let catalog = self.bundle_catalog.read()
5486                        .unwrap_or_else(|poisoned| poisoned.into_inner());
5487                    let Some(installed) = catalog.get(&bundle_receipt.id) else {
5488                        return Err(failure(
5489                            NodeFailureCode::UnknownBundle,
5490                            "managed session bundle is unavailable",
5491                        ));
5492                    };
5493                    let layout = bundle_layout.ok_or_else(|| failure(
5494                        NodeFailureCode::BundleBindingMismatch,
5495                        "record bundle provider layout is unavailable",
5496                    ))?;
5497                    if materializer.revalidate_bundle(&ownership, installed, layout).is_err() {
5498                        drop(catalog);
5499                        self.mark_materialization_recovery_required(ownership.id());
5500                        return Err(failure(
5501                            NodeFailureCode::BundleMaterializationFailed,
5502                            "managed session bundle revalidation failed",
5503                        ));
5504                    }
5505                    Some(bundle_launch_arguments(
5506                        layout,
5507                        ownership.bundle_root(),
5508                        ownership.provider_home(),
5509                    ).map_err(|_| failure(
5510                        NodeFailureCode::BundleBindingMismatch,
5511                        "managed session bundle launch binding is invalid",
5512                    ))?)
5513                } else {
5514                    None
5515                };
5516                if !has_environment_materialization {
5517                    let overlay = bundle_arguments.map(|extra_args| {
5518                        NativeInstanceLaunchOverlay::new(
5519                            provider.clone(),
5520                            TransportKind::Pty,
5521                            Vec::new(),
5522                            extra_args,
5523                        )
5524                        .map(PreparedNativeLaunchOverlay::Instance)
5525                        .map_err(|_| failure(
5526                            NodeFailureCode::BundleBindingMismatch,
5527                            "managed session bundle launch overlay is invalid",
5528                        ))
5529                    }).transpose()?;
5530                    return Ok(Some((ownership.id().clone(), overlay)));
5531                }
5532                let environment = match materializer.resolve_environment(&ownership, &profile) {
5533                    Ok(environment) => environment,
5534                    Err(
5535                        SessionEnvironmentMaterializeError::SecretUnavailable
5536                        | SessionEnvironmentMaterializeError::SecretDenied
5537                        | SessionEnvironmentMaterializeError::InvalidSecretValue,
5538                    ) => {
5539                        return Err(failure(
5540                            NodeFailureCode::BackendOperationFailed,
5541                            "managed session environment resolution failed",
5542                        ));
5543                    }
5544                    Err(
5545                        SessionEnvironmentMaterializeError::InvalidRoot
5546                        | SessionEnvironmentMaterializeError::OwnershipMismatch
5547                        | SessionEnvironmentMaterializeError::Filesystem(_)
5548                        | SessionEnvironmentMaterializeError::Record(_),
5549                    ) => {
5550                        self.mark_materialization_recovery_required(ownership.id());
5551                        return Err(failure(
5552                            NodeFailureCode::BackendOperationFailed,
5553                            "managed session environment resolution failed",
5554                        ));
5555                    }
5556                };
5557                let transport = match mode {
5558                    SessionMode::Pty => TransportKind::Pty,
5559                    SessionMode::Inline => TransportKind::Pipe,
5560                    SessionMode::Acp => TransportKind::Acp,
5561                };
5562                let overlay = match bundle_arguments {
5563                    Some(extra_args) => PreparedNativeLaunchOverlay::Instance(
5564                        NativeInstanceLaunchOverlay::new(
5565                            provider.clone(),
5566                            transport,
5567                            environment,
5568                            extra_args,
5569                        )
5570                        .map_err(|_| failure(
5571                            NodeFailureCode::BundleBindingMismatch,
5572                            "managed session bundle launch overlay is invalid",
5573                        ))?,
5574                    ),
5575                    None => PreparedNativeLaunchOverlay::Environment(
5576                        NativeLaunchEnvironmentOverlay::new(
5577                            provider.clone(),
5578                            transport,
5579                            environment,
5580                        )
5581                        .map_err(|_| {
5582                            failure(
5583                                NodeFailureCode::BackendOperationFailed,
5584                                "managed session environment overlay is invalid",
5585                            )
5586                        })?,
5587                    ),
5588                };
5589                Ok(Some((ownership.id().clone(), Some(overlay))))
5590            }
5591        }
5592    }
5593
5594    fn destroy_record_materialization(
5595        &self,
5596        record_id: &SessionRecordId,
5597    ) -> Result<bool, NodeFailure> {
5598        let ids = self
5599            .materializations
5600            .lock()
5601            .unwrap_or_else(|poisoned| poisoned.into_inner())
5602            .values()
5603            .filter_map(|record| {
5604                (record.owner()
5605                    == &MaterializationOwner::Record {
5606                        record_id: record_id.clone(),
5607                    })
5608                .then(|| record.id().clone())
5609            })
5610            .collect::<Vec<_>>();
5611        if ids.len() > 1 {
5612            return Err(failure(
5613                NodeFailureCode::BackendOperationFailed,
5614                "managed session has conflicting environment ownership",
5615            ));
5616        }
5617        let Some(id) = ids.first().cloned() else {
5618            return Ok(false);
5619        };
5620        let (terminal_record, mut cleanup_ownership) = {
5621            let _transaction = self
5622                .state_transaction
5623                .lock()
5624                .unwrap_or_else(|poisoned| poisoned.into_inner());
5625            let original_record = self.record(record_id)?;
5626            let mut terminal_record = original_record.clone();
5627            terminal_record.provider_session = None;
5628            terminal_record.active_session = None;
5629            terminal_record.state = ManagedSessionState::Unavailable;
5630            terminal_record.last_error = Some("environment-profile-unavailable".to_owned());
5631            terminal_record.updated_at_unix_ms = unix_time_ms();
5632            let original_ownership = self
5633                .materializations
5634                .lock()
5635                .unwrap_or_else(|poisoned| poisoned.into_inner())
5636                .get(&id)
5637                .cloned()
5638                .ok_or_else(|| {
5639                    failure(
5640                        NodeFailureCode::BackendOperationFailed,
5641                        "managed session environment ownership disappeared during cleanup",
5642                    )
5643                })?;
5644            let mut cleanup_ownership = original_ownership.clone();
5645            if cleanup_ownership.state() != MaterializationState::CleanupRequired {
5646                cleanup_ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5647                    failure(
5648                        NodeFailureCode::BackendOperationFailed,
5649                        "managed session environment cleanup state is invalid",
5650                    )
5651                })?;
5652            }
5653            self.session_records
5654                .lock()
5655                .unwrap_or_else(|poisoned| poisoned.into_inner())
5656                .records
5657                .insert(record_id.clone(), terminal_record.clone());
5658            self.materializations
5659                .lock()
5660                .unwrap_or_else(|poisoned| poisoned.into_inner())
5661                .insert(id.clone(), cleanup_ownership.clone());
5662            if let Err(error) = self.persist_state_locked() {
5663                self.session_records
5664                    .lock()
5665                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5666                    .records
5667                    .insert(record_id.clone(), original_record);
5668                self.materializations
5669                    .lock()
5670                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5671                    .insert(id.clone(), original_ownership);
5672                return Err(persistence_failure(error));
5673            }
5674            (terminal_record, cleanup_ownership)
5675        };
5676        let cleanup = self
5677            .session_environment_materializer
5678            .as_ref()
5679            .ok_or(())
5680            .and_then(|materializer| materializer.cleanup(&cleanup_ownership).map_err(|_| ()));
5681        if cleanup.is_err() {
5682            let _ = cleanup_ownership.mark_recovery_required(unix_time_ms());
5683            self.materializations
5684                .lock()
5685                .unwrap_or_else(|poisoned| poisoned.into_inner())
5686                .insert(id.clone(), cleanup_ownership);
5687            if self.persist_state().is_err() {
5688                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5689            }
5690            return Err(failure(
5691                NodeFailureCode::BackendOperationFailed,
5692                "managed session environment cleanup requires recovery",
5693            ));
5694        }
5695        let _transaction = self
5696            .state_transaction
5697            .lock()
5698            .unwrap_or_else(|poisoned| poisoned.into_inner());
5699        self.remove_record_memory(record_id);
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.session_records
5706                .lock()
5707                .unwrap_or_else(|poisoned| poisoned.into_inner())
5708                .records
5709                .insert(record_id.clone(), terminal_record);
5710            self.materializations
5711                .lock()
5712                .unwrap_or_else(|poisoned| poisoned.into_inner())
5713                .insert(id, cleanup_ownership);
5714            return Err(persistence_failure(error));
5715        }
5716        Ok(true)
5717    }
5718
5719    fn mark_materialization_recovery_required(&self, id: &MaterializationId) {
5720        let changed = {
5721            let mut materializations = self
5722                .materializations
5723                .lock()
5724                .unwrap_or_else(|poisoned| poisoned.into_inner());
5725            let Some(ownership) = materializations.get_mut(id) else {
5726                return;
5727            };
5728            ownership.mark_recovery_required(unix_time_ms()).is_ok()
5729        };
5730        if changed && self.persist_state().is_err() {
5731            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5732        }
5733    }
5734
5735    fn reconcile_materializations(&self) {
5736        let records = self
5737            .materializations
5738            .lock()
5739            .unwrap_or_else(|poisoned| poisoned.into_inner())
5740            .values()
5741            .cloned()
5742            .collect::<Vec<_>>();
5743        for mut ownership in records {
5744            let result = match (ownership.state(), ownership.owner()) {
5745                (MaterializationState::Preparing, _)
5746                | (MaterializationState::CleanupRequired, _) => {
5747                    self.cleanup_materialization(ownership.id())
5748                }
5749                (MaterializationState::Ready, MaterializationOwner::Session { .. }) => {
5750                    if ownership.mark_recovery_required(unix_time_ms()).is_ok() {
5751                        self.materializations
5752                            .lock()
5753                            .unwrap_or_else(|poisoned| poisoned.into_inner())
5754                            .insert(ownership.id().clone(), ownership.clone());
5755                        self.persist_state().map_err(persistence_failure)
5756                    } else {
5757                        Err(failure(
5758                            NodeFailureCode::BackendOperationFailed,
5759                            "session environment reconciliation failed",
5760                        ))
5761                    }
5762                }
5763                (MaterializationState::Ready, MaterializationOwner::Record { record_id }) => self
5764                    .session_environment_materializer
5765                    .as_ref()
5766                    .ok_or_else(|| {
5767                        failure(
5768                            NodeFailureCode::BackendOperationFailed,
5769                            "session environment materializer is unavailable",
5770                        )
5771                    })
5772                    .and_then(|materializer| {
5773                        materializer.revalidate(&ownership).map_err(|_| failure(
5774                            NodeFailureCode::BackendOperationFailed,
5775                            "session materialization revalidation failed",
5776                        ))?;
5777                        let record = self.record(record_id)?;
5778                        if record.environment_profile.as_ref() != ownership.environment_profile()
5779                            || record.bundle.as_ref() != ownership.bundle()
5780                            || record.context.as_ref() != ownership.context()
5781                        {
5782                            return Err(failure(
5783                                NodeFailureCode::BackendOperationFailed,
5784                                "session materialization ownership changed after restart",
5785                            ));
5786                        }
5787                        if let Some(receipt) = ownership.bundle() {
5788                            let catalog = self.bundle_catalog.read()
5789                                .unwrap_or_else(|poisoned| poisoned.into_inner());
5790                            let bundle = catalog.get(&receipt.id).ok_or_else(|| failure(
5791                                NodeFailureCode::UnknownBundle,
5792                                "session bundle is unavailable after restart",
5793                            ))?;
5794                            if bundle.receipt() != *receipt {
5795                                return Err(failure(
5796                                    NodeFailureCode::BundleBindingMismatch,
5797                                    "session bundle changed after restart",
5798                                ));
5799                            }
5800                            let layout = self.bundle_layout(
5801                                &record.provider,
5802                                record.mode,
5803                                record.environment_profile.as_ref(),
5804                                bundle,
5805                            )?;
5806                            materializer
5807                                .revalidate_bundle(&ownership, bundle, layout)
5808                                .map_err(|_| failure(
5809                                    NodeFailureCode::BundleMaterializationFailed,
5810                                    "session bundle revalidation failed",
5811                                ))?;
5812                        }
5813                        if let Some(receipt) = ownership.context() {
5814                            let pack = materializer
5815                                .revalidate_context(&ownership, receipt)
5816                                .map_err(|_| {
5817                                    failure(
5818                                        NodeFailureCode::ContextPackMaterializationFailed,
5819                                        "session context revalidation failed",
5820                                    )
5821                                })?;
5822                            self.context_catalog
5823                                .write()
5824                                .unwrap_or_else(|poisoned| poisoned.into_inner())
5825                                .insert(pack)
5826                                .map_err(|_| {
5827                                    failure(
5828                                        NodeFailureCode::ContextPackMaterializationFailed,
5829                                        "session context catalog restore failed",
5830                                    )
5831                                })?;
5832                        }
5833                        Ok(())
5834                    }),
5835                (MaterializationState::RecoveryRequired, _) => Ok(()),
5836            };
5837            if result.is_err() && ownership.state() != MaterializationState::RecoveryRequired {
5838                let _ = ownership.mark_recovery_required(unix_time_ms());
5839                self.materializations
5840                    .lock()
5841                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5842                    .insert(ownership.id().clone(), ownership);
5843                if self.persist_state().is_err() {
5844                    self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5845                }
5846            }
5847        }
5848        let materialized_profiles = self
5849            .environment_materialization_profiles
5850            .read()
5851            .unwrap_or_else(|poisoned| poisoned.into_inner())
5852            .keys()
5853            .cloned()
5854            .collect::<Vec<_>>();
5855        let ownership = self
5856            .materializations
5857            .lock()
5858            .unwrap_or_else(|poisoned| poisoned.into_inner())
5859            .values()
5860            .cloned()
5861            .collect::<Vec<_>>();
5862        let changed = {
5863            let mut session_records = self
5864                .session_records
5865                .lock()
5866                .unwrap_or_else(|poisoned| poisoned.into_inner());
5867            let mut changed = false;
5868            for record in session_records.records.values_mut() {
5869                let environment_profile = record.environment_profile.as_ref();
5870                if environment_profile.is_none()
5871                    && record.bundle.is_none()
5872                    && record.context.is_none()
5873                {
5874                    continue;
5875                }
5876                if record.bundle.is_none()
5877                    && environment_profile.is_some_and(|profile| {
5878                        !materialized_profiles.contains(&profile.profile_id)
5879                    })
5880                {
5881                    continue;
5882                }
5883                let has_exact_ownership = ownership.iter().any(|candidate| {
5884                    candidate.state() == MaterializationState::Ready
5885                        && candidate.environment_profile() == environment_profile
5886                        && candidate.bundle() == record.bundle.as_ref()
5887                        && candidate.context() == record.context.as_ref()
5888                        && candidate.owner()
5889                            == &MaterializationOwner::Record {
5890                                record_id: record.record_id.clone(),
5891                            }
5892                });
5893                if !has_exact_ownership {
5894                    record.provider_session = None;
5895                    record.active_session = None;
5896                    record.state = ManagedSessionState::Unavailable;
5897                    record.last_error = Some(if record.bundle.is_some() {
5898                        "bundle-unavailable".to_owned()
5899                    } else if record.context.is_some() {
5900                        "context-unavailable".to_owned()
5901                    } else {
5902                        "environment-profile-unavailable".to_owned()
5903                    });
5904                    record.updated_at_unix_ms = unix_time_ms();
5905                    changed = true;
5906                }
5907            }
5908            changed
5909        };
5910        if changed && self.persist_state().is_err() {
5911            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5912        }
5913    }
5914
5915    fn replay_spawn_spec(
5916        &self,
5917        spec: &SpawnSpec,
5918        now: Instant,
5919    ) -> Result<Option<Result<ResolvedSpawnReceipt, NodeFailure>>, NodeFailure> {
5920        let mut cache = self
5921            .spawn_idempotency
5922            .lock()
5923            .unwrap_or_else(|poisoned| poisoned.into_inner());
5924        cache.entries.retain(|_, entry| entry.expires_at > now);
5925        if let Some(entry) = cache.entries.get(&spec.idempotency_key) {
5926            let SpawnIdempotencyValue::Standard { spec: existing, result } = &entry.value else {
5927                return Err(failure(
5928                    NodeFailureCode::SpawnIdempotencyConflict,
5929                    "spawn idempotency key was reused across spawn kinds",
5930                ));
5931            };
5932            if existing != spec {
5933                return Err(failure(
5934                    NodeFailureCode::SpawnIdempotencyConflict,
5935                    "spawn idempotency key was reused with a different specification",
5936                ));
5937            }
5938            return Ok(Some(result.clone()));
5939        }
5940        if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
5941            return Err(failure(
5942                NodeFailureCode::SpawnIdempotencyCapacity,
5943                "spawn idempotency cache reached its bounded capacity",
5944            ));
5945        }
5946        Ok(None)
5947    }
5948
5949    fn remember_spawn_spec(
5950        &self,
5951        spec: SpawnSpec,
5952        result: Result<ResolvedSpawnReceipt, NodeFailure>,
5953        accepted_at: Instant,
5954    ) {
5955        self
5956            .spawn_idempotency
5957            .lock()
5958            .unwrap_or_else(|poisoned| poisoned.into_inner())
5959            .entries
5960            .insert(
5961                spec.idempotency_key.clone(),
5962                SpawnIdempotencyEntry {
5963                    value: SpawnIdempotencyValue::Standard { spec, result },
5964                    expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
5965                },
5966            );
5967    }
5968
5969    fn claim_harness_mcp_spawn(
5970        &self,
5971        _reservation_id: &HarnessMcpReservationId,
5972        _activation_digest: &HarnessMcpActivationDigest,
5973        spec: &SpawnSpec,
5974        now: Instant,
5975    ) -> Result<(), NodeFailure> {
5976        let mut cache = self.spawn_idempotency.lock()
5977            .unwrap_or_else(|poisoned| poisoned.into_inner());
5978        cache.entries.retain(|_, entry| entry.expires_at > now);
5979        if cache.entries.contains_key(&spec.idempotency_key) {
5980            return Err(failure(
5981                NodeFailureCode::SpawnIdempotencyConflict,
5982                "spawn idempotency key was reused across spawn kinds",
5983            ));
5984        }
5985        if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
5986            return Err(failure(
5987                NodeFailureCode::SpawnIdempotencyCapacity,
5988                "spawn idempotency cache reached its bounded capacity",
5989            ));
5990        }
5991        cache.entries.insert(spec.idempotency_key.clone(), SpawnIdempotencyEntry {
5992            value: SpawnIdempotencyValue::HarnessMcp,
5993            expires_at: now + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
5994        });
5995        Ok(())
5996    }
5997
5998    fn replay_managed_spawn(
5999        &self,
6000        request: &ManagedWorktreeSpawnRequest,
6001        now: Instant,
6002    ) -> Result<Option<Result<ManagedWorktreeSpawnReceipt, NodeFailure>>, NodeFailure> {
6003        let mut cache = self.spawn_idempotency.lock()
6004            .unwrap_or_else(|poisoned| poisoned.into_inner());
6005        cache.entries.retain(|_, entry| entry.expires_at > now);
6006        if let Some(entry) = cache.entries.get(&request.spawn_spec.idempotency_key) {
6007            let SpawnIdempotencyValue::Managed { request: existing, result } = &entry.value else {
6008                return Err(failure(
6009                    NodeFailureCode::SpawnIdempotencyConflict,
6010                    "spawn idempotency key was reused across spawn kinds",
6011                ));
6012            };
6013            if existing != request {
6014                return Err(failure(
6015                    NodeFailureCode::SpawnIdempotencyConflict,
6016                    "spawn idempotency key was reused with a different managed specification",
6017                ));
6018            }
6019            return Ok(Some(result.clone()));
6020        }
6021        if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
6022            return Err(failure(
6023                NodeFailureCode::SpawnIdempotencyCapacity,
6024                "spawn idempotency cache reached its bounded capacity",
6025            ));
6026        }
6027        Ok(None)
6028    }
6029
6030    fn remember_managed_spawn(
6031        &self,
6032        request: ManagedWorktreeSpawnRequest,
6033        result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
6034        accepted_at: Instant,
6035    ) {
6036        self.spawn_idempotency.lock()
6037            .unwrap_or_else(|poisoned| poisoned.into_inner())
6038            .entries
6039            .insert(
6040                request.spawn_spec.idempotency_key.clone(),
6041                SpawnIdempotencyEntry {
6042                    value: SpawnIdempotencyValue::Managed { request, result },
6043                    expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
6044                },
6045            );
6046    }
6047
6048    fn remember_managed_spawn_attempt(
6049        &self,
6050        durable_v2_key: Option<&SpawnIdempotencyKey>,
6051        request: ManagedWorktreeSpawnRequest,
6052        result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
6053        accepted_at: Instant,
6054    ) {
6055        if durable_v2_key.is_none() {
6056            self.remember_managed_spawn(request, result, accepted_at);
6057        }
6058    }
6059
6060    fn acquire_durable_managed_spawn_v2(
6061        &self,
6062        request: &ManagedWorktreeSpawnRequestV2,
6063        request_digest: String,
6064    ) -> Result<DurableManagedSpawnReplayDecision, NodeFailure> {
6065        if self.state_path.is_none() {
6066            return Err(failure(
6067                NodeFailureCode::BackendOperationFailed,
6068                "managed worktree V2 requires durable node state",
6069            ));
6070        }
6071        let _transaction = self.state_transaction
6072            .lock()
6073            .unwrap_or_else(|poisoned| poisoned.into_inner());
6074        let key = request.spawn_spec.idempotency_key.clone();
6075        let mut previous = None;
6076        {
6077            let mut replays = self.managed_spawn_replays
6078                .lock()
6079                .unwrap_or_else(|poisoned| poisoned.into_inner());
6080            if let Some(record) = replays.get_mut(&key) {
6081                if record.request_digest != request_digest {
6082                    return Err(failure(
6083                        NodeFailureCode::SpawnIdempotencyConflict,
6084                        "managed worktree V2 idempotency key was reused with a different request",
6085                    ));
6086                }
6087                match &record.state {
6088                    ManagedWorktreeSpawnReplayStateV10::Pending {
6089                        owner_incarnation_id,
6090                        lease_id: None,
6091                    } if owner_incarnation_id != &self.incarnation_id => {
6092                        previous = Some(record.clone());
6093                        record.state = ManagedWorktreeSpawnReplayStateV10::Pending {
6094                            owner_incarnation_id: self.incarnation_id,
6095                            lease_id: None,
6096                        };
6097                        record.updated_at_unix_ms = unix_time_ms();
6098                    }
6099                    ManagedWorktreeSpawnReplayStateV10::Pending {
6100                        lease_id: None,
6101                        ..
6102                    } => {
6103                        return Err(failure(
6104                            NodeFailureCode::BackendBusy,
6105                            "managed worktree V2 reservation is already active",
6106                        ));
6107                    }
6108                    ManagedWorktreeSpawnReplayStateV10::Pending {
6109                        lease_id: Some(_),
6110                        ..
6111                    } => {
6112                        return Ok(DurableManagedSpawnReplayDecision::PendingLinked);
6113                    }
6114                    ManagedWorktreeSpawnReplayStateV10::Committed { receipt } => {
6115                        return Ok(DurableManagedSpawnReplayDecision::Committed(
6116                            receipt.clone(),
6117                        ));
6118                    }
6119                }
6120            } else if replays.len() >= MAX_MANAGED_WORKTREE_SPAWN_REPLAYS {
6121                return Err(failure(
6122                    NodeFailureCode::SpawnIdempotencyCapacity,
6123                    "durable managed worktree replay ledger reached its bounded capacity",
6124                ));
6125            } else {
6126                let now = unix_time_ms();
6127                replays.insert(key.clone(), ManagedWorktreeSpawnReplayRecordV10 {
6128                    idempotency_key: key.clone(),
6129                    request_digest,
6130                    source_workspace_id: request.spawn_spec.target.workspace_id.clone(),
6131                    profile_id: request.worktree_profile_id.clone(),
6132                    expected_profile_revision: request.expected_profile_revision.clone(),
6133                    state: ManagedWorktreeSpawnReplayStateV10::Pending {
6134                        owner_incarnation_id: self.incarnation_id,
6135                        lease_id: None,
6136                    },
6137                    created_at_unix_ms: now,
6138                    updated_at_unix_ms: now,
6139                });
6140            }
6141        }
6142        if let Err(error) = self.persist_state_locked() {
6143            let mut replays = self.managed_spawn_replays
6144                .lock()
6145                .unwrap_or_else(|poisoned| poisoned.into_inner());
6146            match previous {
6147                Some(previous) => {
6148                    replays.insert(key, previous);
6149                }
6150                None => {
6151                    replays.remove(&key);
6152                }
6153            }
6154            return Err(persistence_failure(error));
6155        }
6156        Ok(DurableManagedSpawnReplayDecision::Reserved)
6157    }
6158
6159    fn link_durable_managed_spawn_v2_lease_locked(
6160        &self,
6161        key: &SpawnIdempotencyKey,
6162        lease_id: &ManagedWorktreeLeaseId,
6163    ) -> Result<ManagedWorktreeSpawnReplayRecordV10, NodeFailure> {
6164        let mut replays = self.managed_spawn_replays
6165            .lock()
6166            .unwrap_or_else(|poisoned| poisoned.into_inner());
6167        let record = replays.get_mut(key).ok_or_else(|| {
6168            failure(
6169                NodeFailureCode::ManagedWorktreeRecoveryRequired,
6170                "managed worktree V2 reservation disappeared before allocation",
6171            )
6172        })?;
6173        let previous = record.clone();
6174        match &mut record.state {
6175            ManagedWorktreeSpawnReplayStateV10::Pending {
6176                lease_id: current,
6177                ..
6178            }
6179                if current.is_none() =>
6180            {
6181                *current = Some(lease_id.clone());
6182                record.updated_at_unix_ms = unix_time_ms();
6183                Ok(previous)
6184            }
6185            _ => Err(failure(
6186                NodeFailureCode::ManagedWorktreeRecoveryRequired,
6187                "managed worktree V2 reservation is not allocation-ready",
6188            )),
6189        }
6190    }
6191
6192    fn restore_durable_managed_spawn_v2_locked(
6193        &self,
6194        key: &SpawnIdempotencyKey,
6195        previous: ManagedWorktreeSpawnReplayRecordV10,
6196    ) {
6197        self.managed_spawn_replays
6198            .lock()
6199            .unwrap_or_else(|poisoned| poisoned.into_inner())
6200            .insert(key.clone(), previous);
6201    }
6202
6203    fn commit_durable_managed_spawn_v2(
6204        &self,
6205        request: &ManagedWorktreeSpawnRequestV2,
6206        receipt: ManagedWorktreeSpawnReceipt,
6207    ) -> Result<(), NodeFailure> {
6208        let _transaction = self.state_transaction
6209            .lock()
6210            .unwrap_or_else(|poisoned| poisoned.into_inner());
6211        let key = &request.spawn_spec.idempotency_key;
6212        let previous = {
6213            let mut replays = self.managed_spawn_replays
6214                .lock()
6215                .unwrap_or_else(|poisoned| poisoned.into_inner());
6216            let record = replays.get_mut(key).ok_or_else(|| {
6217                failure(
6218                    NodeFailureCode::ManagedWorktreeRecoveryRequired,
6219                    "managed worktree V2 reservation disappeared before commit",
6220                )
6221            })?;
6222            let previous = record.clone();
6223            match &record.state {
6224                ManagedWorktreeSpawnReplayStateV10::Pending {
6225                    lease_id: Some(lease_id),
6226                    ..
6227                } if lease_id == &receipt.lease.lease_id => {}
6228                _ => {
6229                    return Err(failure(
6230                        NodeFailureCode::ManagedWorktreeRecoveryRequired,
6231                        "managed worktree V2 reservation is not linked to the spawned lease",
6232                    ));
6233                }
6234            }
6235            record.state = ManagedWorktreeSpawnReplayStateV10::Committed { receipt };
6236            record.updated_at_unix_ms = unix_time_ms();
6237            previous
6238        };
6239        if let Err(error) = self.persist_state_locked() {
6240            self.restore_durable_managed_spawn_v2_locked(key, previous);
6241            return Err(persistence_failure(error));
6242        }
6243        Ok(())
6244    }
6245
6246    fn remove_unallocated_durable_managed_spawn_v2(
6247        &self,
6248        request: &ManagedWorktreeSpawnRequestV2,
6249    ) -> Result<(), NodeFailure> {
6250        let _transaction = self.state_transaction
6251            .lock()
6252            .unwrap_or_else(|poisoned| poisoned.into_inner());
6253        let key = &request.spawn_spec.idempotency_key;
6254        let removed = {
6255            let mut replays = self.managed_spawn_replays
6256                .lock()
6257                .unwrap_or_else(|poisoned| poisoned.into_inner());
6258            match replays.get(key).map(|record| &record.state) {
6259                Some(ManagedWorktreeSpawnReplayStateV10::Pending {
6260                    lease_id: None,
6261                    ..
6262                }) => {
6263                    replays.remove(key)
6264                }
6265                _ => None,
6266            }
6267        };
6268        let Some(removed) = removed else {
6269            return Ok(());
6270        };
6271        if let Err(error) = self.persist_state_locked() {
6272            self.managed_spawn_replays
6273                .lock()
6274                .unwrap_or_else(|poisoned| poisoned.into_inner())
6275                .insert(key.clone(), removed);
6276            return Err(persistence_failure(error));
6277        }
6278        Ok(())
6279    }
6280
6281    fn resolve_spawn_spec(&self, spec: &SpawnSpec) -> Result<ResolvedSpawnSpec, NodeFailure> {
6282        if spec.target.node_id != self.node_id {
6283            return Err(failure(
6284                NodeFailureCode::SpawnTargetMismatch,
6285                "spawn target does not match this node",
6286            ));
6287        }
6288        let defaults = self.spawn_profiles.get(&spec.profile_id).ok_or_else(|| {
6289            failure(
6290                NodeFailureCode::UnknownSpawnProfile,
6291                "spawn profile is unavailable on this node",
6292            )
6293        })?;
6294        let resolved = spec.resolve(defaults).map_err(|error| match error {
6295            SpawnSpecResolveError::ProfileRevisionMismatch { .. } => failure(
6296                NodeFailureCode::SpawnProfileRevisionMismatch,
6297                "spawn profile revision does not match the loaded profile",
6298            ),
6299            _ => failure(
6300                NodeFailureCode::InvalidRequest,
6301                "spawn specification could not be resolved",
6302            ),
6303        })?;
6304        if resolved.target.worktree_id.is_some() {
6305            self.require_worktree_service(&resolved.target.workspace_id)?;
6306        }
6307        let environment_profile = self.resolve_environment_profile(&resolved)?;
6308        self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6309        self.resolve_context(&resolved)?;
6310        Ok(resolved)
6311    }
6312
6313    async fn resolve_spawn_workspace(
6314        &self,
6315        resolved: &ResolvedSpawnSpec,
6316        deadline: Instant,
6317    ) -> Result<WorkspaceId, NodeFailure> {
6318        let Some(worktree_id) = resolved.target.worktree_id.as_ref() else {
6319            return Ok(resolved.target.workspace_id.clone());
6320        };
6321        if self.managed_worktrees.lock()
6322            .unwrap_or_else(|poisoned| poisoned.into_inner())
6323            .lease_for_workspace(worktree_id)
6324            .is_some()
6325        {
6326            return Err(failure(
6327                NodeFailureCode::ManagedWorktreeOwnershipConflict,
6328                "managed worktree targets may only be spawned through their lease request",
6329            ));
6330        }
6331        self.require_worktree_service(&resolved.target.workspace_id)?;
6332        let source_root = self.workspace_root(&resolved.target.workspace_id)?;
6333        let selected_root = self.workspace_root(worktree_id)?;
6334        let remaining = spawn_deadline_remaining(deadline)?;
6335        let worktrees = timeout(remaining, list_git_worktrees(&source_root))
6336            .await
6337            .map_err(|_| {
6338                failure(
6339                    NodeFailureCode::SpawnDeadlineExceeded,
6340                    "Git worktree selection exceeded the spawn deadline",
6341                )
6342            })?
6343            .map_err(git_worktree_failure)?;
6344        validate_selected_worktree(&source_root, &selected_root, &worktrees)?;
6345        Ok(worktree_id.clone())
6346    }
6347
6348    async fn spawn_from_spec(
6349        &self,
6350        spec: SpawnSpec,
6351    ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6352        self.spawn_from_spec_with_proxy(spec, None).await
6353    }
6354
6355    async fn spawn_from_spec_with_proxy(
6356        &self,
6357        spec: SpawnSpec,
6358        harness_mcp: Option<&PreparedHarnessMcpSpawn>,
6359    ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6360        let accepted_at = Instant::now();
6361        if harness_mcp.is_none() {
6362            if let Some(replayed) = self.replay_spawn_spec(&spec, accepted_at)? {
6363                return replayed;
6364            }
6365        }
6366        let resolved = self.resolve_spawn_spec(&spec)?;
6367        let environment_profile = self.resolve_environment_profile(&resolved)?;
6368        let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6369        let context = self.resolve_context(&resolved)?;
6370        let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6371        let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6372        let spawn_workspace_id = self.resolve_spawn_workspace(&resolved, deadline).await;
6373        let spawn_workspace_id = match spawn_workspace_id {
6374            Ok(workspace_id) => workspace_id,
6375            Err(error) => {
6376                let result = Err(error);
6377                if harness_mcp.is_none() {
6378                    self.remember_spawn_spec(spec, result.clone(), accepted_at);
6379                }
6380                return result;
6381            }
6382        };
6383        if self.managed_worktrees.lock()
6384            .unwrap_or_else(|poisoned| poisoned.into_inner())
6385            .lease_for_workspace(&spawn_workspace_id)
6386            .is_some()
6387        {
6388            let result = Err(failure(
6389                NodeFailureCode::ManagedWorktreeOwnershipConflict,
6390                "managed worktree targets may only be spawned through their lease request",
6391            ));
6392            if harness_mcp.is_none() {
6393                self.remember_spawn_spec(spec, result.clone(), accepted_at);
6394            }
6395            return result;
6396        }
6397        let result = self
6398            .spawn_session_with_deadline(
6399                spawn_workspace_id,
6400                resolved.provider.clone(),
6401                resolved.mode,
6402                resolved.terminal_size,
6403                resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
6404                environment_profile.clone(),
6405                bundle.clone(),
6406                context.clone(),
6407                None,
6408                None,
6409                SpawnRecordPolicy::Always,
6410                Some(deadline),
6411                &required_capabilities,
6412                harness_mcp,
6413                resolved.approval_level,
6414                resolved.network_allowlist.clone(),
6415                resolved.browser_profile_id.clone(),
6416            )
6417            .await
6418            .map(|(session, _runtime_policy)| {
6419                resolved.receipt_with_materialization(
6420                    self.incarnation_id,
6421                    session,
6422                    environment_profile,
6423                    bundle,
6424                    context,
6425                )
6426            });
6427        if harness_mcp.is_none() {
6428            self.remember_spawn_spec(spec, result.clone(), accepted_at);
6429        }
6430        result
6431    }
6432
6433    async fn spawn_from_spec_with_harness_mcp(
6434        &self,
6435        reservation_id: HarnessMcpReservationId,
6436        activation_digest: HarnessMcpActivationDigest,
6437        spec: SpawnSpec,
6438        deadline_unix_ms: u64,
6439    ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6440        let registry = self.harness_mcp_registry.as_ref().ok_or_else(|| {
6441            failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
6442        })?;
6443        if let Some(receipt) = registry
6444            .replay_spawn(&reservation_id, &activation_digest, &spec)
6445            .map_err(harness_mcp_failure)?
6446        {
6447            return Ok(receipt);
6448        }
6449        let resolved = self.resolve_spawn_spec(&spec)?;
6450        let prepared = registry
6451            .prepare_spawn(
6452                &reservation_id,
6453                &activation_digest,
6454                &spec,
6455                deadline_unix_ms,
6456                resolved.provider.clone(),
6457            )
6458            .map_err(harness_mcp_failure)?;
6459        self.claim_harness_mcp_spawn(
6460            &reservation_id,
6461            &activation_digest,
6462            &spec,
6463            Instant::now(),
6464        )?;
6465        let spawn = self.spawn_from_spec_with_proxy(spec, Some(&prepared)).await;
6466        let mut receipt = match spawn {
6467            Ok(receipt) => receipt,
6468            Err(error) => {
6469                let _ = registry.abort(&reservation_id, &activation_digest);
6470                return Err(error);
6471            }
6472        };
6473        let record_id = self
6474            .session_bindings
6475            .lock()
6476            .unwrap_or_else(|poisoned| poisoned.into_inner())
6477            .get(&receipt.session.session.instance_id)
6478            .and_then(|binding| binding.record_id.clone());
6479        let record_id = match record_id {
6480            Some(record_id) => record_id,
6481            None => {
6482                let rollback = self.remove_session(&receipt.session).await;
6483                let _ = registry.abort(&reservation_id, &activation_digest);
6484                if let Err(rollback_error) = rollback {
6485                    return Err(rollback_error);
6486                }
6487                return Err(failure(
6488                    NodeFailureCode::BindingMismatch,
6489                    "harness MCP spawn did not retain its exact managed record",
6490                ));
6491            }
6492        };
6493        receipt.harness_mcp_proxy = Some(ResolvedHarnessMcpProxyReceiptV1 {
6494            reservation_id: reservation_id.clone(),
6495            activation_digest: activation_digest.clone(),
6496        });
6497        if let Err(error) = registry.mark_spawned(
6498            &prepared,
6499            receipt.session.clone(),
6500            record_id,
6501            receipt.clone(),
6502        ) {
6503            let rollback = self.remove_session(&receipt.session).await;
6504            let _ = registry.abort(&reservation_id, &activation_digest);
6505            if let Err(rollback_error) = rollback {
6506                return Err(rollback_error);
6507            }
6508            return Err(harness_mcp_failure(error));
6509        }
6510        Ok(receipt)
6511    }
6512
6513    async fn spawn_managed_worktree(
6514        &self,
6515        request: ManagedWorktreeSpawnRequest,
6516    ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6517        self.spawn_managed_worktree_inner(request, None).await
6518    }
6519
6520    async fn spawn_managed_worktree_inner(
6521        &self,
6522        request: ManagedWorktreeSpawnRequest,
6523        durable_v2_key: Option<&SpawnIdempotencyKey>,
6524    ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6525        let accepted_at = Instant::now();
6526        if durable_v2_key.is_none() {
6527            if let Some(replayed) = self.replay_managed_spawn(&request, accepted_at)? {
6528                return replayed;
6529            }
6530        }
6531        let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
6532        if request.spawn_spec.target.worktree_id.is_some() {
6533            let result = Err(failure(
6534                NodeFailureCode::InvalidRequest,
6535                "managed worktree spawn must not select a caller-provided worktree",
6536            ));
6537            self.remember_managed_spawn_attempt(
6538                durable_v2_key,
6539                request,
6540                result.clone(),
6541                accepted_at,
6542            );
6543            return result;
6544        }
6545        let environment_profile = self.resolve_environment_profile(&resolved)?;
6546        let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6547        let context = self.resolve_context(&resolved)?;
6548        let source_workspace_id = resolved.target.workspace_id.clone();
6549        let profile = self.managed_profile(&source_workspace_id, &request.worktree_profile_id)?;
6550        let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6551        let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6552        let runtime_requirement = match (resolved.mode, resolved.prompt.is_some()) {
6553            (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
6554            (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
6555            (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
6556            (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
6557        };
6558        let runtime_policy = timeout(
6559            spawn_deadline_remaining(deadline)?,
6560            self.admit_provider_runtime(&resolved.provider, runtime_requirement),
6561        ).await.map_err(|_| failure(
6562            NodeFailureCode::SpawnDeadlineExceeded,
6563            "provider admission exceeded the managed spawn deadline",
6564        ))??;
6565        if required_capabilities.iter().any(|capability| !runtime_policy.admits(*capability)) {
6566            return Err(failure(
6567                NodeFailureCode::UnsupportedSpawnCapability,
6568                "provider runtime does not admit a required spawn capability",
6569            ));
6570        }
6571        self.ensure_binding_capacity()?;
6572        let source_root = self.workspace_root(&source_workspace_id)?;
6573        profile.validate_for_workspace(&source_root)
6574            .map_err(|message| failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message))?;
6575        let base_timeout_ms = u64::try_from(
6576            spawn_deadline_remaining(deadline)?.as_millis(),
6577        ).unwrap_or(u64::MAX).max(1);
6578        let base_commit = resolve_base_commit_with_timeout(
6579            &source_root,
6580            profile.base(),
6581            base_timeout_ms,
6582        ).await.map_err(|error| {
6583            if Instant::now() >= deadline || error.message.contains("timed out") {
6584                failure(
6585                    NodeFailureCode::SpawnDeadlineExceeded,
6586                    "managed worktree base resolution exceeded the spawn deadline",
6587                )
6588            } else {
6589                managed_git_worktree_failure(error)
6590            }
6591        })?;
6592
6593        let lease = {
6594            let _transaction = self.state_transaction.lock()
6595                .unwrap_or_else(|poisoned| poisoned.into_inner());
6596            let previous = self.managed_worktrees.lock()
6597                .unwrap_or_else(|poisoned| poisoned.into_inner()).clone();
6598            let lease = self.managed_worktrees.lock()
6599                .unwrap_or_else(|poisoned| poisoned.into_inner())
6600                .allocate(source_workspace_id.clone(), &profile, base_commit.clone(), unix_time_ms())
6601                .map_err(|message| failure(NodeFailureCode::BackendBusy, &message))?;
6602            let previous_replay = match durable_v2_key
6603                .map(|key| self.link_durable_managed_spawn_v2_lease_locked(key, &lease.lease_id))
6604                .transpose()
6605            {
6606                Ok(previous) => previous,
6607                Err(error) => {
6608                    *self.managed_worktrees.lock()
6609                        .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6610                    return Err(error);
6611                }
6612            };
6613            if let Err(error) = self.persist_state_locked() {
6614                *self.managed_worktrees.lock()
6615                    .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6616                if let (Some(key), Some(previous_replay)) =
6617                    (durable_v2_key, previous_replay)
6618                {
6619                    self.restore_durable_managed_spawn_v2_locked(key, previous_replay);
6620                }
6621                return Err(persistence_failure(error));
6622            }
6623            lease
6624        };
6625        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() });
6626        if let Err(message) = profile.validate_target_authority(&lease.lease_id, &lease.target_root) {
6627            self.managed_worktrees.lock()
6628                .unwrap_or_else(|poisoned| poisoned.into_inner())
6629                .remove_unmutated(&lease.lease_id);
6630            if self.persist_state().is_err() {
6631                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6632            }
6633            let result = Err(failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message));
6634            self.remember_managed_spawn_attempt(
6635                durable_v2_key,
6636                request,
6637                result.clone(),
6638                accepted_at,
6639            );
6640            return result;
6641        }
6642        let mutation_timeout_ms = match spawn_deadline_remaining(deadline) {
6643            Ok(remaining) => u64::try_from(remaining.as_millis()).unwrap_or(u64::MAX).max(1),
6644            Err(error) => {
6645                self.reconcile_failed_allocation(&lease, &source_root).await;
6646                let result = Err(error);
6647                self.remember_managed_spawn_attempt(
6648                    durable_v2_key,
6649                    request,
6650                    result.clone(),
6651                    accepted_at,
6652                );
6653                return result;
6654            }
6655        };
6656        let created = match create_git_worktree_with_timeout(
6657                &source_root,
6658                &lease.target_root,
6659                &lease.branch,
6660                Some(&base_commit),
6661                mutation_timeout_ms,
6662            ).await {
6663            Ok(created) => created,
6664            Err(error) => {
6665                self.reconcile_failed_allocation(&lease, &source_root).await;
6666                let result = Err(if Instant::now() >= deadline
6667                    || error.message.contains("timed out")
6668                    || error.message.contains("deadline")
6669                {
6670                    failure(
6671                        NodeFailureCode::SpawnDeadlineExceeded,
6672                        "managed worktree creation exceeded the spawn deadline",
6673                    )
6674                } else {
6675                    managed_git_worktree_failure(error)
6676                });
6677                self.remember_managed_spawn_attempt(
6678                    durable_v2_key,
6679                    request,
6680                    result.clone(),
6681                    accepted_at,
6682                );
6683                return result;
6684            }
6685        };
6686        if !exact_created_worktree(&lease, &created.path, created.branch.as_deref(), &created.head) {
6687            self.mark_managed_recovery_required(&lease.lease_id, ManagedWorktreeCleanupFailure::OwnershipConflict);
6688            let result = Err(failure(
6689                NodeFailureCode::ManagedWorktreeRecoveryRequired,
6690                "created worktree identity did not match its durable lease",
6691            ));
6692            self.remember_managed_spawn_attempt(
6693                durable_v2_key,
6694                request,
6695                result.clone(),
6696                accepted_at,
6697            );
6698            return result;
6699        }
6700        {
6701            let mut registry = self.managed_worktrees.lock()
6702                .unwrap_or_else(|poisoned| poisoned.into_inner());
6703            let current = registry.get_mut(&lease.lease_id)
6704                .expect("allocated managed worktree lease remains present");
6705            current.expected_head = Some(created.head.clone());
6706            current.state = ManagedWorktreeLeaseState::Ready;
6707            current.updated_at_unix_ms = unix_time_ms();
6708        }
6709        if let Err(error) = self.persist_state() {
6710            self.mark_managed_recovery_required(
6711                &lease.lease_id,
6712                ManagedWorktreeCleanupFailure::Backend,
6713            );
6714            let result = Err(persistence_failure(error));
6715            self.remember_managed_spawn_attempt(
6716                durable_v2_key,
6717                request,
6718                result.clone(),
6719                accepted_at,
6720            );
6721            return result;
6722        }
6723        let ready = self.managed_worktrees.lock()
6724            .unwrap_or_else(|poisoned| poisoned.into_inner())
6725            .get(&lease.lease_id)
6726            .expect("ready managed worktree lease remains present")
6727            .snapshot();
6728        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: ready });
6729        if let Err(error) = self.register_workspace(lease.workspace_id.clone(), created.path.clone()).await {
6730            let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
6731            let result = Err(error);
6732            self.remember_managed_spawn_attempt(
6733                durable_v2_key,
6734                request,
6735                result.clone(),
6736                accepted_at,
6737            );
6738            return result;
6739        }
6740        let spawn = self.spawn_session_with_deadline(
6741            lease.workspace_id.clone(),
6742            resolved.provider.clone(),
6743            resolved.mode,
6744            resolved.terminal_size,
6745            resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
6746            environment_profile.clone(),
6747            bundle.clone(),
6748            context.clone(),
6749            Some(lease.lease_id.clone()),
6750            Some(runtime_policy),
6751            SpawnRecordPolicy::Always,
6752            Some(deadline),
6753            &required_capabilities,
6754            None,
6755            resolved.approval_level,
6756            resolved.network_allowlist.clone(),
6757            resolved.browser_profile_id.clone(),
6758        ).await;
6759        let (session, effective_runtime_policy) = match spawn {
6760            Ok(spawned) => spawned,
6761            Err(error) => {
6762                if self
6763                    .cleanup_managed_worktree(&lease.lease_id, true)
6764                    .await
6765                    .is_err()
6766                {
6767                    self.mark_managed_recovery_required(
6768                        &lease.lease_id,
6769                        ManagedWorktreeCleanupFailure::Backend,
6770                    );
6771                }
6772                let result = Err(error);
6773                self.remember_managed_spawn_attempt(
6774                    durable_v2_key,
6775                    request,
6776                    result.clone(),
6777                    accepted_at,
6778                );
6779                return result;
6780            }
6781        };
6782        let (record_id, raw_inventory_record_id) = {
6783            let mut bindings = self.session_bindings.lock()
6784                .unwrap_or_else(|poisoned| poisoned.into_inner());
6785            let binding = bindings.get_mut(&session.session.instance_id)
6786                .expect("successful spawn retains its session binding");
6787            binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
6788            managed_spawn_record_ownership(binding, effective_runtime_policy)
6789        };
6790        let bound = self.managed_worktrees.lock()
6791            .unwrap_or_else(|poisoned| poisoned.into_inner())
6792            .bind_session(
6793                &lease.lease_id,
6794                ManagedWorktreeSessionHolder {
6795                    incarnation_id: self.incarnation_id,
6796                    instance_id: session.session.instance_id,
6797                    generation: session.session.generation,
6798                },
6799                record_id,
6800                unix_time_ms(),
6801            )
6802            .map_err(|message| failure(NodeFailureCode::ManagedWorktreeRecoveryRequired, &message));
6803        let snapshot = match bound {
6804            Ok(snapshot) => snapshot,
6805            Err(error) => {
6806                if raw_inventory_record_id
6807                    .as_ref()
6808                    .is_some_and(|record_id| self.discard_record(record_id).is_err())
6809                {
6810                    self.mark_managed_recovery_required(
6811                        &lease.lease_id,
6812                        ManagedWorktreeCleanupFailure::Backend,
6813                    );
6814                }
6815                let _ = self.remove_session(&session).await;
6816                self.mark_managed_recovery_required(
6817                    &lease.lease_id,
6818                    ManagedWorktreeCleanupFailure::Backend,
6819                );
6820                let result = Err(error);
6821                self.remember_managed_spawn_attempt(
6822                    durable_v2_key,
6823                    request,
6824                    result.clone(),
6825                    accepted_at,
6826                );
6827                return result;
6828            }
6829        };
6830        if let Err(error) = self.persist_state() {
6831            if raw_inventory_record_id
6832                .as_ref()
6833                .is_some_and(|record_id| self.discard_record(record_id).is_err())
6834            {
6835                self.mark_managed_recovery_required(
6836                    &lease.lease_id,
6837                    ManagedWorktreeCleanupFailure::Backend,
6838                );
6839            }
6840            let _ = self.remove_session(&session).await;
6841            if self
6842                .cleanup_managed_worktree(&lease.lease_id, true)
6843                .await
6844                .is_err()
6845            {
6846                self.mark_managed_recovery_required(
6847                    &lease.lease_id,
6848                    ManagedWorktreeCleanupFailure::Backend,
6849                );
6850            }
6851            let result = Err(persistence_failure(error));
6852            self.remember_managed_spawn_attempt(
6853                durable_v2_key,
6854                request,
6855                result.clone(),
6856                accepted_at,
6857            );
6858            return result;
6859        }
6860        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
6861        let receipt = managed_spawn_receipt(
6862            &resolved,
6863            self.incarnation_id,
6864            session,
6865            snapshot,
6866            environment_profile,
6867            bundle,
6868            context,
6869        );
6870        let result = Ok(receipt);
6871        self.remember_managed_spawn_attempt(
6872            durable_v2_key,
6873            request,
6874            result.clone(),
6875            accepted_at,
6876        );
6877        result
6878    }
6879
6880    async fn spawn_managed_worktree_v2(
6881        &self,
6882        request: ManagedWorktreeSpawnRequestV2,
6883    ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6884        if request.spawn_spec.target.node_id != self.node_id {
6885            return Err(failure(
6886                NodeFailureCode::SpawnTargetMismatch,
6887                "spawn target does not match this node",
6888            ));
6889        }
6890        if request.spawn_spec.target.worktree_id.is_some() {
6891            return Err(failure(
6892                NodeFailureCode::InvalidRequest,
6893                "managed worktree spawn must not select a caller-provided worktree",
6894            ));
6895        }
6896        let request_digest = managed_spawn_request_digest_v2(&request)?;
6897        let replay = self.acquire_durable_managed_spawn_v2(&request, request_digest)?;
6898        match replay {
6899            DurableManagedSpawnReplayDecision::Committed(receipt) => return Ok(receipt),
6900            DurableManagedSpawnReplayDecision::PendingLinked => {
6901                return Err(failure(
6902                    NodeFailureCode::ManagedWorktreeRecoveryRequired,
6903                    "managed worktree V2 reservation has an incomplete durable allocation",
6904                ));
6905            }
6906            DurableManagedSpawnReplayDecision::Reserved => {}
6907        }
6908        let validated = (|| {
6909            let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
6910            let profile = self.managed_profile(
6911                &resolved.target.workspace_id,
6912                &request.worktree_profile_id,
6913            )?;
6914            if profile.revision() != &request.expected_profile_revision {
6915                return Err(failure(
6916                    NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
6917                    "managed worktree profile revision does not match the loaded profile",
6918                ));
6919            }
6920            Ok(())
6921        })();
6922        match validated {
6923            Ok(()) => {}
6924            Err(error) => {
6925                self.remove_unallocated_durable_managed_spawn_v2(&request)?;
6926                return Err(error);
6927            }
6928        }
6929        let legacy = ManagedWorktreeSpawnRequest {
6930            spawn_spec: request.spawn_spec.clone(),
6931            worktree_profile_id: request.worktree_profile_id.clone(),
6932        };
6933        match self
6934            .spawn_managed_worktree_inner(
6935                legacy,
6936                Some(&request.spawn_spec.idempotency_key),
6937            )
6938            .await
6939        {
6940            Ok(receipt) => {
6941                self.commit_durable_managed_spawn_v2(&request, receipt.clone())?;
6942                Ok(receipt)
6943            }
6944            Err(error) => {
6945                self.remove_unallocated_durable_managed_spawn_v2(&request)?;
6946                Err(error)
6947            }
6948        }
6949    }
6950
6951    fn managed_profile(
6952        &self,
6953        workspace_id: &WorkspaceId,
6954        profile_id: &WorktreeProfileId,
6955    ) -> Result<ManagedWorktreeProfile, NodeFailure> {
6956        if self.worktree_service_modes.get(workspace_id).copied()
6957            != Some(WorktreeServiceMode::Managed)
6958        {
6959            return Err(failure(
6960                NodeFailureCode::UnsupportedSpawnCapability,
6961                "workspace does not enable managed worktrees",
6962            ));
6963        }
6964        self.managed_worktree_profiles.get(workspace_id)
6965            .and_then(|profiles| profiles.get(profile_id))
6966            .cloned()
6967            .ok_or_else(|| failure(
6968                NodeFailureCode::InvalidRequest,
6969                "managed worktree profile is unavailable for this workspace",
6970            ))
6971    }
6972
6973    async fn reconcile_failed_allocation(
6974        &self,
6975        lease: &ManagedWorktreeLeaseRecord,
6976        source_root: &str,
6977    ) {
6978        match list_git_worktrees(source_root).await {
6979            Ok(worktrees) => {
6980                match worktrees.iter().find(|item| worktree_paths_equal(&item.path, &lease.target_root)) {
6981                    None => {
6982                        match std::fs::symlink_metadata(&lease.target_root) {
6983                            Err(error) if error.kind() == io::ErrorKind::NotFound => {
6984                                self.managed_worktrees.lock()
6985                                    .unwrap_or_else(|poisoned| poisoned.into_inner())
6986                                    .remove_unmutated(&lease.lease_id);
6987                                let _ = self.persist_state();
6988                            }
6989                            _ => self.mark_managed_recovery_required(
6990                                &lease.lease_id,
6991                                ManagedWorktreeCleanupFailure::OwnershipConflict,
6992                            ),
6993                        }
6994                    }
6995                    Some(item) if exact_owned_worktree(
6996                        lease,
6997                        &item.path,
6998                        item.branch.as_deref(),
6999                        &item.head,
7000                    ) => {
7001                        let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
7002                    }
7003                    Some(_) => self.mark_managed_recovery_required(
7004                        &lease.lease_id,
7005                        ManagedWorktreeCleanupFailure::OwnershipConflict,
7006                    ),
7007                }
7008            }
7009            Err(_) => self.mark_managed_recovery_required(
7010                &lease.lease_id,
7011                ManagedWorktreeCleanupFailure::Backend,
7012            ),
7013        }
7014    }
7015
7016    async fn reconcile_managed_worktrees(&self) {
7017        let leases = self.managed_worktrees.lock()
7018            .unwrap_or_else(|poisoned| poisoned.into_inner())
7019            .active_records()
7020            .cloned()
7021            .collect::<Vec<_>>();
7022        for lease in leases {
7023            let profile = match self.managed_profile(&lease.source_workspace_id, &lease.profile_id) {
7024                Ok(profile) if profile.revision() == &lease.profile_revision => profile,
7025                _ => {
7026                    self.mark_managed_recovery_required(
7027                        &lease.lease_id,
7028                        ManagedWorktreeCleanupFailure::OwnershipConflict,
7029                    );
7030                    continue;
7031                }
7032            };
7033            if profile.validate_target_authority(&lease.lease_id, &lease.target_root).is_err() {
7034                self.mark_managed_recovery_required(
7035                    &lease.lease_id,
7036                    ManagedWorktreeCleanupFailure::OwnershipConflict,
7037                );
7038                continue;
7039            }
7040            let source_root = match self.workspace_root(&lease.source_workspace_id) {
7041                Ok(root) => root,
7042                Err(_) => {
7043                    self.mark_managed_recovery_required(
7044                        &lease.lease_id,
7045                        ManagedWorktreeCleanupFailure::OwnershipConflict,
7046                    );
7047                    continue;
7048                }
7049            };
7050            let worktrees = match list_git_worktrees(&source_root).await {
7051                Ok(worktrees) => worktrees,
7052                Err(_) => {
7053                    self.mark_managed_recovery_required(
7054                        &lease.lease_id,
7055                        ManagedWorktreeCleanupFailure::Backend,
7056                    );
7057                    continue;
7058                }
7059            };
7060            let listed = worktrees.iter()
7061                .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
7062            let Some(listed) = listed else {
7063                match std::fs::symlink_metadata(&lease.target_root) {
7064                    Err(error) if error.kind() == io::ErrorKind::NotFound
7065                        && lease.state == ManagedWorktreeLeaseState::Allocating
7066                        && !lease.has_holders() => {
7067                            let removed = self.managed_worktrees.lock()
7068                                .unwrap_or_else(|poisoned| poisoned.into_inner())
7069                                .tombstone(&lease.lease_id, unix_time_ms());
7070                            if self.persist_state().is_ok() && removed.is_some() {
7071                                self.publish(NodeEvent::ManagedWorktreeRemoved {
7072                                    lease_id: lease.lease_id.clone(),
7073                                });
7074                            }
7075                        }
7076                    _ => self.mark_managed_recovery_required(
7077                        &lease.lease_id,
7078                        ManagedWorktreeCleanupFailure::OwnershipConflict,
7079                    ),
7080                }
7081                continue;
7082            };
7083            if listed.is_main || listed.is_bare || !exact_owned_worktree(
7084                &lease,
7085                &listed.path,
7086                listed.branch.as_deref(),
7087                &listed.head,
7088            ) {
7089                self.mark_managed_recovery_required(
7090                    &lease.lease_id,
7091                    ManagedWorktreeCleanupFailure::OwnershipConflict,
7092                );
7093                continue;
7094            }
7095            if !self.workspaces.read()
7096                .unwrap_or_else(|poisoned| poisoned.into_inner())
7097                .contains_key(&lease.workspace_id)
7098            {
7099                if self.register_workspace(lease.workspace_id.clone(), listed.path.clone()).await.is_err() {
7100                    self.mark_managed_recovery_required(
7101                        &lease.lease_id,
7102                        ManagedWorktreeCleanupFailure::Backend,
7103                    );
7104                    continue;
7105                }
7106            }
7107            let snapshot = {
7108                let mut registry = self.managed_worktrees.lock()
7109                    .unwrap_or_else(|poisoned| poisoned.into_inner());
7110                let current = registry.get_mut(&lease.lease_id)
7111                    .expect("reconciled managed lease remains present");
7112                current.expected_head = Some(listed.head.clone());
7113                current.cleanup_failure = None;
7114                current.state = if current.has_holders() {
7115                    ManagedWorktreeLeaseState::InUse
7116                } else if current.retention == ManagedWorktreeRetention::Retain {
7117                    ManagedWorktreeLeaseState::Retained
7118                } else {
7119                    ManagedWorktreeLeaseState::Ready
7120                };
7121                current.updated_at_unix_ms = unix_time_ms();
7122                current.snapshot()
7123            };
7124            if self.persist_state().is_err() {
7125                self.mark_managed_recovery_required(
7126                    &lease.lease_id,
7127                    ManagedWorktreeCleanupFailure::Backend,
7128                );
7129                continue;
7130            }
7131            self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
7132            if snapshot.state == ManagedWorktreeLeaseState::Ready {
7133                let _ = self.cleanup_managed_worktree(&lease.lease_id, false).await;
7134            }
7135        }
7136    }
7137
7138    /// Crash-recovery startup sweep: durably capture the pack for every
7139    /// `Live` managed record whose most recent session has not yet produced
7140    /// one (recovers a session whose `Exited{0}` event was observed but the
7141    /// Node crashed before the export finished). The reactive fast path for
7142    /// a single just-exited record does not go through this — see
7143    /// `drive_runtime_until_shutdown`, which resolves and spawns directly so
7144    /// it can capture the exact `SessionAddress` before `publish_control`
7145    /// clears it, rather than re-deriving it from (by then likely already
7146    /// `Dormant`) record state the way a rescan naturally would.
7147    ///
7148    /// Returns as soon as every eligible record's export has been *spawned*,
7149    /// not completed — each one runs on its own detached task (see
7150    /// `reconcile_context_pack_export_for_record`'s own doc comment for why:
7151    /// `NativeRuntime::tick()` has not even started its own loop yet at the
7152    /// point this is called from `NodeServer::run()`, so nothing could ever
7153    /// drain the history round trip if this awaited it inline here either).
7154    /// Best-effort throughout: never panics, no unwrap/expect; every failure
7155    /// is a silent skip for THIS record — an unusable source or a racing
7156    /// record is not surfaced to any caller.
7157    async fn reconcile_context_pack_exports(self: &Arc<Self>) {
7158        let candidates: Vec<(SessionRecordId, SessionAddress)> = self
7159            .session_records
7160            .lock()
7161            .unwrap_or_else(|poisoned| poisoned.into_inner())
7162            .records
7163            .values()
7164            .filter(|record| {
7165                record.state == ManagedSessionState::Live
7166                    && record.exported_context.is_none()
7167                    && record.provider_session.is_some()
7168            })
7169            .filter_map(|record| {
7170                record
7171                    .active_session
7172                    .clone()
7173                    .map(|session| (record.record_id.clone(), session))
7174            })
7175            .collect();
7176        for (record_id, session) in candidates {
7177            let shared = Arc::clone(self);
7178            tokio::spawn(async move {
7179                shared
7180                    .reconcile_context_pack_export_for_record(&record_id, session)
7181                    .await;
7182            });
7183        }
7184    }
7185
7186    /// Runs on its own detached task (`tokio::spawn`, see both call sites),
7187    /// never inline on `drive_runtime_until_shutdown`'s own task: the history
7188    /// round trip this performs (`discover_history`/`load_history`, via
7189    /// `export_context_pack_for_session_record`) can only ever settle once
7190    /// `NativeRuntime::tick()` runs again to drain the authority worker's
7191    /// completion — and `tick()` is exclusively owned by, and only ever
7192    /// called from, that same drive-loop task. Awaiting this inline there
7193    /// would starve `tick()` and this call could never observe its own
7194    /// command complete.
7195    ///
7196    /// Because this now genuinely races the drive loop's own
7197    /// `publish_control` (which downgrades this exact record from `Live` to
7198    /// `Dormant`, clearing `active_session`, for the very `Exited` event that
7199    /// triggered this reconcile), the record legitimately may already be
7200    /// `Dormant` by the time this reads it, or may transition mid-flight —
7201    /// `allow_clean_detachment: true` on the downstream export call is what
7202    /// tolerates exactly that transition (and only that transition; see
7203    /// `session_record_export_target_matches`).
7204    async fn reconcile_context_pack_export_for_record(
7205        &self,
7206        record_id: &SessionRecordId,
7207        session: SessionAddress,
7208    ) {
7209        let Ok(record) = self.record(record_id) else {
7210            return;
7211        };
7212        if !matches!(
7213            record.state,
7214            ManagedSessionState::Live | ManagedSessionState::Dormant
7215        ) || record.exported_context.is_some()
7216            || record.provider_session.is_none()
7217        {
7218            return;
7219        }
7220        // Pack bytes are already durably committed inside this call, via
7221        // commit_context_pack_for_session_record's own durable
7222        // write-through (context_pack_store) — no separate step here.
7223        // Unclean exits (non-zero/forced/no loadable history) are already
7224        // rejected by context_pack_source_status_is_usable inside
7225        // materialize_context_pack: this simply returns Err and is skipped.
7226        let Ok(receipt) = self
7227            .export_context_pack_for_session_record(&record.record_id, &session, true)
7228            .await
7229        else {
7230            return;
7231        };
7232        self.set_exported_context(&record.record_id, receipt);
7233    }
7234
7235    /// Records the record's most recent clean-exit pack. No-op (not an
7236    /// error) if the record disappeared, or if it already carries an
7237    /// `exported_context` — first-write-wins, matching the harness-side
7238    /// idempotent reconciliation that consumes this field.
7239    fn set_exported_context(&self, record_id: &SessionRecordId, receipt: ResolvedContextPackReceipt) {
7240        let transaction = self
7241            .state_transaction
7242            .lock()
7243            .unwrap_or_else(|poisoned| poisoned.into_inner());
7244        let (previous, updated) = {
7245            let mut records = self
7246                .session_records
7247                .lock()
7248                .unwrap_or_else(|poisoned| poisoned.into_inner());
7249            let Some(record) = records.records.get_mut(record_id) else {
7250                return;
7251            };
7252            if record.exported_context.is_some() {
7253                return;
7254            }
7255            let previous = record.clone();
7256            record.exported_context = Some(receipt);
7257            record.updated_at_unix_ms = unix_time_ms().max(record.updated_at_unix_ms);
7258            (previous, record.clone())
7259        };
7260        if self.persist_state_locked().is_err() {
7261            self.session_records
7262                .lock()
7263                .unwrap_or_else(|poisoned| poisoned.into_inner())
7264                .records
7265                .insert(record_id.clone(), previous);
7266            return;
7267        }
7268        drop(transaction);
7269        self.publish_record(updated);
7270    }
7271
7272    /// Resolves the managed record and its exact `SessionAddress` currently
7273    /// bound to `instance_id` at exactly `generation`, used by the reactive
7274    /// `Exited{0}` hook in `drive_runtime_until_shutdown` to target a single
7275    /// record without a full rescan. Mirrors the lookup already inlined in
7276    /// `publish_control`. Captured synchronously, before `publish_control`
7277    /// for the same event clears the record's own `active_session` — the
7278    /// caller passes the resolved address through to a detached
7279    /// reconciliation task rather than re-deriving it from the record later,
7280    /// after that race has necessarily already happened.
7281    fn managed_record_export_target_for_instance(
7282        &self,
7283        instance_id: AgentInstanceId,
7284        generation: SessionGeneration,
7285    ) -> Option<(SessionRecordId, SessionAddress)> {
7286        let bindings = self
7287            .session_bindings
7288            .lock()
7289            .unwrap_or_else(|poisoned| poisoned.into_inner());
7290        let binding = bindings.get(&instance_id).filter(|binding| binding.generation == generation)?;
7291        let record_id = binding.record_id.clone()?;
7292        Some((
7293            record_id,
7294            SessionAddress {
7295                workspace_id: binding.workspace_id.clone(),
7296                session: SessionKey { instance_id, generation },
7297            },
7298        ))
7299    }
7300
7301    fn mark_managed_recovery_required(
7302        &self,
7303        lease_id: &ManagedWorktreeLeaseId,
7304        failure_kind: ManagedWorktreeCleanupFailure,
7305    ) {
7306        let snapshot = {
7307            let mut registry = self.managed_worktrees.lock()
7308                .unwrap_or_else(|poisoned| poisoned.into_inner());
7309            let Some(lease) = registry.get_mut(lease_id) else { return };
7310            lease.state = ManagedWorktreeLeaseState::RecoveryRequired;
7311            lease.cleanup_failure = Some(failure_kind);
7312            lease.updated_at_unix_ms = unix_time_ms();
7313            lease.snapshot()
7314        };
7315        let _ = self.persist_state();
7316        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
7317    }
7318
7319    fn set_persistence_error(&self, error: Option<String>) {
7320        *self
7321            .persistence_error
7322            .write()
7323            .unwrap_or_else(|poisoned| poisoned.into_inner()) = error
7324            .map(|message| session_registry::sanitized_persistence_summary(&message));
7325    }
7326
7327    fn persist_state(&self) -> io::Result<()> {
7328        let _transaction = self
7329            .state_transaction
7330            .lock()
7331            .unwrap_or_else(|poisoned| poisoned.into_inner());
7332        self.persist_state_locked()
7333    }
7334
7335    fn persist_state_locked(&self) -> io::Result<()> {
7336        let workspaces = self
7337            .workspaces
7338            .read()
7339            .unwrap_or_else(|poisoned| poisoned.into_inner())
7340            .clone();
7341        let records = self
7342            .session_records
7343            .lock()
7344            .unwrap_or_else(|poisoned| poisoned.into_inner())
7345            .records
7346            .values()
7347            .cloned()
7348            .collect::<Vec<_>>();
7349        let mut managed = self.managed_worktrees
7350            .lock()
7351            .unwrap_or_else(|poisoned| poisoned.into_inner());
7352        managed.reattach_record_holders(
7353            &managed_worktree_record_holders(&records),
7354            unix_time_ms(),
7355        );
7356        let materializations = self
7357            .materializations
7358            .lock()
7359            .unwrap_or_else(|poisoned| poisoned.into_inner())
7360            .values()
7361            .cloned()
7362            .collect::<Vec<_>>();
7363        let managed_spawn_replays = self.managed_spawn_replays
7364            .lock()
7365            .unwrap_or_else(|poisoned| poisoned.into_inner())
7366            .values()
7367            .cloned()
7368            .collect::<Vec<_>>();
7369        let result = session_registry::save_v10(
7370            self.state_path.as_deref(),
7371            &self.node_id,
7372            &workspaces,
7373            &records,
7374            &managed.records(),
7375            &managed.tombstones(),
7376            &materializations,
7377            &managed_spawn_replays,
7378        );
7379        match &result {
7380            Ok(warning) => self.set_persistence_error(warning.clone()),
7381            Err(_) => self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned())),
7382        }
7383        result.map(|_| ())
7384    }
7385
7386    async fn begin_shutdown(&self) -> Result<(), NodeFailure> {
7387        let _mutation_guard = self.mutation_gate.lock().await;
7388        self.begin_shutdown_locked().await
7389    }
7390
7391    async fn begin_shutdown_locked(&self) -> Result<(), NodeFailure> {
7392        if self.shutdown.swap(true, Ordering::AcqRel) {
7393            return Ok(());
7394        }
7395        SessionLifecycleHook::on_shutdown(self);
7396        let deadline = Instant::now() + Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
7397        let mut first_error = None;
7398        for session in &self.handle.snapshot().sessions {
7399            let remaining = deadline.saturating_duration_since(Instant::now());
7400            if remaining.is_zero() {
7401                first_error.get_or_insert_with(|| {
7402                    failure(NodeFailureCode::BackendBusy, "stop-all dispatch exceeded its bounded deadline")
7403                });
7404                break;
7405            }
7406            if let Err(error) = self
7407                .dispatch_bounded(
7408                    ControlCommand::Stop {
7409                        instance_id: session.instance_id,
7410                        force: true,
7411                    },
7412                    remaining,
7413                )
7414                .await
7415            {
7416                first_error.get_or_insert(error);
7417            }
7418        }
7419        self.shutdown_notify.notify_waiters();
7420        match first_error {
7421            Some(error) => Err(error),
7422            None => Ok(()),
7423        }
7424    }
7425
7426    fn ensure_binding_capacity(&self) -> Result<(), NodeFailure> {
7427        let bindings = self
7428            .session_bindings
7429            .lock()
7430            .unwrap_or_else(|poisoned| poisoned.into_inner());
7431        if bindings.len() >= CONTROL_SESSIONS_MAX {
7432            Err(failure(
7433                NodeFailureCode::BackendBusy,
7434                "node session-binding capacity is full; remove an existing session first",
7435            ))
7436        } else {
7437            Ok(())
7438        }
7439    }
7440
7441    /// One-owner workspace/env collision guard: a live session on
7442    /// `workspace_id` with a different `environment_profile` (option
7443    /// equality on the resolved receipt, including Some vs None) blocks
7444    /// another spawn on that same cwd. Same profile may still share the
7445    /// workspace; distinct profiles need distinct workspace ids (managed
7446    /// worktree).
7447    fn ensure_workspace_environment_profile_compatible(
7448        &self,
7449        workspace_id: &WorkspaceId,
7450        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
7451    ) -> Result<(), NodeFailure> {
7452        let bindings = self
7453            .session_bindings
7454            .lock()
7455            .unwrap_or_else(|poisoned| poisoned.into_inner());
7456        for binding in bindings.values() {
7457            if &binding.workspace_id != workspace_id {
7458                continue;
7459            }
7460            if binding.environment_profile.as_ref() != environment_profile {
7461                return Err(failure(
7462                    NodeFailureCode::WorkspaceBusy,
7463                    "workspace already has a live session with a different environment profile; use a managed worktree for concurrent distinct ProviderHome/env bindings on one repo",
7464                ));
7465            }
7466        }
7467        Ok(())
7468    }
7469
7470    #[cfg(test)]
7471    fn bind_session(
7472        &self,
7473        instance_id: AgentInstanceId,
7474        workspace_id: WorkspaceId,
7475        generation: SessionGeneration,
7476    ) {
7477        self.bind_session_with_policy(
7478            &SessionAddress {
7479                workspace_id,
7480                session: SessionKey { instance_id, generation },
7481            },
7482            ProviderRuntimePolicy::raw_pty(),
7483            None,
7484        );
7485    }
7486
7487    #[cfg(test)]
7488    fn bind_session_with_policy(
7489        &self,
7490        address: &SessionAddress,
7491        runtime_policy: ProviderRuntimePolicy,
7492        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7493    ) {
7494        self.bind_session_with_materialization(
7495            address,
7496            runtime_policy,
7497            environment_profile,
7498            None,
7499            None,
7500            None,
7501        );
7502    }
7503
7504    fn bind_session_with_materialization(
7505        &self,
7506        address: &SessionAddress,
7507        runtime_policy: ProviderRuntimePolicy,
7508        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7509        bundle: Option<ResolvedBundleReceipt>,
7510        context: Option<ResolvedContextPackReceipt>,
7511        materialization_id: Option<MaterializationId>,
7512    ) {
7513        let mut bindings = self
7514            .session_bindings
7515            .lock()
7516            .unwrap_or_else(|poisoned| poisoned.into_inner());
7517        debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7518        debug_assert!(!bindings.contains_key(&address.session.instance_id));
7519        bindings.insert(address.session.instance_id, SessionBinding {
7520            workspace_id: address.workspace_id.clone(),
7521            generation: address.session.generation,
7522            runtime_policy,
7523            pending_resume: None,
7524            record_id: None,
7525            managed_worktree_lease_id: None,
7526            environment_profile,
7527            bundle,
7528            context,
7529            materialization_id,
7530        });
7531    }
7532
7533    #[cfg(test)]
7534    fn bind_managed_session(
7535        &self,
7536        address: &SessionAddress,
7537        record_id: SessionRecordId,
7538        runtime_policy: ProviderRuntimePolicy,
7539        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7540    ) {
7541        self.bind_managed_session_with_materialization(
7542            address,
7543            record_id,
7544            runtime_policy,
7545            environment_profile,
7546            None,
7547            None,
7548            None,
7549        );
7550    }
7551
7552    fn bind_managed_session_with_materialization(
7553        &self,
7554        address: &SessionAddress,
7555        record_id: SessionRecordId,
7556        runtime_policy: ProviderRuntimePolicy,
7557        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7558        bundle: Option<ResolvedBundleReceipt>,
7559        context: Option<ResolvedContextPackReceipt>,
7560        materialization_id: Option<MaterializationId>,
7561    ) {
7562        let mut bindings = self
7563            .session_bindings
7564            .lock()
7565            .unwrap_or_else(|poisoned| poisoned.into_inner());
7566        debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7567        debug_assert!(!bindings.contains_key(&address.session.instance_id));
7568        bindings.insert(
7569            address.session.instance_id,
7570            SessionBinding {
7571                workspace_id: address.workspace_id.clone(),
7572                generation: address.session.generation,
7573                runtime_policy,
7574                pending_resume: None,
7575                record_id: Some(record_id),
7576                managed_worktree_lease_id: None,
7577                environment_profile,
7578                bundle,
7579                context,
7580                materialization_id,
7581            },
7582        );
7583    }
7584
7585    #[cfg(test)]
7586    fn bind_spawn_session(
7587        &self,
7588        address: &SessionAddress,
7589        provider: AgentId,
7590        mode: SessionMode,
7591        runtime_policy: ProviderRuntimePolicy,
7592        record_policy: SpawnRecordPolicy,
7593        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7594    ) -> Result<Option<SessionRecordId>, NodeFailure> {
7595        self.bind_spawn_session_with_materialization(
7596            address,
7597            provider,
7598            mode,
7599            runtime_policy,
7600            record_policy,
7601            environment_profile,
7602            None,
7603            None,
7604            None,
7605        )
7606    }
7607
7608    fn bind_spawn_session_with_materialization(
7609        &self,
7610        address: &SessionAddress,
7611        provider: AgentId,
7612        mode: SessionMode,
7613        runtime_policy: ProviderRuntimePolicy,
7614        record_policy: SpawnRecordPolicy,
7615        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7616        bundle: Option<ResolvedBundleReceipt>,
7617        context: Option<ResolvedContextPackReceipt>,
7618        materialization_id: Option<MaterializationId>,
7619    ) -> Result<Option<SessionRecordId>, NodeFailure> {
7620        if record_policy == SpawnRecordPolicy::ProviderIdentityOnly
7621            && !runtime_policy.provider_session_identity
7622        {
7623            self.bind_session_with_materialization(
7624                address,
7625                runtime_policy,
7626                environment_profile,
7627                bundle,
7628                context,
7629                materialization_id,
7630            );
7631            return Ok(None);
7632        }
7633        let record_owns_lifecycle = runtime_policy.provider_session_identity;
7634        let record = self.new_record(
7635            address,
7636            provider,
7637            mode,
7638            active_record_state(runtime_policy.provider_session_identity, false),
7639            None,
7640            record_owns_lifecycle.then(|| environment_profile.clone()).flatten(),
7641            record_owns_lifecycle.then(|| bundle.clone()).flatten(),
7642            record_owns_lifecycle.then(|| context.clone()).flatten(),
7643        )?;
7644        let record_id = record.record_id.clone();
7645        let transaction = self
7646            .state_transaction
7647            .lock()
7648            .unwrap_or_else(|poisoned| poisoned.into_inner());
7649        self.insert_record(record.clone())?;
7650        let previous_materialization = if record_owns_lifecycle {
7651            if let Some(materialization_id) = materialization_id.as_ref() {
7652                let mut materializations = self
7653                    .materializations
7654                    .lock()
7655                    .unwrap_or_else(|poisoned| poisoned.into_inner());
7656                let Some(ownership) = materializations.get_mut(materialization_id) else {
7657                    drop(materializations);
7658                    self.remove_record_memory(&record_id);
7659                    return Err(failure(
7660                        NodeFailureCode::BackendOperationFailed,
7661                        "session environment ownership disappeared before binding",
7662                    ));
7663                };
7664                let previous = ownership.clone();
7665                if ownership
7666                    .transfer_to_record(record_id.clone(), unix_time_ms())
7667                    .is_err()
7668                {
7669                    drop(materializations);
7670                    self.remove_record_memory(&record_id);
7671                    return Err(failure(
7672                        NodeFailureCode::BackendOperationFailed,
7673                        "session environment ownership transfer failed",
7674                    ));
7675                }
7676                Some(previous)
7677            } else {
7678                None
7679            }
7680        } else {
7681            None
7682        };
7683        self.bind_managed_session_with_materialization(
7684            address,
7685            record_id.clone(),
7686            runtime_policy,
7687            environment_profile,
7688            bundle,
7689            context,
7690            materialization_id.clone(),
7691        );
7692        if let Err(error) = self.persist_state_locked() {
7693            self.remove_binding(address);
7694            self.remove_record_memory(&record_id);
7695            if let (Some(materialization_id), Some(previous)) =
7696                (materialization_id.as_ref(), previous_materialization)
7697            {
7698                self.materializations
7699                    .lock()
7700                    .unwrap_or_else(|poisoned| poisoned.into_inner())
7701                    .insert(materialization_id.clone(), previous);
7702            }
7703            return Err(persistence_failure(error));
7704        }
7705        drop(transaction);
7706        self.publish_record(record);
7707        Ok(Some(record_id))
7708    }
7709
7710    fn allocate_record_id(&self) -> Result<SessionRecordId, NodeFailure> {
7711        for _ in 0..8 {
7712            let bytes = random_nonce().map_err(|error| {
7713                failure(
7714                    NodeFailureCode::BackendOperationFailed,
7715                    &format!("session record identity generation failed: {error}"),
7716                )
7717            })?;
7718            let mut value = String::with_capacity(27);
7719            value.push_str("sr-");
7720            for byte in &bytes[..12] {
7721                use std::fmt::Write as _;
7722                let _ = write!(&mut value, "{byte:02x}");
7723            }
7724            let record_id = SessionRecordId::new(value).map_err(|error| {
7725                failure(NodeFailureCode::BackendOperationFailed, &error.to_string())
7726            })?;
7727            let records = self
7728                .session_records
7729                .lock()
7730                .unwrap_or_else(|poisoned| poisoned.into_inner());
7731            if !records.records.contains_key(&record_id) {
7732                return Ok(record_id);
7733            }
7734        }
7735        Err(failure(
7736            NodeFailureCode::BackendBusy,
7737            "could not allocate a unique session record ID",
7738        ))
7739    }
7740
7741    fn allocate_task_id(&self) -> Result<TaskId, NodeFailure> {
7742        for _ in 0..8 {
7743            let bytes = random_nonce().map_err(|error| {
7744                failure(
7745                    NodeFailureCode::BackendOperationFailed,
7746                    &format!("task identity generation failed: {error}"),
7747                )
7748            })?;
7749            let mut nonce = [0_u8; 12];
7750            nonce.copy_from_slice(&bytes[..12]);
7751            let task_id = TaskId::from_nonce(nonce);
7752            let records = self
7753                .session_records
7754                .lock()
7755                .unwrap_or_else(|poisoned| poisoned.into_inner());
7756            if !records.records.values().any(|record| {
7757                record.task_binding.as_ref().and_then(|binding| binding.task_id.as_ref())
7758                    == Some(&task_id)
7759            }) {
7760                return Ok(task_id);
7761            }
7762        }
7763        Err(failure(
7764            NodeFailureCode::BackendBusy,
7765            "could not allocate a unique task ID",
7766        ))
7767    }
7768
7769    fn default_record_name(&self, provider: &AgentId) -> String {
7770        let records = self
7771            .session_records
7772            .lock()
7773            .unwrap_or_else(|poisoned| poisoned.into_inner());
7774        let ordinal = records
7775            .records
7776            .values()
7777            .filter(|record| &record.provider == provider)
7778            .count()
7779            .saturating_add(1);
7780        format!("{} #{ordinal}", provider.as_str())
7781    }
7782
7783    fn new_record(
7784        &self,
7785        address: &SessionAddress,
7786        provider: AgentId,
7787        mode: SessionMode,
7788        state: ManagedSessionState,
7789        provider_session: Option<gate4agent_types::ProviderSessionIdentity>,
7790        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7791        bundle: Option<ResolvedBundleReceipt>,
7792        context: Option<ResolvedContextPackReceipt>,
7793    ) -> Result<ManagedSessionRecord, NodeFailure> {
7794        let canonical_root = self.workspace_root(&address.workspace_id)?;
7795        let now = unix_time_ms();
7796        Ok(ManagedSessionRecord {
7797            record_id: self.allocate_record_id()?,
7798            display_name: self.default_record_name(&provider),
7799            provider,
7800            mode,
7801            state,
7802            workspace_id: address.workspace_id.clone(),
7803            canonical_root: opaque_windows_path(canonical_root),
7804            provider_session,
7805            active_session: Some(address.clone()),
7806            environment_profile,
7807            bundle,
7808            context_id: context.as_ref().map(|receipt| receipt.id.clone()),
7809            context,
7810            exported_context: None,
7811            task_binding: None,
7812            created_at_unix_ms: now,
7813            updated_at_unix_ms: now,
7814            last_error: None,
7815        })
7816    }
7817
7818    fn insert_record(&self, record: ManagedSessionRecord) -> Result<(), NodeFailure> {
7819        let mut records = self
7820            .session_records
7821            .lock()
7822            .unwrap_or_else(|poisoned| poisoned.into_inner());
7823        if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
7824            return Err(failure(
7825                NodeFailureCode::BackendBusy,
7826                "managed session record capacity is full; forget an inactive record first",
7827            ));
7828        }
7829        if let Some(identity) = record.provider_session.as_ref() {
7830            if records.records.values().any(|candidate| {
7831                candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
7832                    session_registry::same_provider_session(
7833                        &candidate.provider,
7834                        candidate_identity,
7835                        &record.provider,
7836                        identity,
7837                    )
7838                })
7839            }) {
7840                return Err(failure(
7841                    NodeFailureCode::SessionRecordConflict,
7842                    "provider session is already represented by another managed record",
7843                ));
7844            }
7845        }
7846        match records.records.entry(record.record_id.clone()) {
7847            std::collections::btree_map::Entry::Vacant(entry) => {
7848                entry.insert(record);
7849            }
7850            std::collections::btree_map::Entry::Occupied(_) => {
7851                return Err(failure(
7852                    NodeFailureCode::SessionRecordConflict,
7853                    "session record ID collision",
7854                ));
7855            }
7856        }
7857        Ok(())
7858    }
7859
7860    fn record(&self, record_id: &SessionRecordId) -> Result<ManagedSessionRecord, NodeFailure> {
7861        self.session_records
7862            .lock()
7863            .unwrap_or_else(|poisoned| poisoned.into_inner())
7864            .records
7865            .get(record_id)
7866            .cloned()
7867            .ok_or_else(|| {
7868                failure(
7869                    NodeFailureCode::UnknownSessionRecord,
7870                    "managed session record does not exist",
7871                )
7872            })
7873    }
7874
7875    fn revalidate_session_record_preview(
7876        &self,
7877        expected: &ManagedSessionRecord,
7878        identity: &ProviderSessionIdentity,
7879    ) -> Result<(), NodeFailure> {
7880        let current = self.record(&expected.record_id)?;
7881        let current_root = self.workspace_root(&current.workspace_id)?;
7882        if current.provider != expected.provider
7883            || current.workspace_id != expected.workspace_id
7884            || current.canonical_root != expected.canonical_root
7885            || current.provider_session.as_ref() != Some(identity)
7886            || !platform::roots_equal(
7887                &current_root,
7888                windows_path_text(&current.canonical_root),
7889            )
7890        {
7891            return Err(failure(
7892                NodeFailureCode::SessionRecordConflict,
7893                "managed session changed while its preview was loading",
7894            ));
7895        }
7896        Ok(())
7897    }
7898
7899    fn revalidate_session_record_context_export(
7900        &self,
7901        expected: &ManagedSessionRecord,
7902        identity: Option<&ProviderSessionIdentity>,
7903        session: &SessionAddress,
7904        allow_clean_detachment: bool,
7905    ) -> Result<(), NodeFailure> {
7906        let current = self.record(&expected.record_id)?;
7907        let current_root = self.workspace_root(&current.workspace_id)?;
7908        let snapshot = self.internal_session_snapshot(session)?;
7909        let binding = self
7910            .session_bindings
7911            .lock()
7912            .unwrap_or_else(|poisoned| poisoned.into_inner())
7913            .get(&session.session.instance_id)
7914            .cloned()
7915            .ok_or_else(|| {
7916                failure(
7917                    NodeFailureCode::SessionRecordConflict,
7918                    "managed session runtime binding disappeared during context export",
7919                )
7920            })?;
7921        if !session_record_context_export_binding_is_exact(
7922            expected,
7923            &current,
7924            identity,
7925            session,
7926            &binding,
7927            &snapshot.agent_id,
7928            &current_root,
7929            allow_clean_detachment,
7930        ) || !session_record_context_export_source_is_usable(
7931            &current,
7932            &snapshot.status,
7933            allow_clean_detachment,
7934        ) {
7935            return Err(failure(
7936                NodeFailureCode::SessionRecordConflict,
7937                "managed session binding changed during context export",
7938            ));
7939        }
7940        Ok(())
7941    }
7942
7943    fn revalidate_loaded_history(
7944        &self,
7945        session: &SessionAddress,
7946        candidate_id: &str,
7947        loaded: &HistorySessionRecord,
7948    ) -> Result<(), NodeFailure> {
7949        let snapshot = self.internal_session_snapshot(session)?;
7950        if snapshot.history.pending.is_some()
7951            || snapshot.history.loaded_candidate_id.as_deref() != Some(candidate_id)
7952            || snapshot.history.loaded.as_ref() != Some(loaded)
7953        {
7954            return Err(failure(
7955                NodeFailureCode::SessionRecordConflict,
7956                "managed session history changed during context export",
7957            ));
7958        }
7959        Ok(())
7960    }
7961
7962    fn remove_record_memory(&self, record_id: &SessionRecordId) -> Option<ManagedSessionRecord> {
7963        self.session_records
7964            .lock()
7965            .unwrap_or_else(|poisoned| poisoned.into_inner())
7966            .records
7967            .remove(record_id)
7968    }
7969
7970    fn discard_record(&self, record_id: &SessionRecordId) -> Result<(), NodeFailure> {
7971        if self.destroy_record_materialization(record_id)? {
7972            self.publish(NodeEvent::SessionRecordRemoved {
7973                record_id: record_id.clone(),
7974            });
7975            return Ok(());
7976        }
7977        let transaction = self
7978            .state_transaction
7979            .lock()
7980            .unwrap_or_else(|poisoned| poisoned.into_inner());
7981        let Some(removed) = self.remove_record_memory(record_id) else {
7982            return Ok(());
7983        };
7984        if let Err(error) = self.persist_state_locked() {
7985            let _ = self.insert_record(removed);
7986            return Err(persistence_failure(error));
7987        }
7988        drop(transaction);
7989        self.publish(NodeEvent::SessionRecordRemoved {
7990            record_id: record_id.clone(),
7991        });
7992        Ok(())
7993    }
7994
7995    fn publish_record(&self, record: ManagedSessionRecord) {
7996        self.publish(NodeEvent::SessionRecordUpserted { record });
7997    }
7998
7999    fn mark_record_error(
8000        &self,
8001        record_id: &SessionRecordId,
8002        message: &str,
8003    ) -> Result<(), NodeFailure> {
8004        let transaction = self
8005            .state_transaction
8006            .lock()
8007            .unwrap_or_else(|poisoned| poisoned.into_inner());
8008        let previous = {
8009            let mut records = self
8010                .session_records
8011                .lock()
8012                .unwrap_or_else(|poisoned| poisoned.into_inner());
8013            let Some(record) = records.records.get_mut(record_id) else {
8014                return Ok(());
8015            };
8016            let previous = record.clone();
8017            record.state = ManagedSessionState::Unavailable;
8018            record.last_error = Some(session_registry::sanitized_record_error_summary(message));
8019            record.updated_at_unix_ms = unix_time_ms();
8020            previous
8021        };
8022        if let Err(error) = self.persist_state_locked() {
8023            self.session_records
8024                .lock()
8025                .unwrap_or_else(|poisoned| poisoned.into_inner())
8026                .records
8027                .insert(record_id.clone(), previous);
8028            return Err(persistence_failure(error));
8029        }
8030        let record = self.record(record_id)?;
8031        drop(transaction);
8032        self.publish_record(record);
8033        Ok(())
8034    }
8035
8036    fn rename_session_record(
8037        &self,
8038        record_id: &SessionRecordId,
8039        display_name: String,
8040    ) -> Result<ManagedSessionRecord, NodeFailure> {
8041        validate_display_name(&display_name)
8042            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8043        let _transaction = self
8044            .state_transaction
8045            .lock()
8046            .unwrap_or_else(|poisoned| poisoned.into_inner());
8047        let previous = {
8048            let mut records = self
8049                .session_records
8050                .lock()
8051                .unwrap_or_else(|poisoned| poisoned.into_inner());
8052            let record = records.records.get_mut(record_id).ok_or_else(|| {
8053                failure(
8054                    NodeFailureCode::UnknownSessionRecord,
8055                    "managed session record does not exist",
8056                )
8057            })?;
8058            let previous = record.clone();
8059            record.display_name = display_name;
8060            record.updated_at_unix_ms = unix_time_ms();
8061            previous
8062        };
8063        if let Err(error) = self.persist_state_locked() {
8064            self.session_records
8065                .lock()
8066                .unwrap_or_else(|poisoned| poisoned.into_inner())
8067                .records
8068                .insert(record_id.clone(), previous);
8069            return Err(persistence_failure(error));
8070        }
8071        let updated = self.record(record_id)?;
8072        self.publish_record(updated.clone());
8073        Ok(updated)
8074    }
8075
8076    fn set_session_task(
8077        &self,
8078        record_id: &SessionRecordId,
8079        expected_revision: u64,
8080        target: SessionTaskTargetV1,
8081    ) -> Result<ManagedSessionRecord, NodeFailure> {
8082        let transaction = self
8083            .state_transaction
8084            .lock()
8085            .unwrap_or_else(|poisoned| poisoned.into_inner());
8086        let current = self.record(record_id)?;
8087        let current_task_id = current
8088            .task_binding
8089            .as_ref()
8090            .and_then(|binding| binding.task_id.as_ref());
8091        let current_revision = current
8092            .task_binding
8093            .as_ref()
8094            .map_or(0, |binding| binding.revision);
8095        if expected_revision != current_revision {
8096            return Err(failure(
8097                NodeFailureCode::SessionRecordConflict,
8098                "managed session task binding revision changed",
8099            ));
8100        }
8101        let exact = match &target {
8102            SessionTaskTargetV1::Existing { task_id } => current_task_id == Some(task_id),
8103            SessionTaskTargetV1::Clear => current_task_id.is_none(),
8104            SessionTaskTargetV1::New => false,
8105        };
8106        if exact {
8107            return Ok(current);
8108        }
8109        let revision = current_revision.checked_add(1).ok_or_else(|| {
8110            failure(
8111                NodeFailureCode::SessionRecordConflict,
8112                "managed session task binding revision is exhausted",
8113            )
8114        })?;
8115        let task_id = match target {
8116            SessionTaskTargetV1::New => Some(self.allocate_task_id()?),
8117            SessionTaskTargetV1::Existing { task_id } => Some(task_id),
8118            SessionTaskTargetV1::Clear => None,
8119        };
8120        let changed_at_unix_ms = unix_time_ms()
8121            .max(current.updated_at_unix_ms.checked_add(1).ok_or_else(|| {
8122                failure(
8123                    NodeFailureCode::SessionRecordConflict,
8124                    "managed session record timestamp is exhausted",
8125                )
8126            })?);
8127        let updated = {
8128            let mut records = self
8129                .session_records
8130                .lock()
8131                .unwrap_or_else(|poisoned| poisoned.into_inner());
8132            let record = records.records.get_mut(record_id).ok_or_else(|| {
8133                failure(
8134                    NodeFailureCode::UnknownSessionRecord,
8135                    "managed session record does not exist",
8136                )
8137            })?;
8138            record.task_binding = Some(SessionTaskBindingV1 {
8139                revision,
8140                task_id,
8141                changed_at_unix_ms,
8142            });
8143            record.updated_at_unix_ms = changed_at_unix_ms;
8144            record.clone()
8145        };
8146        if let Err(error) = self.persist_state_locked() {
8147            self.session_records
8148                .lock()
8149                .unwrap_or_else(|poisoned| poisoned.into_inner())
8150                .records
8151                .insert(record_id.clone(), current);
8152            return Err(persistence_failure(error));
8153        }
8154        drop(transaction);
8155        self.publish_record(updated.clone());
8156        Ok(updated)
8157    }
8158
8159    fn index_provider_session(
8160        &self,
8161        workspace_id: WorkspaceId,
8162        provider: AgentId,
8163        identity: ProviderSessionIdentity,
8164        display_name: String,
8165    ) -> Result<ManagedSessionRecord, NodeFailure> {
8166        self.index_provider_session_with_policy(
8167            workspace_id,
8168            provider,
8169            identity,
8170            display_name,
8171            false,
8172        )
8173    }
8174
8175    fn index_provider_session_with_policy(
8176        &self,
8177        workspace_id: WorkspaceId,
8178        provider: AgentId,
8179        identity: ProviderSessionIdentity,
8180        display_name: String,
8181        allow_validated_transcript_path: bool,
8182    ) -> Result<ManagedSessionRecord, NodeFailure> {
8183        validate_display_name(&display_name)
8184            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8185        identity
8186            .validate()
8187            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8188        if identity.transcript_path.is_some() && !allow_validated_transcript_path {
8189            return Err(failure(
8190                NodeFailureCode::InvalidRequest,
8191                "provider session reference index v1 does not accept transcript paths",
8192            ));
8193        }
8194        let canonical_root = self.workspace_root(&workspace_id)?;
8195        if !self.enabled_providers.contains(&provider) {
8196            return Err(failure(
8197                NodeFailureCode::InvalidRequest,
8198                "provider is not enabled on this node",
8199            ));
8200        }
8201
8202        let transaction = self
8203            .state_transaction
8204            .lock()
8205            .unwrap_or_else(|poisoned| poisoned.into_inner());
8206        {
8207            let records = self
8208                .session_records
8209                .lock()
8210                .unwrap_or_else(|poisoned| poisoned.into_inner());
8211            if let Some(existing) = records.records.values().find(|candidate| {
8212                candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8213                    candidate_identity.key == identity.key && candidate_identity.id == identity.id
8214                })
8215            }) {
8216                if existing.provider == provider && existing.workspace_id == workspace_id {
8217                    return Ok(existing.clone());
8218                }
8219                return Err(failure(
8220                    NodeFailureCode::SessionRecordConflict,
8221                    "provider session reference conflicts with another provider or workspace",
8222                ));
8223            }
8224            if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
8225                return Err(failure(
8226                    NodeFailureCode::BackendBusy,
8227                    "managed session record capacity is full; forget an inactive record first",
8228                ));
8229            }
8230        }
8231
8232        let now = unix_time_ms();
8233        let record = ManagedSessionRecord {
8234            record_id: self.allocate_record_id()?,
8235            display_name,
8236            provider,
8237            mode: SessionMode::Pty,
8238            state: ManagedSessionState::Dormant,
8239            workspace_id,
8240            canonical_root: opaque_windows_path(canonical_root),
8241            provider_session: Some(identity),
8242            active_session: None,
8243            environment_profile: None,
8244            bundle: None,
8245            context_id: None,
8246            context: None,
8247            exported_context: None,
8248            task_binding: None,
8249            created_at_unix_ms: now,
8250            updated_at_unix_ms: now,
8251            last_error: None,
8252        };
8253        self.insert_record(record.clone())?;
8254        if let Err(error) = self.persist_state_locked() {
8255            self.remove_record_memory(&record.record_id);
8256            return Err(persistence_failure(error));
8257        }
8258        drop(transaction);
8259        self.publish_record(record.clone());
8260        Ok(record)
8261    }
8262
8263    fn bound_address_for_record(&self, record_id: &SessionRecordId) -> Option<SessionAddress> {
8264        self.session_bindings
8265            .lock()
8266            .unwrap_or_else(|poisoned| poisoned.into_inner())
8267            .iter()
8268            .find_map(|(instance_id, binding)| {
8269                (binding.record_id.as_ref() == Some(record_id)).then(|| SessionAddress {
8270                    workspace_id: binding.workspace_id.clone(),
8271                    session: SessionKey {
8272                        instance_id: *instance_id,
8273                        generation: binding.generation,
8274                    },
8275                })
8276            })
8277    }
8278
8279    async fn forget_session_record(
8280        &self,
8281        record_id: &SessionRecordId,
8282    ) -> Result<(), NodeFailure> {
8283        let record = self.record(record_id)?;
8284        if record.active_session.is_some()
8285            || matches!(
8286                record.state,
8287                ManagedSessionState::Live | ManagedSessionState::IdentityPending
8288            )
8289        {
8290            return Err(failure(
8291                NodeFailureCode::SessionRecordBusy,
8292                "stop the managed session before forgetting it",
8293            ));
8294        }
8295        if let Some(address) = self.bound_address_for_record(record_id) {
8296            self.remove_session(&address).await?;
8297        }
8298        self.remove_managed_session_record(record_id, &record.workspace_id).await
8299    }
8300
8301    /// Removes one managed session record from durable state and notifies
8302    /// every listener -- the shared transaction body behind both
8303    /// `forget_session_record`'s operator-driven path (busy-checked above
8304    /// it, and clears any still-live session binding before calling in
8305    /// here) and the retention sweep (`retire_unavailable_session_records`,
8306    /// which only ever selects `ManagedSessionState::Unavailable` records
8307    /// that are already known never-busy and never bound, so it calls
8308    /// straight into this with neither check). Cleans a managed-worktree
8309    /// lease if the record held one, removes the record's environment
8310    /// materialization (or, absent one, removes it from memory directly),
8311    /// persists under `state_transaction` with rollback on persist failure,
8312    /// then publishes `NodeEvent::SessionRecordRemoved` so c2's own
8313    /// legacy-provider bookkeeping (`record_is_legacy`) stays in sync
8314    /// exactly as it does for an operator-driven forget.
8315    async fn remove_managed_session_record(
8316        &self,
8317        record_id: &SessionRecordId,
8318        workspace_id: &WorkspaceId,
8319    ) -> Result<(), NodeFailure> {
8320        let managed_lease_id = self.managed_worktrees.lock()
8321            .unwrap_or_else(|poisoned| poisoned.into_inner())
8322            .lease_for_workspace(workspace_id);
8323        if !self.destroy_record_materialization(record_id)? {
8324            let transaction = self
8325                .state_transaction
8326                .lock()
8327                .unwrap_or_else(|poisoned| poisoned.into_inner());
8328            let removed = self.remove_record_memory(record_id).ok_or_else(|| {
8329                failure(
8330                    NodeFailureCode::UnknownSessionRecord,
8331                    "managed session record does not exist",
8332                )
8333            })?;
8334            if let Err(error) = self.persist_state_locked() {
8335                let _ = self.insert_record(removed);
8336                return Err(persistence_failure(error));
8337            }
8338            drop(transaction);
8339        }
8340        self.publish(NodeEvent::SessionRecordRemoved {
8341            record_id: record_id.clone(),
8342        });
8343        if let Some(lease_id) = managed_lease_id {
8344            if let Some(snapshot) = self.managed_worktrees.lock()
8345                .unwrap_or_else(|poisoned| poisoned.into_inner())
8346                .get(&lease_id)
8347                .map(ManagedWorktreeLeaseRecord::snapshot)
8348            {
8349                self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
8350            }
8351            let _ = self.cleanup_managed_worktree(&lease_id, false).await;
8352        }
8353        Ok(())
8354    }
8355
8356    /// Retires `ManagedSessionState::Unavailable` records against the
8357    /// node's configured `session_record_retention` age/keep-N policy.
8358    /// Never touches `Live`, `IdentityPending`, or `Dormant` records --
8359    /// `Dormant` is resumable and folding it into this sweep would delete
8360    /// records a caller can still act on. Both policy fields default to
8361    /// `0` (disabled); when both are `0` this is a no-op. When both are
8362    /// set, a record is retired only if it fails BOTH tests (older than
8363    /// `age_ms` AND beyond the newest `keep_per_workspace` in its
8364    /// workspace); when only one is set, that one alone decides. Called
8365    /// once at startup (alongside `reconcile_managed_worktrees`) and then
8366    /// on a `SESSION_RECORD_RETENTION_SWEEP_PERIOD` cadence from the drive
8367    /// loop -- see both call sites.
8368    async fn retire_unavailable_session_records(&self) {
8369        let policy = self.session_record_retention;
8370        let age_enabled = policy.age_ms > 0;
8371        let keep_enabled = policy.keep_per_workspace > 0;
8372        if !age_enabled && !keep_enabled {
8373            return;
8374        }
8375        let now = unix_time_ms();
8376        let candidates: Vec<(SessionRecordId, WorkspaceId, AgentId, u64)> = {
8377            self.session_records
8378                .lock()
8379                .unwrap_or_else(|poisoned| poisoned.into_inner())
8380                .records
8381                .values()
8382                .filter(|record| record.state == ManagedSessionState::Unavailable)
8383                .map(|record| {
8384                    (
8385                        record.record_id.clone(),
8386                        record.workspace_id.clone(),
8387                        record.provider.clone(),
8388                        record.updated_at_unix_ms,
8389                    )
8390                })
8391                .collect()
8392        };
8393        if candidates.is_empty() {
8394            return;
8395        }
8396        let mut by_workspace: BTreeMap<WorkspaceId, Vec<(SessionRecordId, AgentId, u64)>> =
8397            BTreeMap::new();
8398        for (record_id, workspace_id, provider, updated_at_unix_ms) in candidates {
8399            by_workspace
8400                .entry(workspace_id)
8401                .or_default()
8402                .push((record_id, provider, updated_at_unix_ms));
8403        }
8404        let reason = if age_enabled && keep_enabled {
8405            "age+keep-n"
8406        } else if age_enabled {
8407            "age"
8408        } else {
8409            "keep-n"
8410        };
8411        let mut retired: u64 = 0;
8412        for (workspace_id, mut records) in by_workspace {
8413            // Newest `updated_at_unix_ms` first, so index `>=
8414            // keep_per_workspace` names exactly "beyond the newest N".
8415            records.sort_by(|left, right| right.2.cmp(&left.2));
8416            for (index, (record_id, provider, updated_at_unix_ms)) in
8417                records.into_iter().enumerate()
8418            {
8419                let record_age_ms = now.saturating_sub(updated_at_unix_ms);
8420                let age_hit = age_enabled && record_age_ms >= policy.age_ms;
8421                let keep_hit = keep_enabled && index >= policy.keep_per_workspace as usize;
8422                let should_retire = if age_enabled && keep_enabled {
8423                    age_hit && keep_hit
8424                } else {
8425                    age_hit || keep_hit
8426                };
8427                if !should_retire {
8428                    continue;
8429                }
8430                match self.remove_managed_session_record(&record_id, &workspace_id).await {
8431                    Ok(()) => {
8432                        self.retired_records_total.fetch_add(1, Ordering::Relaxed);
8433                        retired += 1;
8434                        tracing::info!(
8435                            node_id = %self.node_id,
8436                            record_id = %record_id,
8437                            workspace_id = %workspace_id,
8438                            provider = %provider,
8439                            age_ms = record_age_ms,
8440                            reason,
8441                            "managed session record retired"
8442                        );
8443                    }
8444                    Err(error) => tracing::warn!(
8445                        node_id = %self.node_id,
8446                        record_id = %record_id,
8447                        workspace_id = %workspace_id,
8448                        code = ?error.code,
8449                        cause = %error.message,
8450                        "session record retention sweep failed to retire a record"
8451                    ),
8452                }
8453            }
8454        }
8455        if retired > 0 {
8456            let remaining_unavailable = self
8457                .session_records
8458                .lock()
8459                .unwrap_or_else(|poisoned| poisoned.into_inner())
8460                .records
8461                .values()
8462                .filter(|record| record.state == ManagedSessionState::Unavailable)
8463                .count();
8464            tracing::info!(
8465                node_id = %self.node_id,
8466                retired,
8467                remaining_unavailable,
8468                "session record retention sweep completed"
8469            );
8470        }
8471    }
8472
8473    async fn resume_session_record(
8474        &self,
8475        record_id: &SessionRecordId,
8476        terminal_size: gate4agent_types::TerminalSize,
8477        initial_prompt: Option<String>,
8478    ) -> Result<(ManagedSessionRecord, SessionAddress), NodeFailure> {
8479        let record = self.record(record_id)?;
8480        if record.active_session.is_some()
8481            || matches!(
8482                record.state,
8483                ManagedSessionState::Live | ManagedSessionState::IdentityPending
8484            )
8485        {
8486            return Err(failure(
8487                NodeFailureCode::SessionRecordBusy,
8488                "managed session already has a live runtime binding",
8489            ));
8490        }
8491        let identity = record.provider_session.clone().ok_or_else(|| {
8492            failure(
8493                NodeFailureCode::SessionRecordNotResumable,
8494                "managed session has no verified provider session identity",
8495            )
8496        })?;
8497        let working_directory = self.workspace_root(&record.workspace_id)?;
8498        if !platform::roots_equal(
8499            &working_directory,
8500            windows_path_text(&record.canonical_root),
8501        ) {
8502            return Err(failure(
8503                NodeFailureCode::SessionRecordConflict,
8504                "managed session workspace root changed; refusing to resume in another directory",
8505            ));
8506        }
8507        {
8508            let records = self
8509                .session_records
8510                .lock()
8511                .unwrap_or_else(|poisoned| poisoned.into_inner());
8512            if records.records.values().any(|candidate| {
8513                candidate.record_id != *record_id
8514                    && candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8515                        session_registry::same_provider_session(
8516                            &candidate.provider,
8517                            candidate_identity,
8518                            &record.provider,
8519                            &identity,
8520                        )
8521                    })
8522                    && (candidate.active_session.is_some()
8523                        || matches!(
8524                            candidate.state,
8525                            ManagedSessionState::Live | ManagedSessionState::IdentityPending
8526                        ))
8527            }) {
8528                return Err(failure(
8529                    NodeFailureCode::SessionRecordConflict,
8530                    "provider session is already active through another managed record",
8531                ));
8532            }
8533        }
8534        let runtime_requirement = if initial_prompt.is_some() {
8535            ProviderRuntimeRequirement::ResumeWithPrompt
8536        } else {
8537            ProviderRuntimeRequirement::Resume
8538        };
8539        let runtime_policy = self.admit_provider_runtime(
8540            &record.provider,
8541            runtime_requirement,
8542        )
8543        .await?;
8544        let request = ResumeLaunchRequest {
8545            working_directory,
8546            terminal_size,
8547            initial_prompt,
8548        };
8549        request
8550            .validate()
8551            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8552        self.ensure_binding_capacity()?;
8553        let resolved_materialization = match self.resolve_record_materialization(
8554            record_id,
8555            &record.provider,
8556            record.mode,
8557            record.environment_profile.as_ref(),
8558            record.bundle.as_ref(),
8559            record.context.as_ref(),
8560        ) {
8561            Ok(materialization) => materialization,
8562            Err(error) => {
8563                let label = match error.code {
8564                    NodeFailureCode::UnknownBundle => "bundle-unavailable",
8565                    NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
8566                    NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
8567                    _ => "environment-profile-unavailable",
8568                };
8569                self.mark_record_error(record_id, label)?;
8570                return Err(error);
8571            }
8572        };
8573        let (materialization_id, environment_overlay) = match resolved_materialization {
8574            Some((id, overlay)) => (Some(id), overlay),
8575            None => (None, None),
8576        };
8577        let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
8578        let mut environment_selection = match SpawnPreparer::select_environment(self, 
8579            instance_id,
8580            &record.provider,
8581            record.mode,
8582            record.environment_profile.as_ref(),
8583        ) {
8584            Ok(selection) => selection,
8585            Err(error) => {
8586                self.mark_record_error(record_id, "environment-profile-unavailable")?;
8587                return Err(error);
8588            }
8589        };
8590        let mut instance_overlay = environment_overlay
8591            .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
8592            .transpose()?
8593            .flatten();
8594        if let Some(stale_address) = self.bound_address_for_record(record_id) {
8595            self.remove_session(&stale_address).await?;
8596        }
8597        let address = SessionAddress {
8598            workspace_id: record.workspace_id.clone(),
8599            session: SessionKey {
8600                instance_id,
8601                generation: SessionGeneration(1),
8602            },
8603        };
8604        let transaction = self
8605            .state_transaction
8606            .lock()
8607            .unwrap_or_else(|poisoned| poisoned.into_inner());
8608        let current = self.record(record_id)?;
8609        if current.active_session.is_some()
8610            || matches!(
8611                current.state,
8612                ManagedSessionState::Live | ManagedSessionState::IdentityPending
8613            )
8614        {
8615            return Err(failure(
8616                NodeFailureCode::SessionRecordBusy,
8617                "managed session became active while resume was being prepared",
8618            ));
8619        }
8620        self.bind_managed_session_with_materialization(
8621            &address,
8622            record_id.clone(),
8623            runtime_policy,
8624            record.environment_profile.clone(),
8625            record.bundle.clone(),
8626            record.context.clone(),
8627            materialization_id,
8628        );
8629        if let Some(selection) = environment_selection.take() {
8630            selection.retain();
8631        }
8632        if let Some(overlay) = instance_overlay.take() {
8633            overlay.retain();
8634        }
8635        drop(transaction);
8636
8637        let transport = match record.mode {
8638            SessionMode::Pty => TransportKind::Pty,
8639            SessionMode::Inline => TransportKind::Pipe,
8640            SessionMode::Acp => TransportKind::Acp,
8641        };
8642        let agent_id = record.provider.clone();
8643        let dispatch_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
8644        if let Err(error) = self
8645            .dispatch_bounded(
8646                ControlCommand::Register {
8647                    instance_id,
8648                    agent_id,
8649                    transport,
8650                },
8651                dispatch_timeout,
8652            )
8653            .await
8654        {
8655            if let Some(binding) = self.remove_binding(&address) {
8656                SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
8657            }
8658            return Err(error);
8659        }
8660        let command = self.prepare_command(ControlCommand::Resume {
8661            instance_id,
8662            target: ResumeTarget::ProviderSession { identity },
8663            runtime_policy,
8664            request,
8665        });
8666        let started_after = self.current_sequence();
8667        if let Err(error) = self.arm_resume(&address, command.id, runtime_policy) {
8668            self.rollback_spawn(&address, dispatch_timeout).await?;
8669            return Err(error);
8670        }
8671        if let Err(error) = self.dispatch_envelope(command) {
8672            self.clear_armed_resume(&address);
8673            self.rollback_spawn(&address, dispatch_timeout).await?;
8674            return Err(error);
8675        }
8676        match self
8677            .wait_for_record_resume(&address, started_after)
8678            .await
8679        {
8680            Ok(settled_address) => {
8681                let managed_lease_id = {
8682                    self.managed_worktrees.lock()
8683                        .unwrap_or_else(|poisoned| poisoned.into_inner())
8684                        .lease_for_workspace(&record.workspace_id)
8685                };
8686                if let Some(lease_id) = managed_lease_id {
8687                    {
8688                        let mut bindings = self.session_bindings.lock()
8689                            .unwrap_or_else(|poisoned| poisoned.into_inner());
8690                        let binding = bindings.get_mut(&settled_address.session.instance_id)
8691                            .ok_or_else(|| failure(
8692                                NodeFailureCode::ManagedWorktreeRecoveryRequired,
8693                                "resumed managed session lost its exact binding",
8694                            ))?;
8695                        if binding.generation != settled_address.session.generation {
8696                            return Err(failure(
8697                                NodeFailureCode::ManagedWorktreeRecoveryRequired,
8698                                "resumed managed session generation diverged",
8699                            ));
8700                        }
8701                        binding.managed_worktree_lease_id = Some(lease_id.clone());
8702                    }
8703                    let snapshot = self.managed_worktrees.lock()
8704                        .unwrap_or_else(|poisoned| poisoned.into_inner())
8705                        .bind_session(
8706                            &lease_id,
8707                            ManagedWorktreeSessionHolder {
8708                                incarnation_id: self.incarnation_id,
8709                                instance_id: settled_address.session.instance_id,
8710                                generation: settled_address.session.generation,
8711                            },
8712                            Some(record_id.clone()),
8713                            unix_time_ms(),
8714                        ).map_err(|message| failure(
8715                            NodeFailureCode::ManagedWorktreeRecoveryRequired,
8716                            &message,
8717                        ))?;
8718                    if let Err(error) = self.persist_state() {
8719                        let _ = self.remove_session(&settled_address).await;
8720                        return Err(persistence_failure(error));
8721                    }
8722                    self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
8723                }
8724                Ok((self.record(record_id)?, settled_address))
8725            }
8726            Err(error) => {
8727                if matches!(
8728                    error.code,
8729                    NodeFailureCode::SessionRecordNotResumable
8730                        | NodeFailureCode::BackendOperationFailed
8731                ) {
8732                    if let Some(bound) = self.bound_address_for_record(record_id) {
8733                        let _ = self.remove_session(&bound).await;
8734                    }
8735                }
8736                Err(error)
8737            }
8738        }
8739    }
8740
8741    async fn wait_for_record_resume(
8742        &self,
8743        initial_address: &SessionAddress,
8744        after_sequence: u64,
8745    ) -> Result<SessionAddress, NodeFailure> {
8746        let deadline = Instant::now()
8747            + Duration::from_millis(MANAGED_RESUME_SETTLE_TIMEOUT_MS);
8748        let mut scan_after = after_sequence;
8749        loop {
8750            let events = self
8751                .history
8752                .lock()
8753                .unwrap_or_else(|poisoned| poisoned.into_inner())
8754                .events
8755                .iter()
8756                .filter(|event| event.sequence > scan_after)
8757                .cloned()
8758                .collect::<Vec<_>>();
8759            for envelope in events {
8760                scan_after = scan_after.max(envelope.sequence);
8761                let NodeEvent::Control { address, event } = envelope.event else {
8762                    continue;
8763                };
8764                if address.session.instance_id != initial_address.session.instance_id {
8765                    continue;
8766                }
8767                match event.event {
8768                    ControlEventKind::Resumed { .. } => return Ok(address),
8769                    ControlEventKind::ResumeDenied { reason } => {
8770                        return Err(failure(
8771                            NodeFailureCode::SessionRecordNotResumable,
8772                            &reason,
8773                        ));
8774                    }
8775                    ControlEventKind::ResumeFailed { message }
8776                    | ControlEventKind::CommandRejected { message } => {
8777                        return Err(failure(
8778                            NodeFailureCode::BackendOperationFailed,
8779                            &message,
8780                        ));
8781                    }
8782                    _ => {}
8783                }
8784            }
8785            if Instant::now() >= deadline {
8786                return Err(failure(
8787                    NodeFailureCode::BackendBusy,
8788                    "managed resume did not settle before the bounded deadline",
8789                ));
8790            }
8791            sleep(Duration::from_millis(5)).await;
8792        }
8793    }
8794
8795    fn workspace_root(&self, workspace_id: &WorkspaceId) -> Result<String, NodeFailure> {
8796        self.workspaces
8797            .read()
8798            .unwrap_or_else(|poisoned| poisoned.into_inner())
8799            .get(workspace_id)
8800            .cloned()
8801            .ok_or_else(|| failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"))
8802    }
8803
8804    fn workspace_roots(&self) -> Vec<PathBuf> {
8805        self.workspaces
8806            .read()
8807            .unwrap_or_else(|poisoned| poisoned.into_inner())
8808            .values()
8809            .map(PathBuf::from)
8810            .collect()
8811    }
8812
8813    async fn inspect_workspace(
8814        &self,
8815        workspace_id: WorkspaceId,
8816    ) -> Result<WorkspaceInspection, NodeFailure> {
8817        let _permit = self
8818            .inspection_slots
8819            .clone()
8820            .try_acquire_owned()
8821            .map_err(|_| {
8822                tracing::warn!(
8823                    workspace_id = %workspace_id,
8824                    "workspace inspection rejected: inspection capacity is busy",
8825                );
8826                failure(
8827                    NodeFailureCode::BackendBusy,
8828                    "workspace inspection capacity is busy",
8829                )
8830            })?;
8831        let canonical_root = self.workspace_root(&workspace_id).map_err(|error| {
8832            tracing::warn!(
8833                workspace_id = %workspace_id,
8834                code = ?error.code,
8835                "workspace inspection rejected: workspace is not registered",
8836            );
8837            error
8838        })?;
8839        let tree_root = canonical_root.clone();
8840        let started_at = Instant::now();
8841        let time_budget_ms = workspace_inspection_time_budget_ms();
8842        let entry_cap = workspace_inspection_entry_cap();
8843        let deadline = started_at + Duration::from_millis(time_budget_ms);
8844        let (entries, tree_truncated, walk_budget) = tokio::task::spawn_blocking(move || {
8845            collect_workspace_entries(Path::new(&tree_root), deadline, entry_cap)
8846        })
8847        .await
8848        .map_err(|error| {
8849            tracing::warn!(
8850                workspace_id = %workspace_id,
8851                error = %error,
8852                "workspace inspection rejected: walk task failed",
8853            );
8854            failure(
8855                NodeFailureCode::BackendOperationFailed,
8856                &format!("workspace inspection task failed: {error}"),
8857            )
8858        })?;
8859        let (mut git, git_time_budget_exceeded) =
8860            inspect_git_workspace(&canonical_root, deadline).await;
8861        if !git.worktrees.is_empty() {
8862            let registered = self
8863                .workspaces
8864                .read()
8865                .unwrap_or_else(|poisoned| poisoned.into_inner())
8866                .iter()
8867                .map(|(workspace_id, root)| (workspace_id.clone(), root.clone()))
8868                .collect::<Vec<_>>();
8869            for worktree in &mut git.worktrees {
8870                worktree.workspace_id = registered
8871                    .iter()
8872                    .find(|(_, root)| {
8873                        worktree_paths_equal(root, windows_path_text(&worktree.path))
8874                    })
8875                    .map(|(workspace_id, _)| workspace_id.clone());
8876            }
8877        }
8878        if git.is_repository {
8879            git.managed_worktree = self
8880                .managed_worktrees
8881                .lock()
8882                .unwrap_or_else(|poisoned| poisoned.into_inner())
8883                .git_scope(&workspace_id, git.branch.as_deref());
8884        }
8885        let truncation = workspace_inspection_truncation(
8886            &walk_budget,
8887            git_time_budget_exceeded,
8888            started_at.elapsed(),
8889        );
8890        if let Some(truncation) = &truncation {
8891            tracing::info!(
8892                workspace_id = %workspace_id,
8893                walk_time_budget_exceeded = truncation.walk_time_budget_exceeded,
8894                walk_entry_cap_exceeded = truncation.walk_entry_cap_exceeded,
8895                git_time_budget_exceeded = truncation.git_time_budget_exceeded,
8896                entries_visited = truncation.entries_visited,
8897                elapsed_ms = truncation.elapsed_ms,
8898                "workspace inspection truncated by its inner budget",
8899            );
8900        }
8901        Ok(WorkspaceInspection {
8902            workspace_id,
8903            entries,
8904            tree_truncated,
8905            git,
8906            truncation,
8907        })
8908    }
8909
8910    async fn browse_host_directories(
8911        &self,
8912        directory: Option<OpaqueHostPath>,
8913        after: Option<OpaqueHostPath>,
8914    ) -> Result<HostDirectoryListing, NodeFailure> {
8915        let permit = self
8916            .inspection_slots
8917            .clone()
8918            .try_acquire_owned()
8919            .map_err(|_| {
8920                failure(
8921                    NodeFailureCode::BackendBusy,
8922                    "host directory browse capacity is busy",
8923                )
8924            })?;
8925        let task = tokio::task::spawn_blocking(move || {
8926            let _permit = permit;
8927            browse_host_directories(directory, after)
8928        });
8929        timeout(Duration::from_millis(HOST_DIRECTORY_BROWSE_TIMEOUT_MS), task)
8930            .await
8931            .map_err(|_| {
8932                failure(
8933                    NodeFailureCode::HostDirectoryReadTimedOut,
8934                    "host directory browse exceeded its bounded deadline",
8935                )
8936            })?
8937            .map_err(|_| {
8938                failure(
8939                    NodeFailureCode::HostDirectoryReadFailed,
8940                    "host directory browse task failed",
8941                )
8942            })?
8943            .map_err(host_directory_failure)
8944    }
8945
8946    async fn read_workspace_file(
8947        &self,
8948        workspace_id: WorkspaceId,
8949        path: RepositoryPath,
8950    ) -> Result<WorkspaceFileRead, NodeFailure> {
8951        let canonical_root = self.workspace_root(&workspace_id)?;
8952        let permit = self
8953            .inspection_slots
8954            .clone()
8955            .try_acquire_owned()
8956            .map_err(|_| {
8957                failure(
8958                    NodeFailureCode::BackendBusy,
8959                    "workspace file read capacity is busy",
8960                )
8961            })?;
8962        let path_to_read = path.clone();
8963        let task = tokio::task::spawn_blocking(move || {
8964            let _permit = permit;
8965            read_workspace_file_from_disk(Path::new(&canonical_root), &path_to_read)
8966        });
8967        let content = timeout(
8968            Duration::from_millis(WORKSPACE_FILE_READ_TIMEOUT_MS),
8969            task,
8970        )
8971        .await
8972        .map_err(|_| {
8973            failure(
8974                NodeFailureCode::RepositoryFileReadTimedOut,
8975                "repository file read exceeded its bounded deadline",
8976            )
8977        })?
8978        .map_err(|_| {
8979            failure(
8980                NodeFailureCode::RepositoryFileReadFailed,
8981                "repository file read task failed",
8982            )
8983        })?
8984        .map_err(workspace_file_failure)?;
8985        let content = match content {
8986            WorkspaceFileBytes::Utf8(text) => {
8987                let revision = Some(workspace_file_revision(text.as_bytes()));
8988                return Ok(WorkspaceFileRead {
8989                    workspace_id,
8990                    path,
8991                    content: WorkspaceFileContent::Utf8 {
8992                        byte_len: u32::try_from(text.len())
8993                            .expect("bounded workspace text length must fit u32"),
8994                        text,
8995                    },
8996                    revision,
8997                });
8998            }
8999            WorkspaceFileBytes::NonUtf8 { byte_length } => {
9000                WorkspaceFileContent::NonUtf8 {
9001                    byte_len: u32::try_from(byte_length)
9002                        .expect("bounded workspace file length must fit u32"),
9003                }
9004            }
9005            WorkspaceFileBytes::TooLarge => WorkspaceFileContent::TooLarge {
9006                limit_bytes: MAX_WORKSPACE_FILE_BYTES as u32,
9007            },
9008        };
9009        Ok(WorkspaceFileRead {
9010            workspace_id,
9011            path,
9012            content,
9013            revision: None,
9014        })
9015    }
9016
9017    async fn write_workspace_file(
9018        &self,
9019        workspace_id: WorkspaceId,
9020        path: RepositoryPath,
9021        expected_revision: WorkspaceFileRevision,
9022        text: String,
9023    ) -> Result<WorkspaceFileRead, NodeFailure> {
9024        let canonical_root = self.workspace_root(&workspace_id)?;
9025        let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9026            failure(NodeFailureCode::BackendBusy, "workspace file write capacity is busy")
9027        })?;
9028        let path_to_write = path.clone();
9029        let expected = expected_revision.as_str().to_owned();
9030        let text_to_write = text.clone();
9031        let task = tokio::task::spawn_blocking(move || {
9032            let _permit = permit;
9033            write_workspace_file_to_disk(
9034                Path::new(&canonical_root),
9035                &path_to_write,
9036                &expected,
9037                &text_to_write,
9038            )
9039        });
9040        let revision = timeout(Duration::from_millis(WORKSPACE_FILE_WRITE_TIMEOUT_MS), task)
9041            .await
9042            .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteTimedOut, "repository file write exceeded its bounded deadline"))?
9043            .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteFailed, "repository file write task failed"))?
9044            .map_err(workspace_file_write_failure)?;
9045        Ok(WorkspaceFileRead {
9046            workspace_id,
9047            path,
9048            content: WorkspaceFileContent::Utf8 {
9049                byte_len: u32::try_from(text.len()).expect("bounded workspace text length must fit u32"),
9050                text,
9051            },
9052            revision: Some(WorkspaceFileRevision::new(revision)
9053                .expect("workspace writer returns a valid SHA-256 revision")),
9054        })
9055    }
9056
9057    async fn create_workspace_file(
9058        &self,
9059        workspace_id: WorkspaceId,
9060        path: RepositoryPath,
9061    ) -> Result<WorkspaceFileRead, NodeFailure> {
9062        let canonical_root = self.workspace_root(&workspace_id)?;
9063        let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9064            failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
9065        })?;
9066        let path_to_create = path.clone();
9067        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
9068        let task_commit_state = Arc::clone(&commit_state);
9069        let task = tokio::task::spawn_blocking(move || {
9070            let _permit = permit;
9071            create_workspace_file_on_disk(
9072                Path::new(&canonical_root),
9073                &path_to_create,
9074                &task_commit_state,
9075            )
9076        });
9077        let revision = settle_workspace_entry_create(
9078            task,
9079            commit_state,
9080            Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
9081            "repository file create exceeded its bounded deadline",
9082            "repository file create task failed",
9083        )
9084        .await?;
9085        Ok(WorkspaceFileRead {
9086            workspace_id,
9087            path,
9088            content: WorkspaceFileContent::Utf8 {
9089                text: String::new(),
9090                byte_len: 0,
9091            },
9092            revision: Some(WorkspaceFileRevision::new(revision)
9093                .expect("workspace creator returns a valid SHA-256 revision")),
9094        })
9095    }
9096
9097    async fn create_workspace_directory(
9098        &self,
9099        workspace_id: WorkspaceId,
9100        path: RepositoryPath,
9101    ) -> Result<WorkspaceEntry, NodeFailure> {
9102        let canonical_root = self.workspace_root(&workspace_id)?;
9103        let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9104            failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
9105        })?;
9106        let path_to_create = path.clone();
9107        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
9108        let task_commit_state = Arc::clone(&commit_state);
9109        let task = tokio::task::spawn_blocking(move || {
9110            let _permit = permit;
9111            create_workspace_directory_on_disk(
9112                Path::new(&canonical_root),
9113                &path_to_create,
9114                &task_commit_state,
9115            )
9116        });
9117        settle_workspace_entry_create(
9118            task,
9119            commit_state,
9120            Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
9121            "repository directory create exceeded its bounded deadline",
9122            "repository directory create task failed",
9123        )
9124        .await?;
9125        Ok(WorkspaceEntry {
9126            relative_path: path,
9127            kind: WorkspaceEntryKind::Directory,
9128        })
9129    }
9130
9131    async fn read_git_history(
9132        &self,
9133        workspace_id: WorkspaceId,
9134        path: Option<RepositoryPath>,
9135        before: Option<GitObjectId>,
9136        limit: u16,
9137    ) -> Result<GitHistoryPage, NodeFailure> {
9138        let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9139            failure(NodeFailureCode::BackendBusy, "git history capacity is busy")
9140        })?;
9141        let root = self.workspace_root(&workspace_id)?;
9142        read_git_history_bounded(&root, path.as_ref(), before.as_ref(), limit).await
9143    }
9144
9145    async fn read_git_diff(
9146        &self,
9147        workspace_id: WorkspaceId,
9148        request: GitDiffRequest,
9149    ) -> Result<GitDiff, NodeFailure> {
9150        let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9151            failure(NodeFailureCode::BackendBusy, "git diff capacity is busy")
9152        })?;
9153        let root = self.workspace_root(&workspace_id)?;
9154        read_git_diff_bounded(&root, request).await
9155    }
9156
9157    fn native_session_route_root(
9158        &self,
9159        route: &NativeSessionCatalogRoute,
9160    ) -> Result<Option<String>, NodeFailure> {
9161        route
9162            .validate()
9163            .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9164        match route.scope {
9165            gate4agent_types::NativeSessionCatalogScope::Workspace => route
9166                .workspace_id
9167                .as_ref()
9168                .map(|workspace_id| self.workspace_root(workspace_id))
9169                .transpose(),
9170            gate4agent_types::NativeSessionCatalogScope::Unregistered => Ok(None),
9171        }
9172    }
9173
9174    fn project_native_session_entries(
9175        &self,
9176        route: &NativeSessionCatalogRoute,
9177        entries: Vec<ScopedNativeSessionCatalogEntry>,
9178    ) -> Vec<crate::protocol::NativeSessionCatalogEntry> {
9179        let current_root = route
9180            .workspace_id
9181            .as_ref()
9182            .and_then(|workspace_id| self.workspace_root(workspace_id).ok());
9183        let records = self
9184            .session_records
9185            .lock()
9186            .unwrap_or_else(|poisoned| poisoned.into_inner());
9187        let mut seen_records = std::collections::HashSet::new();
9188        entries
9189            .into_iter()
9190            .map(|entry| {
9191                let record_id = route.workspace_id.as_ref().and_then(|workspace_id| {
9192                    let matches = records
9193                        .records
9194                        .values()
9195                        .filter(|record| {
9196                            record.provider == route.provider
9197                                && record.workspace_id == *workspace_id
9198                                && current_root.as_deref().is_some_and(|root| {
9199                                    platform::roots_equal(
9200                                        root,
9201                                        windows_path_text(&record.canonical_root),
9202                                    )
9203                                })
9204                                && record.provider_session.as_ref().is_some_and(|identity| {
9205                                    session_registry::same_provider_session(
9206                                        &record.provider,
9207                                        identity,
9208                                        &route.provider,
9209                                        &entry.provider_session,
9210                                    )
9211                                })
9212                        })
9213                        .map(|record| record.record_id.clone())
9214                        .collect::<Vec<_>>();
9215                    (matches.len() == 1
9216                        && seen_records.insert(matches[0].clone()))
9217                        .then(|| matches[0].clone())
9218                });
9219                let metadata = entry.metadata;
9220                crate::protocol::NativeSessionCatalogEntry {
9221                    selection_id: metadata.selection_id,
9222                    title: metadata.title,
9223                    modified_at_unix_ms: metadata.modified_at_unix_ms,
9224                    model: metadata.model,
9225                    message_count: metadata.message_count,
9226                    completed_turn_count: metadata.completed_turn_count,
9227                    external_group: entry.external_group,
9228                    record_id,
9229                }
9230            })
9231            .collect()
9232    }
9233
9234    async fn catalog_native_sessions(
9235        &self,
9236        route: NativeSessionCatalogRoute,
9237        limit: u16,
9238    ) -> Result<(
9239        Vec<crate::protocol::NativeSessionCatalogEntry>,
9240        crate::protocol::NativeSessionCatalogSummary,
9241    ), NodeFailure> {
9242        let canonical_root = self.native_session_route_root(&route)?;
9243        let registered_workspace_roots = self.workspace_roots();
9244        let provider = route.provider.clone();
9245        let scope = route.scope;
9246        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9247            failure(
9248                NodeFailureCode::InvalidRequest,
9249                "native session catalog is not configured",
9250            )
9251        })?;
9252        let _permit = Arc::clone(&self.inspection_slots)
9253            .try_acquire_owned()
9254            .map_err(|_| {
9255                failure(
9256                    NodeFailureCode::BackendBusy,
9257                    "native session catalog capacity is busy",
9258                )
9259            })?;
9260        let page = run_native_session_catalog_operation(
9261            catalog,
9262            "native session catalog",
9263            move |catalog| {
9264                catalog.catalog_initial_for_scope(
9265                    &provider,
9266                    scope,
9267                    canonical_root.as_deref().map(Path::new),
9268                    &registered_workspace_roots,
9269                    limit,
9270                )
9271            },
9272        )
9273        .await?;
9274        let summary = crate::protocol::NativeSessionCatalogSummary {
9275            catalog_revision: page.revision,
9276            recent_cutoff_unix_ms: page.cutoff_unix_ms,
9277            recent_total_count: page.recent_total_count,
9278            older_total_count: page.older_total_count,
9279            recent_next_after_selection_id: page.next_after_selection_id,
9280            recent_has_more: page.remaining_count > 0,
9281        };
9282        Ok((self.project_native_session_entries(&route, page.entries), summary))
9283    }
9284
9285    async fn page_native_sessions(
9286        &self,
9287        route: NativeSessionCatalogRoute,
9288        window: crate::protocol::NativeSessionCatalogWindow,
9289        catalog_revision: u64,
9290        recent_cutoff_unix_ms: u64,
9291        after_selection_id: Option<String>,
9292        limit: u16,
9293    ) -> Result<crate::protocol::NativeSessionCatalogPage, NodeFailure> {
9294        let canonical_root = self.native_session_route_root(&route)?;
9295        let registered_workspace_roots = self.workspace_roots();
9296        let provider = route.provider.clone();
9297        let scope = route.scope;
9298        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9299            failure(
9300                NodeFailureCode::InvalidRequest,
9301                "native session catalog is not configured",
9302            )
9303        })?;
9304        let _permit = Arc::clone(&self.inspection_slots)
9305            .try_acquire_owned()
9306            .map_err(|_| {
9307                failure(
9308                    NodeFailureCode::BackendBusy,
9309                    "native session catalog capacity is busy",
9310                )
9311            })?;
9312        let page = run_native_session_catalog_operation(
9313            catalog,
9314            "native session catalog page",
9315            move |catalog| {
9316                Ok::<_, std::convert::Infallible>(catalog.catalog_page_for_scope(
9317                    &provider,
9318                    scope,
9319                    canonical_root.as_deref().map(Path::new),
9320                    &registered_workspace_roots,
9321                    window,
9322                    catalog_revision,
9323                    recent_cutoff_unix_ms,
9324                    after_selection_id.as_deref(),
9325                    limit,
9326                ))
9327            },
9328        )
9329        .await?;
9330        let page = page.map_err(|error| match error {
9331            NativeSessionCatalogError::StaleCatalog => failure(
9332                NodeFailureCode::StaleNativeSessionCatalog,
9333                "native session catalog revision or cursor is stale",
9334            ),
9335            _ => failure(
9336                NodeFailureCode::BackendOperationFailed,
9337                "native session catalog page failed",
9338            ),
9339        })?;
9340        let entries = self.project_native_session_entries(&route, page.entries);
9341        Ok(crate::protocol::NativeSessionCatalogPage {
9342            window: page.window,
9343            revision: page.revision,
9344            entries,
9345            next_after_selection_id: page.next_after_selection_id,
9346            remaining_count: page.remaining_count,
9347            has_more: page.remaining_count > 0,
9348        })
9349    }
9350
9351    async fn preview_native_session(
9352        &self,
9353        selection: NativeSessionSelection,
9354        message_limit: u16,
9355    ) -> Result<crate::protocol::NativeSessionPreview, NodeFailure> {
9356        selection
9357            .validate()
9358            .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9359        let canonical_root = self.native_session_route_root(&selection.route)?;
9360        let registered_workspace_roots = self.workspace_roots();
9361        let provider = selection.route.provider.clone();
9362        let scope = selection.route.scope;
9363        let catalog_revision = selection.catalog_revision;
9364        let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9365        let selection_id = selection.selection_id.clone();
9366        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9367            failure(
9368                NodeFailureCode::InvalidRequest,
9369                "native session preview is not configured",
9370            )
9371        })?;
9372        let _permit = Arc::clone(&self.inspection_slots)
9373            .try_acquire_owned()
9374            .map_err(|_| {
9375                failure(
9376                    NodeFailureCode::BackendBusy,
9377                    "native session preview capacity is busy",
9378                )
9379            })?;
9380        let preview = run_native_session_catalog_operation(
9381            catalog,
9382            "native session preview",
9383            move |catalog| {
9384                Ok::<_, std::convert::Infallible>(catalog.preview_for_scope(
9385                    &provider,
9386                    scope,
9387                    canonical_root.as_deref().map(Path::new),
9388                    &registered_workspace_roots,
9389                    catalog_revision,
9390                    recent_cutoff_unix_ms,
9391                    &selection_id,
9392                    message_limit,
9393                ))
9394            },
9395        )
9396        .await?;
9397        preview
9398            .map(Into::into)
9399            .map_err(native_session_preview_failure)
9400    }
9401
9402    async fn index_native_session(
9403        &self,
9404        selection: NativeSessionSelection,
9405        display_name: String,
9406    ) -> Result<ManagedSessionRecord, NodeFailure> {
9407        selection
9408            .validate()
9409            .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9410        if selection.route.scope
9411            != gate4agent_types::NativeSessionCatalogScope::Workspace
9412        {
9413            return Err(failure(
9414                NodeFailureCode::WorkspaceRegistrationRequired,
9415                "register the external project as a workspace before indexing or resuming it",
9416            ));
9417        }
9418        let workspace_id = selection
9419            .route
9420            .workspace_id
9421            .clone()
9422            .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9423        let canonical_root = self
9424            .native_session_route_root(&selection.route)?
9425            .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9426        let registered_workspace_roots = self.workspace_roots();
9427        let provider = selection.route.provider.clone();
9428        let catalog_revision = selection.catalog_revision;
9429        let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9430        let selection_id = selection.selection_id.clone();
9431        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9432            failure(
9433                NodeFailureCode::InvalidRequest,
9434                "native session index is not configured",
9435            )
9436        })?;
9437        let _permit = Arc::clone(&self.inspection_slots)
9438            .try_acquire_owned()
9439            .map_err(|_| {
9440                failure(
9441                    NodeFailureCode::BackendBusy,
9442                    "native session index capacity is busy",
9443                )
9444            })?;
9445        let resolved = run_native_session_catalog_operation(
9446            catalog,
9447            "native session index resolution",
9448            move |catalog| {
9449                Ok::<_, std::convert::Infallible>(catalog.resolve_selection_for_scope(
9450                    &provider,
9451                    gate4agent_types::NativeSessionCatalogScope::Workspace,
9452                    Some(Path::new(&canonical_root)),
9453                    &registered_workspace_roots,
9454                    catalog_revision,
9455                    recent_cutoff_unix_ms,
9456                    &selection_id,
9457                ))
9458            },
9459        )
9460        .await?
9461        .map_err(native_session_preview_failure)?;
9462        self.index_provider_session_with_policy(
9463            workspace_id,
9464            selection.route.provider,
9465            resolved.identity,
9466            display_name,
9467            true,
9468        )
9469    }
9470
9471    async fn preview_native_session_by_session_id(
9472        &self,
9473        workspace_id: WorkspaceId,
9474        provider: AgentId,
9475        session_id: String,
9476        message_limit: u16,
9477    ) -> Result<gate4agent_types::NativeSessionPreview, NodeFailure> {
9478        let canonical_root = self.workspace_root(&workspace_id)?;
9479        let registered_workspace_roots = self.workspace_roots();
9480        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9481            failure(
9482                NodeFailureCode::InvalidRequest,
9483                "native session preview is not configured",
9484            )
9485        })?;
9486        let _permit = Arc::clone(&self.inspection_slots)
9487            .try_acquire_owned()
9488            .map_err(|_| {
9489                failure(
9490                    NodeFailureCode::BackendBusy,
9491                    "native session preview capacity is busy",
9492                )
9493            })?;
9494        run_native_session_catalog_operation(
9495            catalog,
9496            "native session preview",
9497            move |catalog| {
9498                catalog.preview_session_id_for_workspace(
9499                    &provider,
9500                    Path::new(&canonical_root),
9501                    &registered_workspace_roots,
9502                    &session_id,
9503                    message_limit,
9504                )
9505            },
9506        )
9507        .await
9508    }
9509
9510    async fn register_workspace(
9511        &self,
9512        workspace_id: WorkspaceId,
9513        root: String,
9514    ) -> Result<WorkspaceSnapshot, NodeFailure> {
9515        validate_workspace_request_root(&root)?;
9516        let canonicalize_workspace_id = workspace_id.clone();
9517        let workspace = tokio::task::spawn_blocking(move || {
9518            WorkspaceConfig::new(canonicalize_workspace_id, root)
9519        })
9520        .await
9521        .map_err(|error| {
9522            failure(
9523                NodeFailureCode::BackendOperationFailed,
9524                &format!("workspace canonicalization task failed: {error}"),
9525            )
9526        })?
9527        .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
9528        let transaction = self
9529            .state_transaction
9530            .lock()
9531            .unwrap_or_else(|poisoned| poisoned.into_inner());
9532        let mut workspaces = self
9533            .workspaces
9534            .write()
9535            .unwrap_or_else(|poisoned| poisoned.into_inner());
9536        if workspaces.contains_key(&workspace_id) {
9537            return Err(failure(
9538                NodeFailureCode::DuplicateWorkspaceId,
9539                "workspace ID is already registered",
9540            ));
9541        }
9542        if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
9543            platform::roots_equal(existing_root, workspace.canonical_root())
9544        }) {
9545            return Err(failure(
9546                NodeFailureCode::DuplicateWorkspaceRoot,
9547                &format!("workspace root is already registered as '{existing_id}'"),
9548            ));
9549        }
9550        {
9551            let records = self
9552                .session_records
9553                .lock()
9554                .unwrap_or_else(|poisoned| poisoned.into_inner());
9555            if let Some(record) = records.records.values().find(|record| {
9556                record.workspace_id == workspace_id
9557                    && !record
9558                        .canonical_root
9559                        .as_utf8()
9560                        .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9561            }) {
9562                return Err(failure(
9563                    NodeFailureCode::SessionRecordConflict,
9564                    &format!(
9565                        "workspace ID is retained by session record '{}' at another root",
9566                        record.record_id,
9567                    ),
9568                ));
9569            }
9570        }
9571        let snapshot = WorkspaceSnapshot {
9572            workspace_id: workspace_id.clone(),
9573            canonical_root: opaque_windows_path(workspace.canonical_root().to_owned()),
9574            sessions: Vec::new(),
9575            worktree_service_mode: Some(WorktreeServiceMode::Manual),
9576            managed_worktree_profiles: Some(WorktreeProfileInventory {
9577                profiles: Vec::new(),
9578            }),
9579        };
9580        workspaces.insert(workspace_id.clone(), workspace.canonical_root().to_owned());
9581        drop(workspaces);
9582        let previous_records = {
9583            let mut records = self
9584                .session_records
9585                .lock()
9586                .unwrap_or_else(|poisoned| poisoned.into_inner());
9587            let previous = records.records.clone();
9588            for record in records.records.values_mut().filter(|record| {
9589                record.workspace_id == workspace_id
9590                    && record
9591                        .canonical_root
9592                        .as_utf8()
9593                        .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9594            }) {
9595                if record.active_session.is_none() {
9596                    record.state = detached_record_state(record);
9597                }
9598                if record.last_error.as_deref() == Some(WORKSPACE_UNAVAILABLE_ERROR) {
9599                    record.last_error = None;
9600                }
9601                record.updated_at_unix_ms = unix_time_ms();
9602            }
9603            previous
9604        };
9605        if let Err(error) = self.persist_state_locked() {
9606            self.workspaces
9607                .write()
9608                .unwrap_or_else(|poisoned| poisoned.into_inner())
9609                .remove(&workspace_id);
9610            self.session_records
9611                .lock()
9612                .unwrap_or_else(|poisoned| poisoned.into_inner())
9613                .records = previous_records;
9614            return Err(persistence_failure(error));
9615        }
9616        let restored = self
9617            .session_records
9618            .lock()
9619            .unwrap_or_else(|poisoned| poisoned.into_inner())
9620            .records
9621            .values()
9622            .filter(|record| record.workspace_id == workspace_id)
9623            .cloned()
9624            .collect::<Vec<_>>();
9625        drop(transaction);
9626        self.publish(NodeEvent::WorkspaceAdded {
9627            workspace: snapshot.clone(),
9628        });
9629        for record in restored {
9630            self.publish_record(record);
9631        }
9632        Ok(snapshot)
9633    }
9634
9635    async fn create_standalone_workspace(
9636        &self,
9637        workspace_id: WorkspaceId,
9638        root: String,
9639        initial_branch: Option<String>,
9640    ) -> Result<WorkspaceSnapshot, NodeFailure> {
9641        validate_workspace_request_root(&root)?;
9642        validate_git_revision("initial branch", initial_branch.as_deref())?;
9643        let preflight_workspace_id = workspace_id.clone();
9644        let preflight_root = root.clone();
9645        let canonical_candidate = tokio::task::spawn_blocking(move || {
9646            canonical_standalone_workspace_candidate(&preflight_workspace_id, &preflight_root)
9647        })
9648        .await
9649        .map_err(|_| failure(
9650            NodeFailureCode::BackendOperationFailed,
9651            "standalone workspace root preflight task failed",
9652        ))?
9653        .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
9654        {
9655            let workspaces = self
9656                .workspaces
9657                .read()
9658                .unwrap_or_else(|poisoned| poisoned.into_inner());
9659            if workspaces.contains_key(&workspace_id) {
9660                return Err(failure(
9661                    NodeFailureCode::DuplicateWorkspaceId,
9662                    "workspace ID is already registered",
9663                ));
9664            }
9665            if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
9666                platform::roots_equal(existing_root, &canonical_candidate)
9667            }) {
9668                return Err(failure(
9669                    NodeFailureCode::DuplicateWorkspaceRoot,
9670                    &format!("workspace root is already registered as '{existing_id}'"),
9671                ));
9672            }
9673        }
9674        let prepared = prepare_standalone_workspace(root, initial_branch.as_deref())
9675            .await
9676            .map_err(standalone_workspace_failure)?;
9677        prepared
9678            .verify_for_registration()
9679            .await
9680            .map_err(standalone_workspace_failure)?;
9681        match self
9682            .register_workspace(workspace_id, prepared.root().to_owned())
9683            .await
9684        {
9685            Ok(workspace) => Ok(workspace),
9686            Err(registration_error) => {
9687                if prepared.compensate_registration_failure().await {
9688                    Err(registration_error)
9689                } else {
9690                    Err(failure(
9691                        NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
9692                        "standalone workspace initialization succeeded but registration failed; recovery is required",
9693                    ))
9694                }
9695            }
9696        }
9697    }
9698
9699    fn unregister_workspace(&self, workspace_id: &WorkspaceId) -> Result<(), NodeFailure> {
9700        let bindings = self
9701            .session_bindings
9702            .lock()
9703            .unwrap_or_else(|poisoned| poisoned.into_inner());
9704        if bindings.values().any(|binding| &binding.workspace_id == workspace_id) {
9705            return Err(failure(
9706                NodeFailureCode::WorkspaceBusy,
9707                "workspace retains a bound session; remove every session before unregistering it",
9708            ));
9709        }
9710        drop(bindings);
9711        let transaction = self
9712            .state_transaction
9713            .lock()
9714            .unwrap_or_else(|poisoned| poisoned.into_inner());
9715        let mut workspaces = self
9716            .workspaces
9717            .write()
9718            .unwrap_or_else(|poisoned| poisoned.into_inner());
9719        if !workspaces.contains_key(workspace_id) {
9720            return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
9721        }
9722        if workspaces.len() == 1 {
9723            return Err(failure(
9724                NodeFailureCode::LastWorkspace,
9725                "node must retain at least one workspace",
9726            ));
9727        }
9728        let removed_root = workspaces
9729            .remove(workspace_id)
9730            .expect("workspace presence was validated");
9731        drop(workspaces);
9732        let previous_records = {
9733            let mut records = self
9734                .session_records
9735                .lock()
9736                .unwrap_or_else(|poisoned| poisoned.into_inner());
9737            let previous = records.records.clone();
9738            for record in records
9739                .records
9740                .values_mut()
9741                .filter(|record| &record.workspace_id == workspace_id)
9742            {
9743                record.active_session = None;
9744                record.state = ManagedSessionState::Unavailable;
9745                record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
9746                record.updated_at_unix_ms = unix_time_ms();
9747            }
9748            previous
9749        };
9750        if let Err(error) = self.persist_state_locked() {
9751            self.workspaces
9752                .write()
9753                .unwrap_or_else(|poisoned| poisoned.into_inner())
9754                .insert(workspace_id.clone(), removed_root);
9755            self.session_records
9756                .lock()
9757                .unwrap_or_else(|poisoned| poisoned.into_inner())
9758                .records = previous_records;
9759            return Err(persistence_failure(error));
9760        }
9761        let unavailable = self
9762            .session_records
9763            .lock()
9764            .unwrap_or_else(|poisoned| poisoned.into_inner())
9765            .records
9766            .values()
9767            .filter(|record| &record.workspace_id == workspace_id)
9768            .cloned()
9769            .collect::<Vec<_>>();
9770        drop(transaction);
9771        self.publish(NodeEvent::WorkspaceRemoved {
9772            workspace_id: workspace_id.clone(),
9773        });
9774        for record in unavailable {
9775            self.publish_record(record);
9776        }
9777        Ok(())
9778    }
9779
9780    async fn create_worktree(
9781        &self,
9782        source_workspace_id: WorkspaceId,
9783        workspace_id: WorkspaceId,
9784        target_root: String,
9785        branch: String,
9786        base: Option<String>,
9787    ) -> Result<(GitWorktreeSnapshot, WorkspaceSnapshot), NodeFailure> {
9788        self.require_worktree_service(&source_workspace_id)?;
9789        validate_workspace_request_root(&target_root)?;
9790        validate_git_revision("worktree base", base.as_deref())?;
9791        let source_root = self.workspace_root(&source_workspace_id)?;
9792        {
9793            let workspaces = self
9794                .workspaces
9795                .read()
9796                .unwrap_or_else(|poisoned| poisoned.into_inner());
9797            if workspaces.contains_key(&workspace_id) {
9798                return Err(failure(
9799                    NodeFailureCode::DuplicateWorkspaceId,
9800                    "workspace ID is already registered",
9801                ));
9802            }
9803            if let Some((existing_id, _)) = workspaces
9804                .iter()
9805                .find(|(_, root)| worktree_paths_equal(root, &target_root))
9806            {
9807                return Err(failure(
9808                    NodeFailureCode::DuplicateWorkspaceRoot,
9809                    &format!("workspace root is already registered as '{existing_id}'"),
9810                ));
9811            }
9812        }
9813        let mut worktree = create_git_worktree(
9814            &source_root,
9815            &target_root,
9816            &branch,
9817            base.as_deref(),
9818        )
9819        .await
9820        .map_err(git_worktree_failure)?;
9821        let workspace = match self
9822            .register_workspace(workspace_id, worktree.path.clone())
9823            .await
9824        {
9825            Ok(workspace) => workspace,
9826            Err(registration_error) => {
9827                if remove_git_worktree(&source_root, &worktree.path).await.is_err() {
9828                    return Err(failure(
9829                        NodeFailureCode::ManagedWorktreeRecoveryRequired,
9830                        "worktree registration failed and compensation requires recovery",
9831                    ));
9832                }
9833                return Err(failure(
9834                    registration_error.code,
9835                    &registration_error.message,
9836                ));
9837            }
9838        };
9839        worktree.workspace_id = Some(workspace.workspace_id.clone());
9840        Ok((protocol_worktree(worktree), workspace))
9841    }
9842
9843    async fn remove_worktree(
9844        &self,
9845        source_workspace_id: WorkspaceId,
9846        target_root: String,
9847    ) -> Result<Option<WorkspaceId>, NodeFailure> {
9848        self.require_worktree_service(&source_workspace_id)?;
9849        validate_workspace_request_root(&target_root)?;
9850        let source_root = self.workspace_root(&source_workspace_id)?;
9851        let target_root = normalize_worktree_removal_target(&target_root)
9852            .map_err(git_worktree_failure)?;
9853        let target = list_git_worktrees(&source_root)
9854            .await
9855            .map_err(git_worktree_failure)?
9856            .into_iter()
9857            .find(|worktree| worktree_paths_equal(&worktree.path, &target_root))
9858            .ok_or_else(|| {
9859                failure(
9860                    NodeFailureCode::WorktreeProtected,
9861                    "refusing to remove a path that is not in Git's worktree listing",
9862                )
9863            })?;
9864        let registered_workspace_id = {
9865            let workspaces = self
9866                .workspaces
9867                .read()
9868                .unwrap_or_else(|poisoned| poisoned.into_inner());
9869            let matched = workspaces
9870                .iter()
9871                .find(|(_, root)| worktree_paths_equal(root, &target.path))
9872                .map(|(workspace_id, _)| workspace_id.clone());
9873            if matched.is_some() && workspaces.len() == 1 {
9874                return Err(failure(
9875                    NodeFailureCode::LastWorkspace,
9876                    "node must retain at least one workspace",
9877                ));
9878            }
9879            matched
9880        };
9881        if let Some(workspace_id) = registered_workspace_id.as_ref() {
9882            let bindings = self
9883                .session_bindings
9884                .lock()
9885                .unwrap_or_else(|poisoned| poisoned.into_inner());
9886            if bindings
9887                .values()
9888                .any(|binding| &binding.workspace_id == workspace_id)
9889            {
9890                return Err(failure(
9891                    NodeFailureCode::WorkspaceBusy,
9892                    "worktree workspace retains a bound session; remove every session first",
9893                ));
9894            }
9895        }
9896        remove_git_worktree(&source_root, &target.path)
9897            .await
9898            .map_err(git_worktree_failure)?;
9899        if let Some(workspace_id) = registered_workspace_id.as_ref() {
9900            self.unregister_workspace(workspace_id)
9901                .map_err(|error| failure(error.code, &error.message))?;
9902        }
9903        Ok(registered_workspace_id)
9904    }
9905
9906    async fn cleanup_managed_worktree(
9907        &self,
9908        lease_id: &ManagedWorktreeLeaseId,
9909        explicit: bool,
9910    ) -> Result<ManagedWorktreeLeaseSnapshot, NodeFailure> {
9911        let lease = self.managed_worktrees.lock()
9912            .unwrap_or_else(|poisoned| poisoned.into_inner())
9913            .get(lease_id)
9914            .cloned()
9915            .ok_or_else(|| failure(
9916                NodeFailureCode::UnknownManagedWorktreeLease,
9917                "managed worktree lease does not exist",
9918            ))?;
9919        if lease.state == ManagedWorktreeLeaseState::Removed {
9920            return Ok(lease.snapshot());
9921        }
9922        if lease.has_holders() {
9923            return Err(failure(
9924                NodeFailureCode::ManagedWorktreeBusy,
9925                "managed worktree lease still has a session or durable-record holder",
9926            ));
9927        }
9928        if self
9929            .materializations
9930            .lock()
9931            .unwrap_or_else(|poisoned| poisoned.into_inner())
9932            .values()
9933            .any(|record| record.managed_lease_id() == Some(lease_id))
9934        {
9935            return Err(failure(
9936                NodeFailureCode::ManagedWorktreeRecoveryRequired,
9937                "managed worktree cleanup is blocked by session environment ownership",
9938            ));
9939        }
9940        if !explicit && lease.retention == ManagedWorktreeRetention::Retain {
9941            let snapshot = {
9942                let mut registry = self.managed_worktrees.lock()
9943                    .unwrap_or_else(|poisoned| poisoned.into_inner());
9944                let current = registry.get_mut(lease_id)
9945                    .expect("managed worktree lease remains present");
9946                current.state = ManagedWorktreeLeaseState::Retained;
9947                current.cleanup_failure = None;
9948                current.updated_at_unix_ms = unix_time_ms();
9949                current.snapshot()
9950            };
9951            if self.persist_state().is_err() {
9952                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
9953            }
9954            self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
9955            return Ok(snapshot);
9956        }
9957        let profile = self.managed_profile(&lease.source_workspace_id, &lease.profile_id)?;
9958        if profile.revision() != &lease.profile_revision {
9959            self.mark_managed_recovery_required(
9960                lease_id,
9961                ManagedWorktreeCleanupFailure::OwnershipConflict,
9962            );
9963            return Err(failure(
9964                NodeFailureCode::ManagedWorktreeRecoveryRequired,
9965                "managed worktree profile revision changed",
9966            ));
9967        }
9968        profile.validate_target_authority(&lease.lease_id, &lease.target_root).map_err(|message| {
9969            self.mark_managed_recovery_required(
9970                lease_id,
9971                ManagedWorktreeCleanupFailure::OwnershipConflict,
9972            );
9973            failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message)
9974        })?;
9975        let source_root = self.workspace_root(&lease.source_workspace_id)?;
9976        let listed = list_git_worktrees(&source_root).await.map_err(|error| {
9977            self.mark_managed_cleanup_blocked(lease_id, ManagedWorktreeCleanupFailure::Backend);
9978            managed_git_worktree_failure(error)
9979        })?;
9980        let target = listed.iter()
9981            .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
9982        if let Some(target) = target {
9983            if target.is_main || target.is_bare || !exact_owned_worktree(
9984                &lease,
9985                &target.path,
9986                target.branch.as_deref(),
9987                &target.head,
9988            ) {
9989                self.mark_managed_recovery_required(
9990                    lease_id,
9991                    ManagedWorktreeCleanupFailure::OwnershipConflict,
9992                );
9993                return Err(failure(
9994                    NodeFailureCode::ManagedWorktreeOwnershipConflict,
9995                    "Git worktree identity no longer matches the managed lease",
9996                ));
9997            }
9998            if target.prunable {
9999                self.mark_managed_cleanup_blocked(
10000                    lease_id,
10001                    ManagedWorktreeCleanupFailure::Prunable,
10002                );
10003                return Err(failure(
10004                    NodeFailureCode::ManagedWorktreeRecoveryRequired,
10005                    "managed worktree is prunable and requires recovery",
10006                ));
10007            }
10008            if let Err(error) = remove_git_worktree(&source_root, &target.path).await {
10009                let failure_kind = match error.kind {
10010                    GitWorktreeErrorKind::Dirty => ManagedWorktreeCleanupFailure::Dirty,
10011                    GitWorktreeErrorKind::Locked => ManagedWorktreeCleanupFailure::Locked,
10012                    GitWorktreeErrorKind::Protected => ManagedWorktreeCleanupFailure::OwnershipConflict,
10013                    GitWorktreeErrorKind::Conflict => ManagedWorktreeCleanupFailure::Busy,
10014                    GitWorktreeErrorKind::Invalid | GitWorktreeErrorKind::NotRepository
10015                    | GitWorktreeErrorKind::Failed => ManagedWorktreeCleanupFailure::Backend,
10016                };
10017                if failure_kind == ManagedWorktreeCleanupFailure::OwnershipConflict {
10018                    self.mark_managed_recovery_required(lease_id, failure_kind);
10019                } else {
10020                    self.mark_managed_cleanup_blocked(lease_id, failure_kind);
10021                }
10022                return Err(managed_git_worktree_failure(error));
10023            }
10024        } else {
10025            match std::fs::symlink_metadata(&lease.target_root) {
10026                Err(error) if error.kind() == io::ErrorKind::NotFound => {}
10027                Ok(_) => {
10028                    self.mark_managed_recovery_required(
10029                        lease_id,
10030                        ManagedWorktreeCleanupFailure::OwnershipConflict,
10031                    );
10032                    return Err(failure(
10033                        NodeFailureCode::ManagedWorktreeOwnershipConflict,
10034                        "managed target exists but is not owned by Git",
10035                    ));
10036                }
10037                Err(_) => {
10038                    self.mark_managed_cleanup_blocked(
10039                        lease_id,
10040                        ManagedWorktreeCleanupFailure::Backend,
10041                    );
10042                    return Err(failure(
10043                        NodeFailureCode::BackendOperationFailed,
10044                        "managed target identity could not be inspected",
10045                    ));
10046                }
10047            }
10048        }
10049        let registered = self.workspaces.read()
10050            .unwrap_or_else(|poisoned| poisoned.into_inner())
10051            .contains_key(&lease.workspace_id);
10052        if registered {
10053            self.unregister_workspace(&lease.workspace_id)?;
10054        }
10055        let removed = {
10056            self.managed_worktrees.lock()
10057                .unwrap_or_else(|poisoned| poisoned.into_inner())
10058                .tombstone(lease_id, unix_time_ms())
10059                .expect("managed worktree lease remains present during cleanup")
10060        };
10061        self.persist_state().map_err(persistence_failure)?;
10062        self.publish(NodeEvent::ManagedWorktreeRemoved { lease_id: lease_id.clone() });
10063        Ok(removed.snapshot())
10064    }
10065
10066    fn mark_managed_cleanup_blocked(
10067        &self,
10068        lease_id: &ManagedWorktreeLeaseId,
10069        failure_kind: ManagedWorktreeCleanupFailure,
10070    ) {
10071        let snapshot = {
10072            let mut registry = self.managed_worktrees.lock()
10073                .unwrap_or_else(|poisoned| poisoned.into_inner());
10074            let Some(lease) = registry.get_mut(lease_id) else { return };
10075            lease.state = ManagedWorktreeLeaseState::CleanupBlocked;
10076            lease.cleanup_failure = Some(failure_kind);
10077            lease.updated_at_unix_ms = unix_time_ms();
10078            lease.snapshot()
10079        };
10080        let _ = self.persist_state();
10081        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
10082    }
10083
10084    fn require_worktree_service(
10085        &self,
10086        source_workspace_id: &WorkspaceId,
10087    ) -> Result<(), NodeFailure> {
10088        if self.managed_worktrees.lock()
10089            .unwrap_or_else(|poisoned| poisoned.into_inner())
10090            .lease_for_workspace(source_workspace_id)
10091            .is_some()
10092        {
10093            return Err(failure(
10094                NodeFailureCode::ManagedWorktreeOwnershipConflict,
10095                "managed worktree targets are internal-only workspaces",
10096            ));
10097        }
10098        match self
10099            .worktree_service_modes
10100            .get(source_workspace_id)
10101            .copied()
10102            .unwrap_or(WorktreeServiceMode::Manual)
10103        {
10104            WorktreeServiceMode::Manual => Ok(()),
10105            WorktreeServiceMode::Managed | WorktreeServiceMode::Off => Err(failure(
10106                NodeFailureCode::UnsupportedSpawnCapability,
10107                "Git worktree service mode is unavailable in this node build",
10108            )),
10109        }
10110    }
10111
10112    fn managed_reservation_conflict(
10113        &self,
10114        workspace_id: Option<&WorkspaceId>,
10115        root: Option<&str>,
10116    ) -> bool {
10117        self.managed_worktrees.lock()
10118            .unwrap_or_else(|poisoned| poisoned.into_inner())
10119            .active_records()
10120            .any(|lease| {
10121                workspace_id.is_some_and(|workspace_id| {
10122                    workspace_id == &lease.source_workspace_id
10123                        || workspace_id == &lease.workspace_id
10124                }) || root.is_some_and(|root| worktree_paths_equal(root, &lease.target_root))
10125            })
10126    }
10127
10128    fn reject_managed_reservation(
10129        &self,
10130        workspace_id: Option<&WorkspaceId>,
10131        root: Option<&str>,
10132    ) -> Result<(), NodeFailure> {
10133        if self.managed_reservation_conflict(workspace_id, root) {
10134            Err(failure(
10135                NodeFailureCode::ManagedWorktreeOwnershipConflict,
10136                "managed worktree ownership may only be mutated through its lease cleanup",
10137            ))
10138        } else {
10139            Ok(())
10140        }
10141    }
10142
10143    fn bound_workspace_root(&self, address: &SessionAddress) -> Result<String, NodeFailure> {
10144        self.validate_address(address)?;
10145        self.workspace_root(&address.workspace_id)
10146    }
10147
10148    /// `Resume`/`ResumeWithPrompt` are always a raw-PTY relaunch (see
10149    /// `require_policy`'s own doc on those two requirements), so they keep
10150    /// gating on `binding.runtime_policy`'s PTY-terminal-text-inference
10151    /// flags exactly as before; `pipe_transport`/`acp_transport` stay
10152    /// `false` on the `require_policy` call below for them because a PTY
10153    /// resume never needs those facts, not because this method cannot look
10154    /// them up.
10155    ///
10156    /// `SemanticPrompt` -- the requirement `Prompt`/`Paste` share -- reads
10157    /// the SAME `binding.runtime_policy` every other requirement reads here
10158    /// (one computed policy, read by every gate). That policy is no longer
10159    /// PTY-terminal-verification-shaped for an ACP session --
10160    /// `spawn_session_with_deadline` computes it BY TRANSPORT now, through
10161    /// `provider_runtime::policy_for_transport` -- so consulting it for
10162    /// `Prompt`/`Paste` on ACP is sound where it used to gate the wrong fact
10163    /// (this is what made an ACP `Prompt` come back `UnsupportedCapability`
10164    /// even though a prompt is an ACP session's only interface). Pipe keeps
10165    /// reading the same PTY-probe-derived value it always has --
10166    /// `policy_for_transport` passes Pipe through unchanged this pass (see
10167    /// its own doc for why).
10168    ///
10169    /// What this method still adds on top, for ACP/Pipe only, is a
10170    /// turn-in-flight PRECONDITION ahead of that policy check: a session
10171    /// takes one turn at a time, so a `Prompt`/`Paste` is refused by name
10172    /// (`NodeFailureCode::TurnInFlight`) while a turn is already running,
10173    /// read off the same live `SessionSnapshot` state `require_acp_session`
10174    /// already reads for the ACP control verbs -- `TurnStarted` observed
10175    /// with no matching `TurnCompleted` yet is exactly `ProviderSnapshot::
10176    /// lead_activity != Idle`. Sending a second prompt mid-turn would put
10177    /// its fate in vendor-specific behaviour this node cannot observe (one
10178    /// agent may queue it, another drop it, another interleave it), so this
10179    /// refuses it outright rather than gambling on that -- a rule with no
10180    /// PTY analogue, which is why PTY does not take this extra precondition.
10181    fn require_session_runtime_policy(
10182        &self,
10183        address: &SessionAddress,
10184        requirement: ProviderRuntimeRequirement,
10185    ) -> Result<ProviderRuntimePolicy, NodeFailure> {
10186        let runtime_policy = {
10187            let bindings = self
10188                .session_bindings
10189                .lock()
10190                .unwrap_or_else(|poisoned| poisoned.into_inner());
10191            let binding = bindings
10192                .get(&address.session.instance_id)
10193                .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10194            if binding.workspace_id != address.workspace_id {
10195                return Err(failure(
10196                    NodeFailureCode::SessionWorkspaceMismatch,
10197                    "session belongs to another workspace",
10198                ));
10199            }
10200            if binding.generation != address.session.generation {
10201                return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10202            }
10203            binding.runtime_policy
10204        };
10205        if requirement == ProviderRuntimeRequirement::SemanticPrompt {
10206            let snapshot = self.internal_session_snapshot(address)?;
10207            if matches!(snapshot.transport, TransportKind::Acp | TransportKind::Pipe)
10208                && snapshot.provider.lead_activity != ProviderActivity::Idle
10209            {
10210                return Err(failure(
10211                    NodeFailureCode::TurnInFlight,
10212                    "session already has a turn in flight; wait for it to complete before sending another prompt",
10213                ));
10214            }
10215        }
10216        require_policy(runtime_policy, false, false, requirement)
10217            .map(|()| runtime_policy)
10218            .map_err(|_| failure(
10219                NodeFailureCode::UnsupportedCapability,
10220                "session runtime policy does not admit this semantic operation",
10221            ))
10222    }
10223
10224    fn remove_binding(&self, address: &SessionAddress) -> Option<SessionBinding> {
10225        let mut bindings = self
10226            .session_bindings
10227            .lock()
10228            .unwrap_or_else(|poisoned| poisoned.into_inner());
10229        if bindings.get(&address.session.instance_id).is_some_and(|binding| {
10230            binding.workspace_id == address.workspace_id
10231                && binding.generation == address.session.generation
10232        }) {
10233            let removed = bindings.remove(&address.session.instance_id);
10234            self.clear_terminal_frame_watermark(address);
10235            SessionLifecycleHook::on_binding_removed(self, address, removed.as_ref());
10236            return removed;
10237        }
10238        None
10239    }
10240
10241    fn cleanup_session_owned_materialization(
10242        &self,
10243        address: &SessionAddress,
10244        binding: &SessionBinding,
10245    ) -> Result<(), NodeFailure> {
10246        let Some(id) = binding.materialization_id.as_ref() else {
10247            return Ok(());
10248        };
10249        let owner = self
10250            .materializations
10251            .lock()
10252            .unwrap_or_else(|poisoned| poisoned.into_inner())
10253            .get(id)
10254            .map(|record| record.owner().clone());
10255        match owner {
10256            None | Some(MaterializationOwner::Record { .. }) => Ok(()),
10257            Some(MaterializationOwner::Session {
10258                incarnation_id,
10259                instance_id,
10260                generation,
10261            }) if incarnation_id == self.incarnation_id
10262                && instance_id == address.session.instance_id
10263                && generation == address.session.generation =>
10264            {
10265                self.cleanup_materialization(id)
10266            }
10267            Some(MaterializationOwner::Session { .. }) => Err(failure(
10268                NodeFailureCode::BackendOperationFailed,
10269                "session environment ownership requires recovery",
10270            )),
10271        }
10272    }
10273
10274    fn clear_terminal_frame_watermark(&self, address: &SessionAddress) {
10275        let mut watermarks = self
10276            .terminal_frame_watermarks
10277            .lock()
10278            .unwrap_or_else(|poisoned| poisoned.into_inner());
10279        if watermarks
10280            .get(&address.session.instance_id)
10281            .is_some_and(|(current, _)| current == address)
10282        {
10283            watermarks.remove(&address.session.instance_id);
10284        }
10285    }
10286
10287    #[cfg(test)]
10288    fn address_for(
10289        &self,
10290        instance_id: AgentInstanceId,
10291        generation: SessionGeneration,
10292    ) -> Option<SessionAddress> {
10293        let binding = self.session_bindings
10294            .lock()
10295            .unwrap_or_else(|poisoned| poisoned.into_inner())
10296            .get(&instance_id)
10297            .cloned()?;
10298        (binding.generation == generation).then_some(SessionAddress {
10299            workspace_id: binding.workspace_id,
10300            session: SessionKey { instance_id, generation },
10301        })
10302    }
10303
10304    fn arm_resume(
10305        &self,
10306        address: &SessionAddress,
10307        command_id: CommandId,
10308        runtime_policy: ProviderRuntimePolicy,
10309    ) -> Result<(), NodeFailure> {
10310        let mut bindings = self
10311            .session_bindings
10312            .lock()
10313            .unwrap_or_else(|poisoned| poisoned.into_inner());
10314        let binding = bindings
10315            .get_mut(&address.session.instance_id)
10316            .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10317        if binding.workspace_id != address.workspace_id {
10318            return Err(failure(
10319                NodeFailureCode::SessionWorkspaceMismatch,
10320                "session belongs to another workspace",
10321            ));
10322        }
10323        if binding.generation != address.session.generation {
10324            return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10325        }
10326        binding.pending_resume = Some((binding.generation, command_id, runtime_policy));
10327        Ok(())
10328    }
10329
10330    fn clear_armed_resume(&self, address: &SessionAddress) {
10331        let mut bindings = self
10332            .session_bindings
10333            .lock()
10334            .unwrap_or_else(|poisoned| poisoned.into_inner());
10335        if let Some(binding) = bindings.get_mut(&address.session.instance_id) {
10336            if binding.workspace_id == address.workspace_id
10337                && binding.generation == address.session.generation
10338            {
10339                binding.pending_resume = None;
10340            }
10341        }
10342    }
10343
10344    fn publish_control(&self, event: ControlEvent) {
10345        let revokes_harness_mcp = matches!(
10346            &event.event,
10347            ControlEventKind::Exited { .. }
10348                | ControlEventKind::Failed { .. }
10349                | ControlEventKind::Removed
10350        );
10351        let (address, previous_address, record_id, identity_admitted, resume_command_rejected) = {
10352            let mut bindings = self
10353                .session_bindings
10354                .lock()
10355                .unwrap_or_else(|poisoned| poisoned.into_inner());
10356            let Some(binding) = bindings.get_mut(&event.instance_id) else {
10357                return;
10358            };
10359            let previous_address = SessionAddress {
10360                workspace_id: binding.workspace_id.clone(),
10361                session: SessionKey {
10362                    instance_id: event.instance_id,
10363                    generation: binding.generation,
10364                },
10365            };
10366            let mut resume_command_rejected = false;
10367            if binding.generation == event.generation {
10368                if let Some((pending_generation, pending_command_id, _)) = binding.pending_resume {
10369                    let rejected_resume = matches!(
10370                        &event.event,
10371                        ControlEventKind::CommandRejected { .. }
10372                    ) && event.command_id == Some(pending_command_id);
10373                    let resume_failed = matches!(
10374                        &event.event,
10375                        ControlEventKind::ResumeDenied { .. }
10376                            | ControlEventKind::ResumeFailed { .. }
10377                    );
10378                    if pending_generation == binding.generation
10379                        && (rejected_resume || resume_failed)
10380                    {
10381                        resume_command_rejected = rejected_resume;
10382                        binding.pending_resume = None;
10383                    }
10384                }
10385            } else {
10386                let expected = binding.generation.0.checked_add(1);
10387                let authorized = matches!(&event.event, ControlEventKind::ResumeAuthorized { .. });
10388                if !binding.pending_resume.is_some_and(|(generation, _, _)| {
10389                    generation == binding.generation
10390                })
10391                    || expected != Some(event.generation.0)
10392                    || !authorized
10393                {
10394                    return;
10395                }
10396                let (_, _, runtime_policy) = binding
10397                    .pending_resume
10398                    .expect("authorized resume retains its pending runtime policy");
10399                binding.generation = event.generation;
10400                binding.runtime_policy = runtime_policy;
10401                binding.pending_resume = None;
10402            }
10403            (
10404                SessionAddress {
10405                    workspace_id: binding.workspace_id.clone(),
10406                    session: SessionKey {
10407                        instance_id: event.instance_id,
10408                        generation: event.generation,
10409                    },
10410                },
10411                (previous_address.session.generation != event.generation)
10412                    .then_some(previous_address),
10413                binding.record_id.clone(),
10414                binding.runtime_policy.provider_session_identity,
10415                resume_command_rejected,
10416            )
10417        };
10418        if let Some(previous_address) = previous_address.as_ref() {
10419            self.clear_terminal_frame_watermark(previous_address);
10420        }
10421        if let Some(record_id) = record_id {
10422            self.reconcile_managed_record(
10423                &record_id,
10424                &address,
10425                &event,
10426                identity_admitted,
10427                resume_command_rejected,
10428            );
10429        }
10430        // Every state change a session's process lifecycle can reach gets a
10431        // line here: this is the single chokepoint every `ControlEventKind`
10432        // passes through regardless of transport (PTY/pipe/ACP) or whether it
10433        // came from a fresh spawn or a resume. Per-message provider chatter
10434        // (`ProviderEvent`/`ProviderGap`/terminal frames) is deliberately not
10435        // matched here -- that is exactly the per-frame noise this crate
10436        // family's level discipline forbids at INFO.
10437        match &event.event {
10438            ControlEventKind::Running { process_id } => {
10439                #[cfg(unix)]
10440                if let (Some(proxy), Some(pid)) = (self.signer_proxy.get(), process_id) {
10441                    proxy.attach_pid(&format!("{}-{}", self.incarnation_id, address.session.instance_id.0), *pid);
10442                }
10443                tracing::info!(
10444                    node_id = %self.node_id,
10445                    workspace_id = %address.workspace_id,
10446                    session = ?address.session,
10447                    pid = ?process_id,
10448                    "session process is running",
10449                );
10450            }
10451            ControlEventKind::Exited { exit_code, forced } => {
10452                #[cfg(unix)]
10453                if let Some(proxy) = self.signer_proxy.get().cloned() {
10454                    let key = format!("{}-{}", self.incarnation_id, address.session.instance_id.0);
10455                    if proxy.has_session(&key) {
10456                        // Revoke at core off the event path (blocking uplink call).
10457                        std::thread::spawn(move || proxy.release(&key));
10458                    }
10459                }
10460                tracing::info!(
10461                    node_id = %self.node_id,
10462                    workspace_id = %address.workspace_id,
10463                    session = ?address.session,
10464                    exit_code = ?exit_code,
10465                    forced = forced,
10466                    "session process exited",
10467                );
10468            }
10469            ControlEventKind::Failed { message } => {
10470                tracing::warn!(
10471                    node_id = %self.node_id,
10472                    workspace_id = %address.workspace_id,
10473                    session = ?address.session,
10474                    cause = %message,
10475                    "session failed",
10476                );
10477            }
10478            ControlEventKind::CommandRejected { message } => {
10479                tracing::warn!(
10480                    node_id = %self.node_id,
10481                    workspace_id = %address.workspace_id,
10482                    session = ?address.session,
10483                    cause = %message,
10484                    "session command rejected",
10485                );
10486            }
10487            ControlEventKind::Removed => {
10488                tracing::info!(
10489                    node_id = %self.node_id,
10490                    workspace_id = %address.workspace_id,
10491                    session = ?address.session,
10492                    "session removed",
10493                );
10494            }
10495            ControlEventKind::ResumeAuthorized { session: provider_session } => {
10496                tracing::info!(
10497                    node_id = %self.node_id,
10498                    workspace_id = %address.workspace_id,
10499                    session = ?address.session,
10500                    provider_session = ?provider_session,
10501                    "session resume authorized",
10502                );
10503            }
10504            ControlEventKind::Resumed { session: provider_session, process_id } => {
10505                tracing::info!(
10506                    node_id = %self.node_id,
10507                    workspace_id = %address.workspace_id,
10508                    session = ?address.session,
10509                    provider_session = ?provider_session,
10510                    pid = ?process_id,
10511                    "session resumed",
10512                );
10513            }
10514            ControlEventKind::ResumeDenied { reason } => {
10515                tracing::warn!(
10516                    node_id = %self.node_id,
10517                    workspace_id = %address.workspace_id,
10518                    session = ?address.session,
10519                    cause = %reason,
10520                    "session resume denied",
10521                );
10522            }
10523            ControlEventKind::ResumeFailed { message } => {
10524                tracing::warn!(
10525                    node_id = %self.node_id,
10526                    workspace_id = %address.workspace_id,
10527                    session = ?address.session,
10528                    cause = %message,
10529                    "session resume failed",
10530                );
10531            }
10532            _ => {}
10533        }
10534        self.apply_pending_acp_options(&address, &event);
10535        let stream_chunk = agent_stream_chunk(&event);
10536        self.publish(NodeEvent::Control {
10537            address: address.clone(),
10538            event,
10539        });
10540        if let Some(chunk) = stream_chunk {
10541            self.publish_agent_stream_chunk(address.clone(), chunk);
10542        }
10543        if revokes_harness_mcp {
10544            SessionLifecycleHook::on_session_ended(self, &address);
10545        }
10546    }
10547
10548    fn reconcile_managed_record(
10549        &self,
10550        record_id: &SessionRecordId,
10551        address: &SessionAddress,
10552        event: &ControlEvent,
10553        identity_admitted: bool,
10554        resume_command_rejected: bool,
10555    ) {
10556        let _transaction = self
10557            .state_transaction
10558            .lock()
10559            .unwrap_or_else(|poisoned| poisoned.into_inner());
10560        let observed_identity = match &event.event {
10561            ControlEventKind::ProviderEvent {
10562                event: gate4agent_types::ProviderEvent::SessionIdentityObserved { identity },
10563                ..
10564            } if identity_admitted => Some(identity.clone()),
10565            _ => None,
10566        };
10567        let identity_was_observed = observed_identity.is_some();
10568        let replacement_id = observed_identity
10569            .as_ref()
10570            .and_then(|_| self.allocate_record_id().ok());
10571        let mut upserts = Vec::new();
10572        let mut removals = Vec::new();
10573        let mut rebind = None;
10574        let original_records;
10575        let original_materializations = self
10576            .materializations
10577            .lock()
10578            .unwrap_or_else(|poisoned| poisoned.into_inner())
10579            .clone();
10580        {
10581            let mut records = self
10582                .session_records
10583                .lock()
10584                .unwrap_or_else(|poisoned| poisoned.into_inner());
10585            original_records = records.records.clone();
10586            let Some(current_snapshot) = records.records.get(record_id).cloned() else {
10587                return;
10588            };
10589            if let Some(identity) = observed_identity {
10590                let existing_id = records
10591                    .records
10592                    .values()
10593                    .find(|record| {
10594                        record.record_id != *record_id
10595                            && record.provider_session.as_ref().is_some_and(|record_identity| {
10596                                session_registry::same_provider_session(
10597                                    &record.provider,
10598                                    record_identity,
10599                                    &current_snapshot.provider,
10600                                    &identity,
10601                                )
10602                            })
10603                    })
10604                    .map(|record| record.record_id.clone());
10605                if let Some(existing_id) = existing_id {
10606                    let scope_matches = records.records.get(&existing_id).is_some_and(|record| {
10607                        record.mode == current_snapshot.mode
10608                            && record.workspace_id == current_snapshot.workspace_id
10609                            && record.environment_profile == current_snapshot.environment_profile
10610                            && record.bundle == current_snapshot.bundle
10611                            && record.context_id == current_snapshot.context_id
10612                            && record.context == current_snapshot.context
10613                            && record
10614                                .canonical_root
10615                                .as_utf8()
10616                                .zip(current_snapshot.canonical_root.as_utf8())
10617                                .is_some_and(|(left, right)| platform::roots_equal(left, right))
10618                    });
10619                    if !scope_matches {
10620                        if let Some(current) = records.records.get_mut(record_id) {
10621                            current.state = ManagedSessionState::Unavailable;
10622                            current.last_error =
10623                                Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR.to_owned());
10624                            current.updated_at_unix_ms = unix_time_ms();
10625                            upserts.push(current.clone());
10626                        }
10627                    } else {
10628                        let existing_is_active = records
10629                            .records
10630                            .get(&existing_id)
10631                            .is_some_and(|record| record.active_session.is_some());
10632                        if existing_is_active {
10633                            if let Some(current) = records.records.get_mut(record_id) {
10634                                current.state = ManagedSessionState::Unavailable;
10635                                current.last_error =
10636                                    Some(PROVIDER_SESSION_LIVE_CONFLICT_ERROR.to_owned());
10637                                current.updated_at_unix_ms = unix_time_ms();
10638                                upserts.push(current.clone());
10639                            }
10640                        } else {
10641                            if current_snapshot.provider_session.is_some() {
10642                                if let Some(current) = records.records.get_mut(record_id) {
10643                                    current.active_session = None;
10644                                    current.state = ManagedSessionState::Dormant;
10645                                    current.updated_at_unix_ms = unix_time_ms();
10646                                    upserts.push(current.clone());
10647                                }
10648                            } else {
10649                                records.records.remove(record_id);
10650                                removals.push(record_id.clone());
10651                            }
10652                            if let Some(existing) = records.records.get_mut(&existing_id) {
10653                                existing.provider_session = Some(identity.clone());
10654                                existing.active_session = Some(address.clone());
10655                                existing.state = ManagedSessionState::Live;
10656                                existing.updated_at_unix_ms = unix_time_ms();
10657                                existing.last_error = None;
10658                                upserts.push(existing.clone());
10659                            }
10660                            rebind = Some(existing_id);
10661                        }
10662                    }
10663                } else if current_snapshot
10664                    .provider_session
10665                    .as_ref()
10666                    .is_some_and(|current| {
10667                        !session_registry::same_provider_session(
10668                            &current_snapshot.provider,
10669                            current,
10670                            &current_snapshot.provider,
10671                            &identity,
10672                        )
10673                    })
10674                {
10675                    if let Some(current) = records.records.get_mut(record_id) {
10676                        current.active_session = None;
10677                        if current.bundle.is_some() || current.context.is_some() {
10678                            let had_bundle = current.bundle.is_some();
10679                            current.provider_session = None;
10680                            current.bundle = None;
10681                            current.context_id = None;
10682                            current.context = None;
10683                            current.state = ManagedSessionState::Unavailable;
10684                            current.last_error = Some(if had_bundle {
10685                                "bundle-unavailable".to_owned()
10686                            } else {
10687                                "context-unavailable".to_owned()
10688                            });
10689                        } else {
10690                            current.state = ManagedSessionState::Dormant;
10691                        }
10692                        current.updated_at_unix_ms = unix_time_ms();
10693                        upserts.push(current.clone());
10694                    }
10695                    if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
10696                        if let Some(current) = records.records.get_mut(record_id) {
10697                            current.state = ManagedSessionState::Unavailable;
10698                            current.last_error =
10699                                Some(MANAGED_SESSION_CAPACITY_ERROR.to_owned());
10700                            current.updated_at_unix_ms = unix_time_ms();
10701                            upserts.push(current.clone());
10702                        }
10703                    } else if let Some(new_record_id) = replacement_id {
10704                        let now = unix_time_ms();
10705                        let replacement = ManagedSessionRecord {
10706                            record_id: new_record_id.clone(),
10707                            display_name: format!(
10708                                "{} #{}",
10709                                current_snapshot.provider.as_str(),
10710                                records
10711                                    .records
10712                                    .values()
10713                                    .filter(|record| {
10714                                        record.provider == current_snapshot.provider
10715                                    })
10716                                    .count()
10717                                    .saturating_add(1),
10718                            ),
10719                            provider: current_snapshot.provider.clone(),
10720                            mode: current_snapshot.mode,
10721                            state: ManagedSessionState::Live,
10722                            workspace_id: current_snapshot.workspace_id,
10723                            canonical_root: current_snapshot.canonical_root,
10724                            provider_session: Some(identity),
10725                            active_session: Some(address.clone()),
10726                            environment_profile: current_snapshot.environment_profile,
10727                            bundle: current_snapshot.bundle,
10728                            context_id: current_snapshot.context_id,
10729                            context: current_snapshot.context,
10730                            // This is a fresh record_id representing the session under its
10731                            // newly-observed identity; it has not yet produced any clean
10732                            // exit of its own, so it starts with no exported pack (the old
10733                            // record, still addressable under its own record_id, keeps
10734                            // whatever exported_context it already had).
10735                            exported_context: None,
10736                            task_binding: current_snapshot.task_binding,
10737                            created_at_unix_ms: now,
10738                            updated_at_unix_ms: now,
10739                            last_error: None,
10740                        };
10741                        records
10742                            .records
10743                            .insert(new_record_id.clone(), replacement.clone());
10744                        upserts.push(replacement);
10745                        rebind = Some(new_record_id);
10746                    } else if let Some(current) = records.records.get_mut(record_id) {
10747                        current.state = ManagedSessionState::Unavailable;
10748                        current.last_error =
10749                            Some(PROVIDER_IDENTITY_ALLOCATION_ERROR.to_owned());
10750                        upserts.push(current.clone());
10751                    }
10752                } else if let Some(current) = records.records.get_mut(record_id) {
10753                    current.provider_session = Some(identity);
10754                    current.active_session = Some(address.clone());
10755                    current.state = ManagedSessionState::Live;
10756                    current.updated_at_unix_ms = unix_time_ms();
10757                    current.last_error = None;
10758                    upserts.push(current.clone());
10759                }
10760            } else if let Some(current) = records.records.get_mut(record_id) {
10761                if resume_command_rejected {
10762                    current.active_session = None;
10763                    current.state = detached_record_state(current);
10764                    current.updated_at_unix_ms = unix_time_ms();
10765                    current.last_error =
10766                        Some(PROVIDER_RESUME_REJECTED_ERROR.to_owned());
10767                    upserts.push(current.clone());
10768                } else {
10769                    match &event.event {
10770                    ControlEventKind::Running { .. }
10771                    | ControlEventKind::ResumeAuthorized { .. }
10772                    | ControlEventKind::Resumed { .. } => {
10773                        current.active_session = Some(address.clone());
10774                        current.state = active_record_state(
10775                            identity_admitted,
10776                            current.provider_session.is_some(),
10777                        );
10778                        current.updated_at_unix_ms = unix_time_ms();
10779                        current.last_error = None;
10780                        upserts.push(current.clone());
10781                    }
10782                    ControlEventKind::Exited { .. } | ControlEventKind::Removed => {
10783                        current.active_session = None;
10784                        current.state = detached_record_state(current);
10785                        current.updated_at_unix_ms = unix_time_ms();
10786                        upserts.push(current.clone());
10787                    }
10788                    ControlEventKind::Failed { message }
10789                    | ControlEventKind::ResumeFailed { message } => {
10790                        current.active_session = None;
10791                        current.state = detached_record_state(current);
10792                        current.updated_at_unix_ms = unix_time_ms();
10793                        current.last_error = Some(
10794                            session_registry::sanitized_record_error_summary(message),
10795                        );
10796                        upserts.push(current.clone());
10797                    }
10798                    ControlEventKind::ResumeDenied { reason } => {
10799                        current.active_session = None;
10800                        current.state = detached_record_state(current);
10801                        current.updated_at_unix_ms = unix_time_ms();
10802                        current.last_error = Some(
10803                            session_registry::sanitized_record_error_summary(reason),
10804                        );
10805                        upserts.push(current.clone());
10806                    }
10807                        _ => {}
10808                    }
10809                }
10810            }
10811        }
10812        let materialization_owner_record = identity_was_observed.then(|| {
10813            rebind.clone().unwrap_or_else(|| record_id.clone())
10814        }).and_then(|candidate| {
10815            self.session_records
10816                .lock()
10817                .unwrap_or_else(|poisoned| poisoned.into_inner())
10818                .records
10819                .get(&candidate)
10820                .filter(|record| {
10821                    record.provider_session.is_some()
10822                        && record.active_session.as_ref() == Some(address)
10823                })
10824                .map(|_| candidate)
10825        });
10826        if let Some(owner_record_id) = materialization_owner_record {
10827            let materialization_id = self
10828                .session_bindings
10829                .lock()
10830                .unwrap_or_else(|poisoned| poisoned.into_inner())
10831                .get(&address.session.instance_id)
10832                .and_then(|binding| binding.materialization_id.clone());
10833            if let Some(materialization_id) = materialization_id {
10834                let mut transfer_failed = false;
10835                {
10836                    let mut materializations = self
10837                        .materializations
10838                        .lock()
10839                        .unwrap_or_else(|poisoned| poisoned.into_inner());
10840                    if let Some(ownership) = materializations.get_mut(&materialization_id) {
10841                        match ownership.owner() {
10842                            MaterializationOwner::Session {
10843                                incarnation_id,
10844                                instance_id,
10845                                generation,
10846                            } if *incarnation_id == self.incarnation_id
10847                                && *instance_id == address.session.instance_id
10848                                && *generation == address.session.generation =>
10849                            {
10850                                transfer_failed = ownership
10851                                    .transfer_to_record(owner_record_id.clone(), unix_time_ms())
10852                                    .is_err();
10853                            }
10854                            MaterializationOwner::Record { record_id }
10855                                if record_id == &owner_record_id => {}
10856                            MaterializationOwner::Record { record_id } => {
10857                                let expected = record_id.clone();
10858                                transfer_failed = ownership
10859                                    .transfer_record_owner(
10860                                        &expected,
10861                                        owner_record_id.clone(),
10862                                        unix_time_ms(),
10863                                    )
10864                                    .is_err();
10865                            }
10866                            _ => transfer_failed = true,
10867                        }
10868                        if transfer_failed {
10869                            let _ = ownership.mark_recovery_required(unix_time_ms());
10870                        }
10871                    } else {
10872                        transfer_failed = true;
10873                    }
10874                }
10875                if transfer_failed {
10876                    if let Some(record) = self
10877                        .session_records
10878                        .lock()
10879                        .unwrap_or_else(|poisoned| poisoned.into_inner())
10880                        .records
10881                        .get_mut(&owner_record_id)
10882                    {
10883                        record.state = ManagedSessionState::Unavailable;
10884                        record.last_error = Some("environment-profile-unavailable".to_owned());
10885                        record.updated_at_unix_ms = unix_time_ms();
10886                        upserts.push(record.clone());
10887                    }
10888                }
10889            }
10890        }
10891        if let Some(new_record_id) = rebind.clone() {
10892            if let Some(binding) = self
10893                .session_bindings
10894                .lock()
10895                .unwrap_or_else(|poisoned| poisoned.into_inner())
10896                .get_mut(&address.session.instance_id)
10897            {
10898                binding.record_id = Some(new_record_id);
10899            }
10900        }
10901        if upserts.is_empty() && removals.is_empty() {
10902            return;
10903        }
10904        if self.persist_state_locked().is_err() {
10905            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
10906            let mut records = self
10907                .session_records
10908                .lock()
10909                .unwrap_or_else(|poisoned| poisoned.into_inner());
10910            records.records = original_records;
10911            *self
10912                .materializations
10913                .lock()
10914                .unwrap_or_else(|poisoned| poisoned.into_inner()) = original_materializations;
10915            if let Some(current) = records.records.get_mut(record_id) {
10916                current.state = ManagedSessionState::Unavailable;
10917                current.last_error = Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned());
10918                current.updated_at_unix_ms = unix_time_ms();
10919                upserts = vec![current.clone()];
10920            } else {
10921                upserts.clear();
10922            }
10923            removals.clear();
10924            drop(records);
10925            if let Some(binding) = self
10926                .session_bindings
10927                .lock()
10928                .unwrap_or_else(|poisoned| poisoned.into_inner())
10929                .get_mut(&address.session.instance_id)
10930            {
10931                binding.record_id = Some(record_id.clone());
10932            }
10933            eprintln!("gate4agent-node: {DURABLE_STATE_COMMIT_FAILED_ERROR}");
10934        }
10935        for record in upserts {
10936            self.publish_record(record);
10937        }
10938        for record_id in removals {
10939            self.publish(NodeEvent::SessionRecordRemoved { record_id });
10940        }
10941    }
10942
10943    fn snapshot(&self) -> NodeSnapshot {
10944        let control = self.handle.snapshot();
10945        let bindings = self
10946            .session_bindings
10947            .lock()
10948            .unwrap_or_else(|poisoned| poisoned.into_inner())
10949            .clone();
10950        let workspace_roots = self
10951            .workspaces
10952            .read()
10953            .unwrap_or_else(|poisoned| poisoned.into_inner())
10954            .clone();
10955        let environment_profiles = self
10956            .environment_profiles
10957            .read()
10958            .unwrap_or_else(|poisoned| poisoned.into_inner());
10959        let mut workspaces = workspace_roots
10960            .iter()
10961            .map(|(workspace_id, canonical_root)| {
10962                (
10963                    workspace_id.clone(),
10964                    WorkspaceSnapshot {
10965                        workspace_id: workspace_id.clone(),
10966                        canonical_root: opaque_windows_path(canonical_root.clone()),
10967                        sessions: Vec::new(),
10968                        worktree_service_mode: Some(
10969                            self.worktree_service_modes
10970                                .get(workspace_id)
10971                                .copied()
10972                                .unwrap_or(WorktreeServiceMode::Manual),
10973                        ),
10974                        managed_worktree_profiles: Some(WorktreeProfileInventory {
10975                            profiles: self
10976                                .managed_worktree_profiles
10977                                .get(workspace_id)
10978                                .into_iter()
10979                                .flat_map(BTreeMap::values)
10980                                .map(|profile| ManagedWorktreeProfileSummary {
10981                                    id: profile.profile_id().clone(),
10982                                    revision: profile.revision().clone(),
10983                                    retention: profile.retention(),
10984                                })
10985                                .collect(),
10986                        }),
10987                    },
10988                )
10989            })
10990            .collect::<BTreeMap<_, _>>();
10991        let mut agent_progress = Vec::new();
10992        for session in &control.sessions {
10993            let Some(binding) = bindings.get(&session.instance_id) else {
10994                continue;
10995            };
10996            if binding.generation != session.generation {
10997                continue;
10998            }
10999            if let Some(workspace) = workspaces.get_mut(&binding.workspace_id) {
11000                let address = SessionAddress {
11001                    workspace_id: binding.workspace_id.clone(),
11002                    session: SessionKey {
11003                        instance_id: session.instance_id,
11004                        generation: session.generation,
11005                    },
11006                };
11007                if let Some(progress) =
11008                    agent_progress_from_provider_snapshot(address, &session.provider)
11009                {
11010                    agent_progress.push(progress);
11011                }
11012                workspace.sessions.push(session.clone());
11013            }
11014        }
11015        NodeSnapshot {
11016            node_id: self.node_id.clone(),
11017            enabled_providers: self.enabled_providers.clone(),
11018            provider_runtime_statuses: {
11019                let updates = self
11020                    .provider_runtime_status_updates
11021                    .read()
11022                    .unwrap_or_else(|poisoned| poisoned.into_inner());
11023                ProviderRuntimeStatuses::new(
11024                    self.provider_runtime_statuses
11025                        .iter()
11026                        .filter(|status| !updates.contains_key(status.provider()))
11027                        .cloned()
11028                        .chain(updates.values().cloned()),
11029                )
11030                .expect("refreshed provider runtime inventory remains bounded and unique")
11031            },
11032            workspaces: workspaces.into_values().collect(),
11033            session_records: self
11034                .session_records
11035                .lock()
11036                .unwrap_or_else(|poisoned| poisoned.into_inner())
11037                .records
11038                .values()
11039                .cloned()
11040                .collect(),
11041            managed_worktrees: self.managed_worktrees
11042                .lock()
11043                .unwrap_or_else(|poisoned| poisoned.into_inner())
11044                .snapshots(),
11045            launch_inventory: Some(LaunchInventory {
11046                spawn_profiles: Some(
11047                    self.spawn_profiles
11048                        .iter()
11049                        .filter_map(|profile| {
11050                            resolved_spawn_profile_summary(profile, &environment_profiles)
11051                        })
11052                        .collect(),
11053                ),
11054                bundles: Some(
11055                    self.bundle_catalog
11056                        .read()
11057                        .unwrap_or_else(|poisoned| poisoned.into_inner())
11058                        .iter()
11059                        .map(NodeBundle::receipt)
11060                        .collect(),
11061                ),
11062                // Opaque station allowlist ids from empty-default catalog /
11063                // optional startup loader. Never secrets. Dig2 probe stays stub.
11064                network_allowlists: {
11065                    let catalog = self
11066                        .network_allowlist_catalog
11067                        .read()
11068                        .unwrap_or_else(|poisoned| poisoned.into_inner());
11069                    if catalog.is_empty() {
11070                        None
11071                    } else {
11072                        Some(catalog.ids().cloned().collect())
11073                    }
11074                },
11075            }),
11076            agent_progress,
11077        }
11078    }
11079
11080    fn publish(&self, event: NodeEvent) -> NodeEventEnvelope {
11081        assert!(
11082            !matches!(
11083                &event,
11084                NodeEvent::TerminalFrame { .. } | NodeEvent::AgentStream { .. }
11085            ),
11086            "terminal frame and agent stream events must use their own dedicated live channel",
11087        );
11088        let mut history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11089        let sequence = history
11090            .last_sequence
11091            .checked_add(1)
11092            .expect("node event sequence exhausted");
11093        if history.events.len() == NODE_EVENT_HISTORY_MAX {
11094            if let Some(evicted) = history.events.pop_front() {
11095                history.replay_floor_sequence = evicted
11096                    .sequence
11097                    .checked_add(1)
11098                    .expect("node replay floor sequence exhausted");
11099                history.removed_record_providers.remove(&evicted.sequence);
11100            }
11101        }
11102        match &event {
11103            NodeEvent::HarnessMcpReadCall { .. } => {
11104                panic!("transient harness MCP calls must not enter durable event history")
11105            }
11106            NodeEvent::SessionRecordUpserted { record } => {
11107                history
11108                    .record_providers
11109                    .insert(record.record_id.clone(), record.provider.clone());
11110            }
11111            NodeEvent::SessionRecordRemoved { record_id } => {
11112                if let Some(provider) = history.record_providers.remove(record_id) {
11113                    history.removed_record_providers.insert(sequence, provider);
11114                }
11115            }
11116            NodeEvent::ManagedWorktreeUpserted { .. }
11117            | NodeEvent::ManagedWorktreeRemoved { .. }
11118            | NodeEvent::Control { .. }
11119            | NodeEvent::ControllerChanged { .. }
11120            | NodeEvent::SessionRecordHistorySummarized { .. }
11121            | NodeEvent::WorkspaceAdded { .. }
11122            | NodeEvent::WorkspaceRemoved { .. }
11123            | NodeEvent::ResyncRequired { .. }
11124            | NodeEvent::AgentStream { .. }
11125            | NodeEvent::TerminalFrame { .. } => {}
11126        }
11127        let envelope = NodeEventEnvelope { sequence, event };
11128        history.events.push_back(envelope.clone());
11129        history.last_sequence = sequence;
11130        let _ = self.event_tx.send(envelope.clone());
11131        drop(history);
11132        envelope
11133    }
11134
11135    /// Publishes a transient (never durable) `NodeEvent` -- today only
11136    /// `NodeEvent::HarnessMcpReadCall` -- onto the dedicated
11137    /// `harness_mcp_proxy_event_tx` broadcast (see that field's own doc for
11138    /// why this must never ride the shared `event_tx` bus). Returns whether
11139    /// the send reached at least one subscriber: `broadcast::Sender::send`
11140    /// fails in exactly one way, no active receiver at that instant, and
11141    /// the caller (`HarnessMcpProxyRegistry::begin_call`) uses a `false`
11142    /// return to fail the pending call immediately rather than leave it to
11143    /// expire silently at `LOCAL_READ_TIMEOUT`/
11144    /// `MAX_HARNESS_MCP_CALL_DEADLINE_MS`.
11145    fn publish_transient(&self, event: NodeEvent) -> bool {
11146        assert!(event.requires_harness_mcp_proxy_capability());
11147        assert!(event.harness_mcp_contract_is_valid_at(unix_time_ms()));
11148        let envelope = NodeEventEnvelope { sequence: 0, event };
11149        match self.harness_mcp_proxy_event_tx.send(Arc::new(vec![envelope])) {
11150            Ok(_) => true,
11151            Err(broadcast::error::SendError(undelivered)) => {
11152                if let Some(NodeEventEnvelope {
11153                    event: NodeEvent::HarnessMcpReadCall { call_id, request, .. },
11154                    ..
11155                }) = undelivered.first() {
11156                    // `request.content_type` only, never a per-tool label --
11157                    // this crate does not decode `request.body` and does not
11158                    // hold `hatchery-harness-api` (Nested Control Plane
11159                    // doctrine, Law 3; see this crate's dependency on
11160                    // `gate4agent-node-protocol`'s own `Forbidden:` line).
11161                    tracing::warn!(
11162                        call_id = call_id.as_str(),
11163                        content_type = ?request.content_type,
11164                        cause = "no-subscribers",
11165                        "harness MCP proxy event could not be published",
11166                    );
11167                }
11168                false
11169            }
11170        }
11171    }
11172
11173    fn publish_terminal_frames(&self) {
11174        let snapshot = self.handle.snapshot();
11175        let bindings = self
11176            .session_bindings
11177            .lock()
11178            .unwrap_or_else(|poisoned| poisoned.into_inner());
11179        let candidates = snapshot
11180            .sessions
11181            .iter()
11182            .filter_map(|session| {
11183                let binding = bindings.get(&session.instance_id)?;
11184                if binding.generation != session.generation {
11185                    return None;
11186                }
11187                let frame = session.terminal_frame.clone()?;
11188                crate::session_signals::feed(
11189                    binding.workspace_id.as_str(),
11190                    session.instance_id.0,
11191                    session.agent_id.as_str(),
11192                    &frame.contents,
11193                );
11194                Some((
11195                    SessionAddress {
11196                        workspace_id: binding.workspace_id.clone(),
11197                        session: SessionKey {
11198                            instance_id: session.instance_id,
11199                            generation: session.generation,
11200                        },
11201                    },
11202                    frame,
11203                ))
11204            })
11205            .collect();
11206        drop(bindings);
11207        self.publish_terminal_frame_candidates(candidates);
11208    }
11209
11210    fn publish_terminal_frame_candidates(
11211        &self,
11212        candidates: Vec<(SessionAddress, TerminalFrame)>,
11213    ) {
11214        let mut watermarks = self
11215            .terminal_frame_watermarks
11216            .lock()
11217            .unwrap_or_else(|poisoned| poisoned.into_inner());
11218        let mut events = Vec::with_capacity(candidates.len());
11219        for (address, frame) in candidates {
11220            let is_first_frame = !watermarks.contains_key(&address.session.instance_id);
11221            let advanced = watermarks
11222                .get(&address.session.instance_id)
11223                .map_or(true, |(current_address, sequence)| {
11224                    current_address != &address || frame.sequence > *sequence
11225                });
11226            if !advanced {
11227                continue;
11228            }
11229            if is_first_frame {
11230                // Do NOT sessionize on first PTY output: a session is a
11231                // provider conversation id, not bytes on the wire. Drafts
11232                // leave the provisional map only when HQ attaches
11233                // (Resize / TerminalBytes). The attach-never-arrived timer
11234                // (PROVISIONAL_SPAWN_GRACE_MS) covers deploy lag; HQ's
11235                // disconnect grace starts only on a real client WS close.
11236                // This is the first PTY output this node has observed for
11237                // this session -- a one-time event per spawn, not per-frame
11238                // chatter (the watermark makes sure of that). A session that
11239                // reaches this line with no controller attached anywhere on
11240                // the node is running unobserved: exactly how processes from
11241                // repeated spawn attempts went orphaned for hours without a
11242                // single line about it.
11243                if self.controller_state().is_none() {
11244                    tracing::warn!(
11245                        node_id = %self.node_id,
11246                        workspace_id = %address.workspace_id,
11247                        session = ?address.session,
11248                        terminal_size = ?frame.size,
11249                        "session producing output with no controller attached",
11250                    );
11251                } else {
11252                    tracing::info!(
11253                        node_id = %self.node_id,
11254                        workspace_id = %address.workspace_id,
11255                        session = ?address.session,
11256                        terminal_size = ?frame.size,
11257                        "session produced its first output",
11258                    );
11259                }
11260            }
11261            watermarks.insert(
11262                address.session.instance_id,
11263                (address.clone(), frame.sequence),
11264            );
11265            if let Some((at, reported)) = self
11266                .pty_input_marks
11267                .lock()
11268                .unwrap_or_else(|poisoned| poisoned.into_inner())
11269                .get_mut(&address.session.instance_id)
11270            {
11271                // First frame after an input: PTY write -> output published.
11272                if !*reported && at.elapsed() < Duration::from_secs(5) {
11273                    *reported = true;
11274                    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");
11275                }
11276            }
11277            events.push(NodeEvent::TerminalFrame { address, frame });
11278        }
11279        drop(watermarks);
11280        if events.is_empty() {
11281            return;
11282        }
11283        let mut history = self
11284            .history
11285            .lock()
11286            .unwrap_or_else(|poisoned| poisoned.into_inner());
11287        let mut envelopes = Vec::with_capacity(events.len());
11288        for event in events {
11289            let sequence = history
11290                .last_sequence
11291                .checked_add(1)
11292                .expect("node event sequence exhausted");
11293            history.last_sequence = sequence;
11294            envelopes.push(NodeEventEnvelope { sequence, event });
11295        }
11296        let _ = self.terminal_event_tx.send(Arc::new(envelopes));
11297        drop(history);
11298    }
11299
11300    /// Publishes one `AgentStreamChunkV1` onto the dedicated agent-stream
11301    /// channel, mirroring `publish_terminal_frame_candidates` above: the
11302    /// envelope's `sequence` is drawn from the same `history.last_sequence`
11303    /// counter every other event kind shares, never a counter of its own --
11304    /// `queue_connection_event`/`queue_connection_event_batch` compare
11305    /// `envelope.sequence` against one `discard_events_through` watermark
11306    /// regardless of which broadcast channel it arrived on, so a
11307    /// per-channel counter would desynchronize that comparison.
11308    fn publish_agent_stream_chunk(&self, address: SessionAddress, chunk: AgentStreamChunkV1) {
11309        let mut history = self
11310            .history
11311            .lock()
11312            .unwrap_or_else(|poisoned| poisoned.into_inner());
11313        let sequence = history
11314            .last_sequence
11315            .checked_add(1)
11316            .expect("node event sequence exhausted");
11317        history.last_sequence = sequence;
11318        drop(history);
11319        // Debug, not info/warn -- this fires on every single chunk, but it
11320        // is exactly the counter the next drop-measurement pass needs: what
11321        // the node actually emitted (`source_sequence`, `kind`) versus what
11322        // a harness subscriber's own forwarder later logs having received
11323        // (`hatchery-harness-service::runtime`'s "forwarding agent stream
11324        // event"), so a gap between the two logs names itself instead of
11325        // needing a new counter added ad hoc.
11326        tracing::debug!(
11327            source_sequence = chunk.source_sequence,
11328            kind = agent_stream_chunk_kind_label(&chunk.kind),
11329            envelope_sequence = sequence,
11330            "publishing agent stream chunk",
11331        );
11332        let envelope = NodeEventEnvelope {
11333            sequence,
11334            event: NodeEvent::AgentStream { address, chunk },
11335        };
11336        let _ = self.agent_stream_event_tx.send(Arc::new(vec![envelope]));
11337    }
11338
11339    fn current_sequence(&self) -> u64 {
11340        self.history
11341            .lock()
11342            .unwrap_or_else(|poisoned| poisoned.into_inner())
11343            .last_sequence
11344    }
11345
11346    fn controller_state(&self) -> Option<ControllerState> {
11347        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11348        let lease = controller.as_ref().copied()?;
11349        let remaining = lease.expires_at.saturating_duration_since(Instant::now());
11350        if remaining.is_zero() {
11351            *controller = None;
11352            return None;
11353        }
11354        Some(ControllerState {
11355            connection_id: lease.connection_id,
11356            lease_remaining_ms: remaining.as_millis().min(u64::MAX as u128) as u64,
11357        })
11358    }
11359
11360    fn acquire_controller(&self, connection_id: u64, role: ClientRole, lease_ms: u64) -> Result<ControllerState, NodeFailure> {
11361        if role != ClientRole::Operator {
11362            tracing::warn!(
11363                node_id = %self.node_id,
11364                connection_id,
11365                "controller lease rejected: observer connections cannot acquire control",
11366            );
11367            return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections cannot acquire control"));
11368        }
11369        let lease_ms = if lease_ms == 0 {
11370            DEFAULT_CONTROLLER_LEASE_MS
11371        } else {
11372            lease_ms.clamp(MIN_CONTROLLER_LEASE_MS, MAX_CONTROLLER_LEASE_MS)
11373        };
11374        let now = Instant::now();
11375        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11376        if let Some(active) = controller.as_ref() {
11377            if active.connection_id != connection_id && active.expires_at > now {
11378                tracing::warn!(
11379                    node_id = %self.node_id,
11380                    connection_id,
11381                    holder_connection_id = active.connection_id,
11382                    "controller lease rejected: another operator holds it",
11383                );
11384                return Err(failure(NodeFailureCode::ControllerBusy, "another operator holds the controller lease"));
11385            }
11386        }
11387        // A reconnect (this connection already holds the lease, renewing
11388        // before expiry) happens on a periodic cadence; logging it at INFO
11389        // every renewal would be exactly the heartbeat chatter this crate
11390        // family's level discipline forbids. A genuine attach -- the
11391        // controller identity actually changing -- is a state change and
11392        // gets INFO; a same-connection renewal gets DEBUG.
11393        let is_new_attach = match controller.as_ref() {
11394            Some(active) => active.connection_id != connection_id,
11395            None => true,
11396        };
11397        *controller = Some(ControllerLease {
11398            connection_id,
11399            expires_at: now + Duration::from_millis(lease_ms),
11400        });
11401        drop(controller);
11402        let state = ControllerState { connection_id, lease_remaining_ms: lease_ms };
11403        if is_new_attach {
11404            tracing::info!(
11405                node_id = %self.node_id,
11406                connection_id,
11407                lease_ms,
11408                "controller lease acquired",
11409            );
11410        } else {
11411            tracing::debug!(
11412                node_id = %self.node_id,
11413                connection_id,
11414                lease_ms,
11415                "controller lease renewed",
11416            );
11417        }
11418        self.publish(NodeEvent::ControllerChanged { controller: Some(state.clone()) });
11419        Ok(state)
11420    }
11421
11422    fn release_controller(&self, connection_id: u64) -> bool {
11423        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11424        let released = controller.as_ref().is_some_and(|active| active.connection_id == connection_id);
11425        if released {
11426            *controller = None;
11427        }
11428        drop(controller);
11429        if released {
11430            tracing::info!(
11431                node_id = %self.node_id,
11432                connection_id,
11433                "controller lease released",
11434            );
11435            self.publish(NodeEvent::ControllerChanged { controller: None });
11436        }
11437        released
11438    }
11439
11440    /// Mark a fresh Spawn as provisional and arm the attach-never-arrived
11441    /// reaper (PROVISIONAL_SPAWN_GRACE_MS from spawn). Removed from this map
11442    /// only when HQ attaches (Resize / TerminalBytes) — that is *not* a
11443    /// provider session; it only cancels the orphan timer. Disconnect/reload
11444    /// draft kills are owned by HQ and arm only on a real client WS close.
11445    /// ACP path of launch options: once the agent advertises its config options, write the
11446    /// pending model / effort through session/set_config_option (session/set_model when the
11447    /// agent has no model option). Gaps are logged, never forced.
11448    fn apply_pending_acp_options(&self, address: &SessionAddress, event: &ControlEvent) {
11449        let ControlEventKind::ProviderEvent { event: ProviderEvent::ConfigOptionsUpdated { options }, .. } = &event.event else {
11450            return;
11451        };
11452        let id = address.session.instance_id;
11453        let Some((model, effort)) = self.pending_acp_options.lock().unwrap_or_else(|p| p.into_inner()).remove(&id) else {
11454            return;
11455        };
11456        let advertised: Vec<(String, Option<String>, Vec<String>)> = options
11457            .iter()
11458            .map(|o| (o.id.clone(), o.category.clone(), o.choices.iter().map(|c| c.value_json.clone()).collect()))
11459            .collect();
11460        let (writes, set_model, effort_gap) =
11461            backend_paths::acp_option_writes(&advertised, model.as_deref(), effort.as_deref());
11462        if set_model {
11463            if let Some(model_id) = model.clone() {
11464                if let Err(e) = self.dispatch(ControlCommand::SetSessionModel { instance_id: id, model_id }) {
11465                    tracing::warn!(instance_id = id.0, message = %e.message, "acp set_model failed");
11466                }
11467            }
11468        }
11469        for (option_id, value_json) in writes {
11470            if let Err(e) = self.dispatch(ControlCommand::SetSessionConfigOption { instance_id: id, option_id, value_json }) {
11471                tracing::warn!(instance_id = id.0, message = %e.message, "acp set_config_option failed");
11472            }
11473        }
11474        if effort_gap {
11475            tracing::warn!(instance_id = id.0, effort = ?effort, "acp agent advertises no matching thought_level option; effort not applied");
11476        }
11477        tracing::info!(instance_id = id.0, model = ?model, effort = ?effort, "launch options applied over acp");
11478    }
11479
11480    fn mark_spawn_provisional(&self, address: SessionAddress) {
11481        let id = address.session.instance_id;
11482        self.provisional_spawns
11483            .lock()
11484            .unwrap_or_else(|poisoned| poisoned.into_inner())
11485            .insert(id, (address, Instant::now()));
11486        tracing::info!(
11487            node_id = %self.node_id,
11488            instance_id = id.0,
11489            grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
11490            "draft spawn; waiting for HQ view",
11491        );
11492    }
11493
11494    fn sessionize_spawn(&self, instance_id: AgentInstanceId) {
11495        let removed = self
11496            .provisional_spawns
11497            .lock()
11498            .unwrap_or_else(|poisoned| poisoned.into_inner())
11499            .remove(&instance_id);
11500        if removed.is_some() {
11501            tracing::info!(
11502                node_id = %self.node_id,
11503                instance_id = instance_id.0,
11504                "draft spawn claimed by HQ view",
11505            );
11506        }
11507    }
11508
11509    fn take_due_provisional_spawns(&self) -> Vec<SessionAddress> {
11510        let grace = Duration::from_millis(PROVISIONAL_SPAWN_GRACE_MS);
11511        let mut map = self
11512            .provisional_spawns
11513            .lock()
11514            .unwrap_or_else(|poisoned| poisoned.into_inner());
11515        let due: Vec<AgentInstanceId> = map
11516            .iter()
11517            .filter(|(_, (_, at))| at.elapsed() >= grace)
11518            .map(|(id, _)| *id)
11519            .collect();
11520        due.into_iter()
11521            .filter_map(|id| map.remove(&id).map(|(addr, _)| addr))
11522            .collect()
11523    }
11524
11525    async fn reap_provisional_spawn(&self, address: SessionAddress) {
11526        let instance_id = address.session.instance_id;
11527        tracing::warn!(
11528            node_id = %self.node_id,
11529            instance_id = instance_id.0,
11530            workspace = %address.workspace_id,
11531            reason = "attach_never_arrived",
11532            grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
11533            "draft spawn never viewed; stopping PTY",
11534        );
11535        let _ = self.dispatch(ControlCommand::Stop {
11536            instance_id,
11537            force: true,
11538        });
11539        let _ = self.remove_session(&address).await;
11540    }
11541
11542    fn require_controller(&self, connection_id: u64, role: ClientRole) -> Result<(), NodeFailure> {
11543        if role != ClientRole::Operator {
11544            return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections are read-only"));
11545        }
11546        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11547        let now = Instant::now();
11548        match controller.as_ref().copied() {
11549            Some(active) if active.connection_id == connection_id && active.expires_at > now => Ok(()),
11550            Some(active) if active.expires_at <= now => {
11551                *controller = None;
11552                Err(failure(NodeFailureCode::ControllerRequired, "controller lease expired"))
11553            }
11554            _ => Err(failure(NodeFailureCode::ControllerRequired, "controller lease is required")),
11555        }
11556    }
11557
11558    fn validate_address(&self, address: &SessionAddress) -> Result<AgentId, NodeFailure> {
11559        if !self
11560            .workspaces
11561            .read()
11562            .unwrap_or_else(|poisoned| poisoned.into_inner())
11563            .contains_key(&address.workspace_id)
11564        {
11565            return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
11566        }
11567        let binding = self
11568            .session_bindings
11569            .lock()
11570            .unwrap_or_else(|poisoned| poisoned.into_inner())
11571            .get(&address.session.instance_id)
11572            .cloned()
11573            .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
11574        if binding.workspace_id != address.workspace_id {
11575            return Err(failure(
11576                NodeFailureCode::SessionWorkspaceMismatch,
11577                "session belongs to another workspace",
11578            ));
11579        }
11580        if binding.generation != address.session.generation {
11581            return Err(failure(
11582                NodeFailureCode::StaleGeneration,
11583                &format!(
11584                    "stale session generation: expected {}, received {}",
11585                    binding.generation.0,
11586                    address.session.generation.0,
11587                ),
11588            ));
11589        }
11590        let snapshot = self.handle.snapshot();
11591        let Some(current) = snapshot
11592            .sessions
11593            .iter()
11594            .find(|item| item.instance_id == address.session.instance_id)
11595        else {
11596            return Err(failure(NodeFailureCode::UnknownSession, "session instance does not exist"));
11597        };
11598        if current.generation != address.session.generation {
11599            return Err(failure(
11600                NodeFailureCode::StaleGeneration,
11601                &format!(
11602                    "stale session generation: expected {}, received {}",
11603                    current.generation.0,
11604                    address.session.generation.0,
11605                ),
11606            ));
11607        }
11608        Ok(current.agent_id.clone())
11609    }
11610
11611    fn dispatch(&self, command: ControlCommand) -> Result<CommandId, NodeFailure> {
11612        self.dispatch_envelope(self.prepare_command(command))
11613    }
11614
11615    fn prepare_command(&self, command: ControlCommand) -> CommandEnvelope {
11616        let id = self.next_command_id.fetch_add(1, Ordering::AcqRel);
11617        CommandEnvelope {
11618            id: CommandId(id),
11619            command,
11620        }
11621    }
11622
11623    fn dispatch_envelope(&self, envelope: CommandEnvelope) -> Result<CommandId, NodeFailure> {
11624        let command_id = envelope.id;
11625        self.handle
11626            .dispatch(envelope)
11627            .map_err(|error| match error {
11628                PortDispatchError::Full => failure(NodeFailureCode::BackendBusy, "backend command ingress is full"),
11629                PortDispatchError::Disconnected => failure(NodeFailureCode::BackendDisconnected, "backend command ingress is disconnected"),
11630            })?;
11631        Ok(command_id)
11632    }
11633
11634    /// Records the kernel's own reason for asynchronously refusing
11635    /// `command_id` -- see `rejected_command_reasons`'s doc for why a
11636    /// spawn-dispatch waiter otherwise has no way to learn it. Bounded by
11637    /// `REJECTED_COMMAND_REASON_MAX_ENTRIES`: expired entries are swept
11638    /// first, and if the map is still at capacity after that, the single
11639    /// oldest surviving entry is evicted -- `CommandId` is monotonically
11640    /// assigned (`next_command_id.fetch_add`), so the `BTreeMap`'s first key
11641    /// is always the oldest.
11642    fn record_rejected_command(
11643        &self,
11644        command_id: CommandId,
11645        message: String,
11646        unsupported_transport: bool,
11647    ) {
11648        let now = Instant::now();
11649        let mut reasons = self.rejected_command_reasons.lock()
11650            .unwrap_or_else(|poisoned| poisoned.into_inner());
11651        reasons.retain(|_, reason| reason.expires_at > now);
11652        if reasons.len() >= REJECTED_COMMAND_REASON_MAX_ENTRIES {
11653            if let Some(&oldest) = reasons.keys().next() {
11654                reasons.remove(&oldest);
11655            }
11656        }
11657        reasons.insert(
11658            command_id,
11659            RejectedCommandReason {
11660                message,
11661                unsupported_transport,
11662                expires_at: now + Duration::from_millis(REJECTED_COMMAND_REASON_TTL_MS),
11663            },
11664        );
11665    }
11666
11667    /// Removes and returns the kernel's reason for refusing `command_id`,
11668    /// if the drive loop has recorded one and it has not expired -- see
11669    /// `rejected_command_reasons`'s doc. Consumed at most once: a spawn
11670    /// wait loop calls this instead of blindly polling out its whole
11671    /// deadline when the command it is waiting on turns out to have been
11672    /// refused rather than merely slow.
11673    fn take_rejected_command_reason(&self, command_id: CommandId) -> Option<RejectedCommandReason> {
11674        let now = Instant::now();
11675        let mut reasons = self.rejected_command_reasons.lock()
11676            .unwrap_or_else(|poisoned| poisoned.into_inner());
11677        match reasons.remove(&command_id) {
11678            Some(reason) if reason.expires_at > now => Some(reason),
11679            _ => None,
11680        }
11681    }
11682
11683    async fn dispatch_bounded(
11684        &self,
11685        command: ControlCommand,
11686        timeout_duration: Duration,
11687    ) -> Result<CommandId, NodeFailure> {
11688        let deadline = Instant::now() + timeout_duration;
11689        loop {
11690            match self.dispatch(command.clone()) {
11691                Ok(command_id) => return Ok(command_id),
11692                Err(error) if error.code == NodeFailureCode::BackendBusy && Instant::now() < deadline => {
11693                    sleep(Duration::from_millis(2)).await;
11694                }
11695                Err(error) => return Err(error),
11696            }
11697        }
11698    }
11699
11700    fn internal_session_snapshot(
11701        &self,
11702        address: &SessionAddress,
11703    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
11704        self.validate_address(address)?;
11705        self.handle
11706            .snapshot()
11707            .sessions
11708            .clone()
11709            .into_iter()
11710            .find(|session| {
11711                session.instance_id == address.session.instance_id
11712                    && session.generation == address.session.generation
11713            })
11714            .ok_or_else(|| {
11715                failure(
11716                    NodeFailureCode::UnknownSession,
11717                    "session snapshot is unavailable",
11718                )
11719            })
11720    }
11721
11722    /// Confirms `address` names a live session spawned over the ACP
11723    /// transport and returns its current snapshot -- the gate every
11724    /// `acp-control-v1` verb shares. `require_session_runtime_policy`
11725    /// cannot answer this by itself: `ProviderRuntimePolicy` carries no
11726    /// transport bit for `Acp`/`Inline` (see that method's own doc), only
11727    /// the session's own recorded `TransportKind` does, and that lives on
11728    /// the live snapshot, not the binding.
11729    fn require_acp_session(
11730        &self,
11731        address: &SessionAddress,
11732    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
11733        let snapshot = self.internal_session_snapshot(address)?;
11734        if snapshot.transport != TransportKind::Acp {
11735            return Err(failure(
11736                NodeFailureCode::UnsupportedTransport,
11737                "this verb requires a session spawned over the ACP transport",
11738            ));
11739        }
11740        Ok(snapshot)
11741    }
11742
11743    async fn dispatch_history_bounded(
11744        &self,
11745        address: &SessionAddress,
11746        command: ControlCommand,
11747        operation: HistoryOperation,
11748    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
11749        self.validate_address(address)?;
11750        let started_after = self.current_sequence();
11751        let command_id = self
11752            .dispatch_bounded(
11753                command,
11754                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
11755            )
11756            .await?;
11757        let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
11758        let mut scan_after = started_after;
11759        let mut accepted_after = None;
11760        loop {
11761            let events = self
11762                .history
11763                .lock()
11764                .unwrap_or_else(|poisoned| poisoned.into_inner())
11765                .events
11766                .iter()
11767                .filter(|envelope| envelope.sequence > scan_after)
11768                .cloned()
11769                .collect::<Vec<_>>();
11770            for envelope in events {
11771                scan_after = scan_after.max(envelope.sequence);
11772                let NodeEvent::Control {
11773                    address: event_address,
11774                    event,
11775                } = envelope.event
11776                else {
11777                    continue;
11778                };
11779                if event_address != *address {
11780                    continue;
11781                }
11782                if event.command_id == Some(command_id) {
11783                    match &event.event {
11784                        ControlEventKind::CommandRejected { .. } => {
11785                            return Err(failure(
11786                                NodeFailureCode::InvalidRequest,
11787                                "history request was rejected",
11788                            ));
11789                        }
11790                        ControlEventKind::HistoryRequested {
11791                            operation: requested,
11792                            ..
11793                        } if requested == &operation => {
11794                            accepted_after = Some(envelope.sequence);
11795                            continue;
11796                        }
11797                        _ => {}
11798                    }
11799                }
11800                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
11801                    match (&operation, &event.event) {
11802                        (
11803                            HistoryOperation::Discover { .. },
11804                            ControlEventKind::HistoryDiscovered { .. },
11805                        )
11806                        | (
11807                            HistoryOperation::Load { .. },
11808                            ControlEventKind::HistoryLoaded { .. },
11809                        ) => return self.internal_session_snapshot(address),
11810                        (_, ControlEventKind::HistoryFailed { .. }) => {
11811                            return Err(failure(
11812                                NodeFailureCode::BackendOperationFailed,
11813                                "history operation failed",
11814                            ));
11815                        }
11816                        _ => {}
11817                    }
11818                }
11819            }
11820            if Instant::now() >= deadline {
11821                return Err(failure(
11822                    NodeFailureCode::BackendBusy,
11823                    if accepted_after.is_some() {
11824                        "history operation did not settle before the bounded deadline"
11825                    } else {
11826                        "history operation was not accepted before the bounded deadline"
11827                    },
11828                ));
11829            }
11830            sleep(Duration::from_millis(2)).await;
11831        }
11832    }
11833
11834    async fn discover_history(
11835        &self,
11836        address: &SessionAddress,
11837        limit: u16,
11838    ) -> Result<Vec<HistoryCandidateSummary>, NodeFailure> {
11839        let query = HistoryQuery {
11840            working_directory: Some(self.workspace_root(&address.workspace_id)?),
11841            limit,
11842        };
11843        query
11844            .validate()
11845            .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history query"))?;
11846        let snapshot = self
11847            .dispatch_history_bounded(
11848                address,
11849                ControlCommand::DiscoverHistory {
11850                    instance_id: address.session.instance_id,
11851                    query: query.clone(),
11852                },
11853                HistoryOperation::Discover { query },
11854            )
11855            .await?;
11856        if snapshot.history.pending.is_some() || snapshot.history.loaded.is_some() {
11857            return Err(failure(
11858                NodeFailureCode::BackendOperationFailed,
11859                "history discovery settled into an invalid state",
11860            ));
11861        }
11862        Ok(snapshot.history.candidates)
11863    }
11864
11865    async fn load_history(
11866        &self,
11867        address: &SessionAddress,
11868        candidate_id: String,
11869    ) -> Result<HistorySessionRecord, NodeFailure> {
11870        validate_candidate_id(&candidate_id)
11871            .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history candidate"))?;
11872        let snapshot = self
11873            .dispatch_history_bounded(
11874                address,
11875                ControlCommand::LoadHistory {
11876                    instance_id: address.session.instance_id,
11877                    candidate_id: candidate_id.clone(),
11878                },
11879                HistoryOperation::Load {
11880                    candidate_id: candidate_id.clone(),
11881                },
11882            )
11883            .await?;
11884        if snapshot.history.pending.is_some()
11885            || snapshot.history.loaded_candidate_id.as_ref() != Some(&candidate_id)
11886        {
11887            return Err(failure(
11888                NodeFailureCode::BackendOperationFailed,
11889                "history load settled into an invalid state",
11890            ));
11891        }
11892        snapshot.history.loaded.ok_or_else(|| {
11893            failure(
11894                NodeFailureCode::BackendOperationFailed,
11895                "history load produced no bounded session",
11896            )
11897        })
11898    }
11899
11900    /// `allow_clean_detachment`: `false` for the on-demand
11901    /// `NodeRequest::ExportContextPackForSessionRecord` API path (the
11902    /// managed record must stay untouched, still live, for the whole call);
11903    /// `true` only for the reactive auto-export-at-exit path (`§2.1`), which
11904    /// reacts to the very clean-exit event that detaches the record out from
11905    /// under this same call — see `session_record_export_target_matches`.
11906    async fn export_context_pack_for_session_record(
11907        &self,
11908        record_id: &SessionRecordId,
11909        session: &SessionAddress,
11910        allow_clean_detachment: bool,
11911    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11912        // C2 replaces a relayed failure's prose with a canned per-code
11913        // string (`C2NodeFailure`'s own `From`), so this path's dozen
11914        // distinct refusals are indistinguishable to anyone downstream --
11915        // and a refusal here expires a continuation, which keeps no field
11916        // for why. The prose stays on the node, where it was written.
11917        let outcome = self
11918            .export_context_pack_for_session_record_inner(record_id, session, allow_clean_detachment)
11919            .await;
11920        if let Err(failure) = &outcome {
11921            tracing::warn!(
11922                record = %record_id,
11923                code = ?failure.code,
11924                reason = %failure.message,
11925                "session-record context-pack export refused",
11926            );
11927        }
11928        outcome
11929    }
11930
11931    async fn export_context_pack_for_session_record_inner(
11932        &self,
11933        record_id: &SessionRecordId,
11934        session: &SessionAddress,
11935        allow_clean_detachment: bool,
11936    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11937        // Several steps here refuse with the same BackendOperationFailed
11938        // code and no prose of their own, so which step failed is
11939        // otherwise unrecoverable -- and a refusal here expires a
11940        // continuation.
11941        let step = |name: &'static str| move |failure: NodeFailure| {
11942            tracing::warn!(step = name, code = ?failure.code, "context-pack export step refused");
11943            failure
11944        };
11945        let record = self.record(record_id).map_err(step("record-lookup"))?;
11946        // `provider_session` is `Some` only for a provider whose resolved
11947        // runtime policy admits live identity verification (raw PTY plus a
11948        // semantic PTY adapter -- `provider_runtime::policy_from_capability_flags`).
11949        // The PTY-sidecar-observation shape (no fleet provider today, but
11950        // capability-keyed rather than vendor-locked) hardcodes
11951        // `provider_session_identity: false` in `provider_runtime::evaluate`,
11952        // so its records go straight to `Live` with `provider_session: None`
11953        // and stay that way for the record's entire life -- that is this
11954        // shape's permanent resting state, not a race with observation, and
11955        // `discover_history`/`load_history` below do not need an identity to
11956        // do their job for it. `IdentityPending` is the actual race (an
11957        // identity-admitted provider whose observation has not landed yet):
11958        // it keeps refusing here rather than falling back, because that
11959        // provider's own history the fallback would otherwise trust is not
11960        // guaranteed to be exact without the identity it is still waiting on.
11961        let identity = match record.provider_session.clone() {
11962            Some(identity) => {
11963                if identity.key != ProviderSessionKey::SessionId {
11964                    return Err(step("provider-identity-key")(failure(
11965                        NodeFailureCode::BackendOperationFailed,
11966                        "managed session provider identity cannot bind history exactly",
11967                    )));
11968                }
11969                Some(identity)
11970            }
11971            None if record.state == ManagedSessionState::IdentityPending => {
11972                return Err(step("provider-session-identity")(failure(
11973                    NodeFailureCode::BackendOperationFailed,
11974                    "managed session has not yet observed its provider session identity",
11975                )));
11976            }
11977            None => None,
11978        };
11979        self.revalidate_session_record_context_export(
11980            &record,
11981            identity.as_ref(),
11982            session,
11983            allow_clean_detachment,
11984        ).map_err(step("revalidate-before-discovery"))?;
11985        let candidates = self
11986            .discover_history(session, HISTORY_DISCOVERY_LIMIT_MAX)
11987            .await
11988            .map_err(step("discover-history"))?;
11989        // With no verified identity to filter by, the discovered set must
11990        // still resolve to exactly one candidate: the same ambiguity refusal
11991        // an identity filter would otherwise produce is the only safety
11992        // property left available for this shape.
11993        let candidate_id = select_history_candidate(
11994            &candidates,
11995            identity.as_ref().map(|identity| identity.id.as_str()),
11996        ).map_err(step("unique-candidate"))?;
11997        let loaded = self.load_history(session, candidate_id.clone()).await
11998            .map_err(step("load-history"))?;
11999        if let Some(identity) = identity.as_ref() {
12000            if loaded.session_id != identity.id {
12001                return Err(failure(
12002                    NodeFailureCode::SessionRecordConflict,
12003                    "loaded history identity does not match the managed session record",
12004                ));
12005            }
12006        }
12007        self.revalidate_session_record_context_export(
12008            &record,
12009            identity.as_ref(),
12010            session,
12011            allow_clean_detachment,
12012        )?;
12013        self.revalidate_loaded_history(session, &candidate_id, &loaded)
12014            .map_err(step("revalidate-loaded-history"))?;
12015        let pack = self.materialize_context_pack(session).await
12016            .map_err(step("materialize-context-pack"))?;
12017        self.commit_context_pack_for_session_record(
12018            &record,
12019            identity.as_ref(),
12020            session,
12021            &candidate_id,
12022            &loaded,
12023            pack,
12024            allow_clean_detachment,
12025        )
12026    }
12027
12028    async fn export_context_pack(
12029        &self,
12030        address: &SessionAddress,
12031    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12032        let pack = self.materialize_context_pack(address).await?;
12033        self.commit_context_pack(pack)
12034    }
12035
12036    async fn materialize_context_pack(
12037        &self,
12038        address: &SessionAddress,
12039    ) -> Result<NodeContextPack, NodeFailure> {
12040        let snapshot = self.internal_session_snapshot(address)?;
12041        if !context_pack_source_status_is_usable(&snapshot.status) {
12042            return Err(failure(
12043                NodeFailureCode::InvalidRequest,
12044                "context export requires a running or successfully exited source session",
12045            ));
12046        }
12047        if snapshot.history.pending.is_some()
12048            || snapshot.history.loaded_candidate_id.is_none()
12049        {
12050            return Err(failure(
12051                NodeFailureCode::InvalidRequest,
12052                "context export requires settled loaded history",
12053            ));
12054        }
12055        let loaded = snapshot.history.loaded.clone().ok_or_else(|| {
12056            failure(
12057                NodeFailureCode::InvalidRequest,
12058                "context export requires settled loaded history",
12059            )
12060        })?;
12061        let source_provider = snapshot.agent_id.clone();
12062        let loaded_candidate_id = snapshot.history.loaded_candidate_id.clone();
12063        let repository = self
12064            .context_pack_repository(&address.workspace_id)
12065            .await?;
12066        let current = self.internal_session_snapshot(address)?;
12067        if !context_pack_source_status_is_usable(&current.status)
12068            || current.agent_id != source_provider
12069            || current.history.pending.is_some()
12070            || current.history.loaded_candidate_id != loaded_candidate_id
12071            || current.history.loaded.as_ref() != Some(&loaded)
12072        {
12073            return Err(failure(
12074                NodeFailureCode::ContextPackMaterializationFailed,
12075                "context source changed during bounded repository capture",
12076            ));
12077        }
12078        NodeContextPack::export_with_repository(
12079            ContextPackLineageReceipt {
12080                source_node_id: self.node_id.clone(),
12081                source_session: address.clone(),
12082                source_provider,
12083            },
12084            &loaded,
12085            Some(repository),
12086        )
12087        .map_err(|_| {
12088            failure(
12089                NodeFailureCode::ContextPackMaterializationFailed,
12090                "bounded context pack export failed",
12091            )
12092        })
12093    }
12094
12095    /// Write-through to the durable [`ContextPackStore`], if one is
12096    /// configured (`state_path` is `Some`). A node with no durable state
12097    /// path — fixtures, tests, ephemeral runs — has no `ContextPackStore`
12098    /// open at all and this is a no-op, matching `persist_state`'s own
12099    /// "durability optional, degrade gracefully when unconfigured" idiom.
12100    fn commit_context_pack_durably(&self, pack: &NodeContextPack) -> Result<(), NodeFailure> {
12101        let mut store = self
12102            .context_pack_store
12103            .lock()
12104            .unwrap_or_else(|poisoned| poisoned.into_inner());
12105        let Some(store) = store.as_mut() else {
12106            return Ok(());
12107        };
12108        store.commit(pack).map_err(|_| {
12109            failure(
12110                NodeFailureCode::ContextPackMaterializationFailed,
12111                "durable context pack commit failed",
12112            )
12113        })
12114    }
12115
12116    fn commit_context_pack(
12117        &self,
12118        pack: NodeContextPack,
12119    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12120        self.commit_context_pack_durably(&pack)?;
12121        self.context_catalog
12122            .write()
12123            .unwrap_or_else(|poisoned| poisoned.into_inner())
12124            .insert(pack)
12125            .map_err(|_| {
12126                failure(
12127                    NodeFailureCode::ContextPackMaterializationFailed,
12128                    "context pack catalog is full",
12129                )
12130            })
12131    }
12132
12133    fn commit_context_pack_for_session_record(
12134        &self,
12135        expected: &ManagedSessionRecord,
12136        identity: Option<&ProviderSessionIdentity>,
12137        session: &SessionAddress,
12138        candidate_id: &str,
12139        loaded: &HistorySessionRecord,
12140        pack: NodeContextPack,
12141        allow_clean_detachment: bool,
12142    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12143        let current_root = self.workspace_root(&expected.workspace_id)?;
12144        let _transaction = self
12145            .state_transaction
12146            .lock()
12147            .unwrap_or_else(|poisoned| poisoned.into_inner());
12148        let records = self
12149            .session_records
12150            .lock()
12151            .unwrap_or_else(|poisoned| poisoned.into_inner());
12152        let current = records.records.get(&expected.record_id).ok_or_else(|| {
12153            failure(
12154                NodeFailureCode::SessionRecordConflict,
12155                "managed session record disappeared during context export",
12156            )
12157        })?;
12158        if !session_record_export_target_matches(expected, current, session, allow_clean_detachment) {
12159            return Err(failure(
12160                NodeFailureCode::SessionRecordConflict,
12161                "managed session record changed during context export",
12162            ));
12163        }
12164        let bindings = self
12165            .session_bindings
12166            .lock()
12167            .unwrap_or_else(|poisoned| poisoned.into_inner());
12168        let binding = bindings.get(&session.session.instance_id).ok_or_else(|| {
12169            failure(
12170                NodeFailureCode::SessionRecordConflict,
12171                "managed session runtime binding disappeared during context export",
12172            )
12173        })?;
12174        let runtime = self
12175            .handle
12176            .snapshot()
12177            .sessions
12178            .iter()
12179            .find(|snapshot| {
12180                snapshot.instance_id == session.session.instance_id
12181                    && snapshot.generation == session.session.generation
12182            })
12183            .cloned()
12184            .ok_or_else(|| {
12185                failure(
12186                    NodeFailureCode::SessionRecordConflict,
12187                    "managed session runtime disappeared during context export",
12188                )
12189            })?;
12190        let receipt = pack.receipt();
12191        if !session_record_context_export_binding_is_exact(
12192            expected,
12193            current,
12194            identity,
12195            session,
12196            binding,
12197            &runtime.agent_id,
12198            &current_root,
12199            allow_clean_detachment,
12200        ) || !session_record_context_export_source_is_usable(
12201            current,
12202            &runtime.status,
12203            allow_clean_detachment,
12204        ) || runtime.history.pending.is_some()
12205            || runtime.history.loaded_candidate_id.as_deref() != Some(candidate_id)
12206            || runtime.history.loaded.as_ref() != Some(loaded)
12207            || receipt.lineage.source_node_id != self.node_id
12208            || receipt.lineage.source_session != *session
12209            || receipt.lineage.source_provider != current.provider
12210            || receipt.source_message_count != loaded.message_count
12211        {
12212            return Err(failure(
12213                NodeFailureCode::SessionRecordConflict,
12214                "managed session source changed before context catalog commit",
12215            ));
12216        }
12217        self.commit_context_pack_durably(&pack)?;
12218        self.context_catalog
12219            .write()
12220            .unwrap_or_else(|poisoned| poisoned.into_inner())
12221            .insert(pack)
12222            .map_err(|_| {
12223                failure(
12224                    NodeFailureCode::ContextPackMaterializationFailed,
12225                    "context pack catalog is full",
12226                )
12227            })
12228    }
12229
12230    async fn context_pack_repository(
12231        &self,
12232        workspace_id: &WorkspaceId,
12233    ) -> Result<ContextPackRepository, NodeFailure> {
12234        let first = self
12235            .context_pack_repository_observation(workspace_id)
12236            .await?;
12237        let second = self
12238            .context_pack_repository_observation(workspace_id)
12239            .await?;
12240        stable_context_pack_repository(first, second)
12241    }
12242
12243    async fn context_pack_repository_observation(
12244        &self,
12245        workspace_id: &WorkspaceId,
12246    ) -> Result<ContextPackRepositoryObservation, NodeFailure> {
12247        let git = self.inspect_workspace(workspace_id.clone()).await?.git;
12248        let head = if git.is_repository {
12249            let root = self.workspace_root(workspace_id)?;
12250            match resolve_base_commit_with_timeout(
12251                &root,
12252                "HEAD",
12253                GIT_COMMAND_TIMEOUT_MS,
12254            )
12255            .await
12256            {
12257                Ok(commit) => ContextPackRepositoryHead::Commit(commit),
12258                Err(error) if error.kind == GitWorktreeErrorKind::Conflict => {
12259                    ContextPackRepositoryHead::Unborn
12260                }
12261                Err(_) => {
12262                    return Err(failure(
12263                        NodeFailureCode::ContextPackMaterializationFailed,
12264                        "context pack HEAD observation failed",
12265                    ));
12266                }
12267            }
12268        } else {
12269            ContextPackRepositoryHead::NotRepository
12270        };
12271        let mut files = Vec::with_capacity(CONTEXT_PACK_SELECTED_FILES.len());
12272        for selected_path in CONTEXT_PACK_SELECTED_FILES {
12273            let path = RepositoryPath::utf8(selected_path.to_owned()).map_err(|_| {
12274                failure(
12275                    NodeFailureCode::InvalidRepositoryPath,
12276                    "context pack selected file path is invalid",
12277                )
12278            })?;
12279            match self.read_workspace_file(workspace_id.clone(), path).await {
12280                Ok(WorkspaceFileRead {
12281                    content: WorkspaceFileContent::Utf8 { text, byte_len },
12282                    ..
12283                }) => files.push(ContextPackRepositoryFileSource::utf8(
12284                    selected_path,
12285                    text,
12286                    byte_len,
12287                )),
12288                Ok(WorkspaceFileRead {
12289                    content: WorkspaceFileContent::NonUtf8 { byte_len },
12290                    ..
12291                }) => files.push(ContextPackRepositoryFileSource::skipped(
12292                    selected_path,
12293                    ContextPackSelectedFileSkipReason::NonUtf8,
12294                    Some(byte_len),
12295                )),
12296                Ok(WorkspaceFileRead {
12297                    content: WorkspaceFileContent::TooLarge { .. },
12298                    ..
12299                }) => files.push(ContextPackRepositoryFileSource::skipped(
12300                    selected_path,
12301                    ContextPackSelectedFileSkipReason::TooLarge,
12302                    None,
12303                )),
12304                Err(error) => {
12305                    let reason = if matches!(
12306                        error.code,
12307                        NodeFailureCode::InvalidRepositoryPath
12308                            | NodeFailureCode::RepositoryFileNotRegular
12309                            | NodeFailureCode::RepositoryPathUnsafe
12310                    ) {
12311                        ContextPackSelectedFileSkipReason::Unsafe
12312                    } else {
12313                        ContextPackSelectedFileSkipReason::Unavailable
12314                    };
12315                    files.push(ContextPackRepositoryFileSource::skipped(
12316                        selected_path,
12317                        reason,
12318                        None,
12319                    ));
12320                }
12321            }
12322        }
12323        Ok(ContextPackRepositoryObservation { head, git, files })
12324    }
12325
12326    fn forget_context_pack(&self, context_id: &SpawnContextId) -> Result<(), NodeFailure> {
12327        let referenced_by_binding = self
12328            .session_bindings
12329            .lock()
12330            .unwrap_or_else(|poisoned| poisoned.into_inner())
12331            .values()
12332            .any(|binding| {
12333                binding.context.as_ref().map(|receipt| &receipt.id) == Some(context_id)
12334            });
12335        let referenced_by_record = self
12336            .session_records
12337            .lock()
12338            .unwrap_or_else(|poisoned| poisoned.into_inner())
12339            .records
12340            .values()
12341            .any(|record| record.context_id.as_ref() == Some(context_id));
12342        let referenced_by_materialization = self
12343            .materializations
12344            .lock()
12345            .unwrap_or_else(|poisoned| poisoned.into_inner())
12346            .values()
12347            .any(|ownership| {
12348                ownership.context().map(|receipt| &receipt.id) == Some(context_id)
12349            });
12350        if referenced_by_binding
12351            || referenced_by_record
12352            || referenced_by_materialization
12353        {
12354            return Err(failure(
12355                NodeFailureCode::ContextPackBusy,
12356                "context pack is still referenced",
12357            ));
12358        }
12359        let mut catalog = self.context_catalog
12360            .write()
12361            .unwrap_or_else(|poisoned| poisoned.into_inner());
12362        if catalog.get(context_id).is_none() {
12363            return Err(failure(
12364                NodeFailureCode::UnknownContextPack,
12365                "context pack is unavailable",
12366            ));
12367        }
12368        self.spawn_idempotency
12369            .lock()
12370            .unwrap_or_else(|poisoned| poisoned.into_inner())
12371            .entries
12372            .retain(|_, entry| !entry.value.references_context(context_id));
12373        let removed = catalog.remove(context_id);
12374        debug_assert!(removed.is_some(), "validated context pack remains present");
12375        Ok(())
12376    }
12377
12378    async fn dispatch_input_bounded(
12379        &self,
12380        address: &SessionAddress,
12381        action: InputAction,
12382    ) -> Result<(), NodeFailure> {
12383        let started_after = self.current_sequence();
12384        self.pty_input_marks
12385            .lock()
12386            .unwrap_or_else(|poisoned| poisoned.into_inner())
12387            .insert(address.session.instance_id, (Instant::now(), false));
12388        self.pty_input_wake.notify_one();
12389        let command_id = self
12390            .dispatch_bounded(
12391                ControlCommand::SendInput {
12392                    instance_id: address.session.instance_id,
12393                    action,
12394                },
12395                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12396            )
12397            .await?;
12398        // A semantic prompt may spend the provider's full readiness budget before
12399        // the runtime emits InputCompleted. Keep the node request deadline bounded,
12400        // but strictly beyond every readiness policy in this node's catalog.
12401        let deadline = Instant::now() + Duration::from_millis(self.input_settle_timeout_ms);
12402        let mut scan_after = started_after;
12403        let mut accepted_after = None;
12404        loop {
12405            let events = self
12406                .history
12407                .lock()
12408                .unwrap_or_else(|poisoned| poisoned.into_inner())
12409                .events
12410                .iter()
12411                .filter(|envelope| envelope.sequence > scan_after)
12412                .cloned()
12413                .collect::<Vec<_>>();
12414            for envelope in events {
12415                scan_after = scan_after.max(envelope.sequence);
12416                let NodeEvent::Control {
12417                    address: event_address,
12418                    event,
12419                } = envelope.event
12420                else {
12421                    continue;
12422                };
12423                if event_address != *address {
12424                    continue;
12425                }
12426                if event.command_id == Some(command_id) {
12427                    match &event.event {
12428                        ControlEventKind::CommandRejected { message } => {
12429                            return Err(failure(NodeFailureCode::InvalidRequest, message));
12430                        }
12431                        ControlEventKind::InputRequested { .. } => {
12432                            accepted_after = Some(envelope.sequence);
12433                            continue;
12434                        }
12435                        _ => {}
12436                    }
12437                }
12438                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12439                    match &event.event {
12440                        ControlEventKind::InputCompleted { .. } => return Ok(()),
12441                        ControlEventKind::InputFailed { message, .. } => {
12442                            return Err(failure(
12443                                NodeFailureCode::BackendOperationFailed,
12444                                message,
12445                            ));
12446                        }
12447                        _ => {}
12448                    }
12449                }
12450            }
12451            if Instant::now() >= deadline {
12452                return Err(failure(
12453                    NodeFailureCode::BackendBusy,
12454                    if accepted_after.is_some() {
12455                        "input was accepted but did not settle before the bounded deadline"
12456                    } else {
12457                        "input was not accepted before the bounded deadline"
12458                    },
12459                ));
12460            }
12461            sleep(Duration::from_millis(2)).await;
12462        }
12463    }
12464
12465    async fn dispatch_resize_bounded(
12466        &self,
12467        address: &SessionAddress,
12468        size: gate4agent_types::TerminalSize,
12469    ) -> Result<(), NodeFailure> {
12470        let started_after = self.current_sequence();
12471        let command_id = self
12472            .dispatch_bounded(
12473                ControlCommand::Resize {
12474                    instance_id: address.session.instance_id,
12475                    size,
12476                },
12477                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12478            )
12479            .await?;
12480        let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
12481        let mut scan_after = started_after;
12482        let mut accepted_after = None;
12483        loop {
12484            let events = self
12485                .history
12486                .lock()
12487                .unwrap_or_else(|poisoned| poisoned.into_inner())
12488                .events
12489                .iter()
12490                .filter(|envelope| envelope.sequence > scan_after)
12491                .cloned()
12492                .collect::<Vec<_>>();
12493            for envelope in events {
12494                scan_after = scan_after.max(envelope.sequence);
12495                let NodeEvent::Control {
12496                    address: event_address,
12497                    event,
12498                } = envelope.event
12499                else {
12500                    continue;
12501                };
12502                if event_address != *address {
12503                    continue;
12504                }
12505                if event.command_id == Some(command_id) {
12506                    match &event.event {
12507                        ControlEventKind::CommandRejected { message } => {
12508                            return Err(failure(NodeFailureCode::InvalidRequest, message));
12509                        }
12510                        ControlEventKind::ResizeRequested {
12511                            size: requested_size,
12512                            ..
12513                        } if *requested_size == size => {
12514                            accepted_after = Some(envelope.sequence);
12515                            continue;
12516                        }
12517                        _ => {}
12518                    }
12519                }
12520                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12521                    match &event.event {
12522                        ControlEventKind::Resized { size: settled_size }
12523                            if *settled_size == size =>
12524                        {
12525                            return Ok(());
12526                        }
12527                        ControlEventKind::ResizeFailed { message } => {
12528                            return Err(failure(
12529                                NodeFailureCode::BackendOperationFailed,
12530                                message,
12531                            ));
12532                        }
12533                        _ => {}
12534                    }
12535                }
12536            }
12537            if Instant::now() >= deadline {
12538                return Err(failure(
12539                    NodeFailureCode::BackendBusy,
12540                    if accepted_after.is_some() {
12541                        "resize was accepted but did not settle before the bounded deadline"
12542                    } else {
12543                        "resize was not accepted before the bounded deadline"
12544                    },
12545                ));
12546            }
12547            sleep(Duration::from_millis(2)).await;
12548        }
12549    }
12550
12551    async fn spawn_session(
12552        &self,
12553        workspace_id: WorkspaceId,
12554        provider: AgentId,
12555        mode: SessionMode,
12556        terminal_size: gate4agent_types::TerminalSize,
12557        initial_prompt: Option<String>,
12558    ) -> Result<SessionAddress, NodeFailure> {
12559        if self.managed_worktrees.lock()
12560            .unwrap_or_else(|poisoned| poisoned.into_inner())
12561            .lease_for_workspace(&workspace_id)
12562            .is_some()
12563        {
12564            return Err(failure(
12565                NodeFailureCode::ManagedWorktreeOwnershipConflict,
12566                "managed worktree targets may only be spawned through their lease request",
12567            ));
12568        }
12569        self
12570            .spawn_session_with_deadline(
12571                workspace_id,
12572                provider,
12573                mode,
12574                terminal_size,
12575                initial_prompt,
12576                None,
12577                None,
12578                None,
12579                None,
12580                None,
12581                SpawnRecordPolicy::ProviderIdentityOnly,
12582                None,
12583                &[],
12584                None,
12585                // `NodeRequest::Spawn` (this method's only caller) carries no
12586                // approval-level selection of its own -- see
12587                // `HarnessOperatorRequestV1::SpawnSession`'s own field for
12588                // the one operator-wire path that can choose something other
12589                // than the axis default.
12590                ApprovalLevel::default(),
12591                None,
12592                None,
12593            )
12594            .await
12595            .map(|(session, _runtime_policy)| session)
12596    }
12597
12598    async fn spawn_session_with_deadline(
12599        &self,
12600        workspace_id: WorkspaceId,
12601        provider: AgentId,
12602        mode: SessionMode,
12603        terminal_size: gate4agent_types::TerminalSize,
12604        initial_prompt: Option<String>,
12605        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
12606        bundle: Option<ResolvedBundleReceipt>,
12607        context: Option<ResolvedContextPackReceipt>,
12608        managed_authority: Option<ManagedWorktreeLeaseId>,
12609        admitted_runtime_policy: Option<ProviderRuntimePolicy>,
12610        record_policy: SpawnRecordPolicy,
12611        deadline: Option<Instant>,
12612        required_capabilities: &[ProviderRuntimeCapability],
12613        harness_mcp: Option<&PreparedHarnessMcpSpawn>,
12614        approval_level: ApprovalLevel,
12615        network_allowlist: Option<crate::protocol::SpawnNetworkAllowlistId>,
12616        // Opaque dig2browser station profile id (ids only). Feature
12617        // `dig2-station-probe`: after probe at resolve, spawn takes an
12618        // exclusive node-local lease; cleared on session end. Never
12619        // ImportSession / cookies on C2. Feature off: ignored (stub echo
12620        // already happened on the receipt path).
12621        browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
12622    ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
12623        let reserved_lease = self.managed_worktrees.lock()
12624            .unwrap_or_else(|poisoned| poisoned.into_inner())
12625            .lease_for_workspace(&workspace_id);
12626        match (reserved_lease.as_ref(), managed_authority.as_ref()) {
12627            (Some(expected), Some(provided)) if expected == provided => {}
12628            (None, None) => {}
12629            _ => {
12630                return Err(failure(
12631                    NodeFailureCode::ManagedWorktreeOwnershipConflict,
12632                    "spawn lacks exact managed worktree lease authority",
12633                ));
12634            }
12635        }
12636        // Refuse concurrent distinct environment profiles (incl. ProviderHome
12637        // bindings) on the same workspace cwd. Parallel path: managed worktree
12638        // spawn so each profile gets its own workspace_id.
12639        self.ensure_workspace_environment_profile_compatible(
12640            &workspace_id,
12641            environment_profile.as_ref(),
12642        )?;
12643        let runtime_requirement = match (mode, initial_prompt.is_some()) {
12644            (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
12645            (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
12646            (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
12647            (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
12648        };
12649        // This session's own transport -- computed once, here, from `mode`,
12650        // and reused both to admit spawn (below) and, further down, to
12651        // compute the final `runtime_policy` this session actually operates
12652        // under (`provider_runtime::policy_for_transport`). Registration
12653        // reuses this exact value rather than recomputing it a second time.
12654        let transport = match mode {
12655            SessionMode::Pty => TransportKind::Pty,
12656            SessionMode::Inline => TransportKind::Pipe,
12657            SessionMode::Acp => TransportKind::Acp,
12658        };
12659        let pipe_transport = self.provider_supports_pipe_transport(&provider);
12660        let acp_transport = self.provider_supports_acp_transport(&provider);
12661        let runtime_policy = if let Some(runtime_policy) = admitted_runtime_policy {
12662            require_policy(runtime_policy, pipe_transport, acp_transport, runtime_requirement).map_err(|_| failure(
12663                NodeFailureCode::UnsupportedSpawnCapability,
12664                "pre-admitted provider runtime no longer satisfies spawn requirements",
12665            ))?;
12666            runtime_policy
12667        } else if let Some(deadline) = deadline {
12668            let remaining = spawn_deadline_remaining(deadline)?;
12669            timeout(
12670                remaining,
12671                self.admit_provider_runtime(&provider, runtime_requirement),
12672            )
12673            .await
12674            .map_err(|_| {
12675                failure(
12676                    NodeFailureCode::SpawnDeadlineExceeded,
12677                    "provider admission exceeded the spawn deadline",
12678                )
12679            })??
12680        } else {
12681            self.admit_provider_runtime(&provider, runtime_requirement).await?
12682        };
12683        let runtime_policy = self.effective_spawn_runtime_policy(runtime_policy);
12684        // The single point where this session's `runtime_policy` becomes
12685        // transport-correct: `admit_provider_runtime` (and the pre-admitted
12686        // branch above) only ever compute a PTY-terminal-verification-shaped
12687        // value, sound outright only for `TransportKind::Pty` (Pipe still
12688        // relies on that same value downstream -- see `policy_for_transport`'s
12689        // own doc for why). `policy_for_transport` is the explicit,
12690        // exhaustive match Item 1 asks for -- this is the ONE place its
12691        // output becomes the session's `runtime_policy`, so every later
12692        // reader (`ControlCommand::Start`, `session_bindings`, and every
12693        // engine gate that reads `state.runtime_policy`) sees the same,
12694        // correctly-computed value.
12695        let runtime_policy =
12696            crate::provider_runtime::policy_for_transport(transport, runtime_policy);
12697        if required_capabilities
12698            .iter()
12699            .any(|capability| !runtime_policy.admits(*capability))
12700        {
12701            return Err(failure(
12702                NodeFailureCode::UnsupportedSpawnCapability,
12703                "provider runtime does not admit a required spawn capability",
12704            ));
12705        }
12706        if let Some(deadline) = deadline {
12707            spawn_deadline_remaining(deadline)?;
12708        }
12709        self.ensure_binding_capacity()?;
12710        let working_directory = self.workspace_root(&workspace_id)?;
12711        let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
12712        let session = SessionKey {
12713            instance_id,
12714            generation: SessionGeneration(1),
12715        };
12716        let address = SessionAddress {
12717            workspace_id: workspace_id.clone(),
12718            session,
12719        };
12720        // This is the request-accepted checkpoint: admission (managed
12721        // worktree authority, runtime capability, dispatch deadline) already
12722        // passed and a concrete session identity is minted. It is a distinct
12723        // state from the process actually running (`ControlEventKind::Running`,
12724        // logged in `publish_control`) or the terminal outcome (`Exited`/
12725        // `Failed`, logged there too) -- this line exists so that gap is no
12726        // longer silent.
12727        tracing::info!(
12728            node_id = %self.node_id,
12729            workspace_id = %workspace_id,
12730            provider = %provider,
12731            environment_profile = ?environment_profile.as_ref().map(|receipt| &receipt.profile_id),
12732            session = ?address.session,
12733            "session spawn request accepted",
12734        );
12735        let mut prepared_spawn = SpawnPreparer::prepare(
12736            self,
12737            SpawnPrepareRequest {
12738                instance_id,
12739                address: &address,
12740                provider: &provider,
12741                mode,
12742                environment_profile: environment_profile.as_ref(),
12743                bundle: bundle.as_ref(),
12744                context: context.as_ref(),
12745                managed_authority: managed_authority.as_ref(),
12746                harness_mcp,
12747                approval_level,
12748                network_allowlist: network_allowlist.as_ref(),
12749                browser_profile_id,
12750            },
12751        )?;
12752        let materialization_id = prepared_spawn.materialization_id.take();
12753        let dispatch_timeout = spawn_dispatch_timeout(deadline)?;
12754        let record_id = self.bind_spawn_session_with_materialization(
12755            &address,
12756            provider.clone(),
12757            mode,
12758            runtime_policy,
12759            record_policy,
12760            environment_profile,
12761            bundle,
12762            context,
12763            materialization_id,
12764        )?;
12765        prepared_spawn.retain_session_guards();
12766        // `transport` was already computed above, alongside this session's
12767        // `runtime_policy`; reused here for `Register` rather than derived a
12768        // second time.
12769        let agent_id = provider;
12770        let register_command_id = match self.dispatch_bounded(
12771            ControlCommand::Register {
12772                instance_id,
12773                agent_id,
12774                transport,
12775            },
12776            dispatch_timeout,
12777        )
12778        .await {
12779            Ok(command_id) => command_id,
12780            Err(error) => {
12781                if let Some(binding) = self.remove_binding(&address) {
12782                    SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
12783                }
12784                if let Some(record_id) = record_id.as_ref() {
12785                    self.discard_record(record_id)?;
12786                }
12787                return Err(spawn_dispatch_error(error, deadline));
12788            }
12789        };
12790        let start_timeout = match spawn_dispatch_timeout(deadline) {
12791            Ok(timeout) => timeout,
12792            Err(error) => {
12793                let recovery = self
12794                    .rollback_spawn(
12795                        &address,
12796                        Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12797                    )
12798                    .await;
12799                return match recovery {
12800                    Ok(()) => {
12801                        if let Some(record_id) = record_id.as_ref() {
12802                            self.discard_record(record_id)?;
12803                        }
12804                        Err(error)
12805                    }
12806                    Err(recovery_error) => {
12807                        if let Some(record_id) = record_id.as_ref() {
12808                            self.mark_record_error(
12809                                record_id,
12810                                "spawn deadline elapsed and registration recovery failed",
12811                            )?;
12812                        }
12813                        Err(recovery_error)
12814                    }
12815                };
12816            }
12817        };
12818        if let Err(start_error) = self.verify_harness_mcp_before_start(harness_mcp) {
12819            if let Err(recovery_error) = self
12820                .rollback_spawn(&address, Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS))
12821                .await
12822            {
12823                return Err(recovery_error);
12824            }
12825            if let Some(record_id) = record_id.as_ref() {
12826                self.discard_record(record_id)?;
12827            }
12828            return Err(start_error);
12829        }
12830        let start_result = self
12831            .dispatch_bounded(
12832                ControlCommand::Start {
12833                    instance_id,
12834                    runtime_policy,
12835                    request: StartRequest {
12836                        working_directory,
12837                        terminal_size,
12838                        initial_prompt,
12839                        session_options: SPAWN_SESSION_OPTIONS.try_with(|o| o.clone()).ok().flatten(),
12840                        approval_level,
12841                    },
12842                },
12843                start_timeout,
12844            )
12845            .await;
12846        let start_command_id = match start_result {
12847            Ok(command_id) => command_id,
12848            Err(start_error) => {
12849                let start_error = spawn_dispatch_error(start_error, deadline);
12850                let recovery = self
12851                    .rollback_spawn(
12852                        &address,
12853                        Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12854                    )
12855                    .await;
12856                return match recovery {
12857                    Ok(()) => {
12858                        if let Some(record_id) = record_id.as_ref() {
12859                            self.discard_record(record_id)?;
12860                        }
12861                        Err(start_error)
12862                    }
12863                    Err(recovery_error) => {
12864                        let diagnostic = format!(
12865                            "start failed and registration recovery failed: {}",
12866                            recovery_error.message,
12867                        );
12868                        if let Some(record_id) = record_id.as_ref() {
12869                            self.mark_record_error(record_id, &diagnostic)?;
12870                        }
12871                        Err(failure(recovery_error.code, &diagnostic))
12872                    }
12873                };
12874            }
12875        };
12876
12877        let commit_deadline = deadline.unwrap_or_else(|| Instant::now() + dispatch_timeout);
12878        loop {
12879            // `Register`/`Start` only fail synchronously for a port-level
12880            // ingress problem (see `dispatch_bounded`); the kernel refusing
12881            // either command's content -- an unsupported transport, an
12882            // unknown agent -- lands one drive-loop tick later, and no
12883            // session ever appears in `self.handle.snapshot()` for it to
12884            // notice below. Check for that named reason before falling
12885            // through to the blind presence poll, so a rejected spawn
12886            // reports why instead of just timing out.
12887            if let Some(reason) = self.take_rejected_command_reason(register_command_id)
12888                .or_else(|| self.take_rejected_command_reason(start_command_id))
12889            {
12890                self.rollback_spawn(
12891                    &address,
12892                    Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12893                ).await.map_err(|error| {
12894                        failure(
12895                            error.code,
12896                            &format!(
12897                                "spawn was rejected ({}) and registration recovery failed: {}",
12898                                reason.message, error.message
12899                            ),
12900                        )
12901                    })?;
12902                if let Some(record_id) = record_id.as_ref() {
12903                    self.discard_record(record_id)?;
12904                }
12905                let code = if reason.unsupported_transport {
12906                    NodeFailureCode::UnsupportedTransport
12907                } else {
12908                    NodeFailureCode::BackendOperationFailed
12909                };
12910                return Err(failure(code, &reason.message));
12911            }
12912            if let Some(current) = self
12913                .handle
12914                .snapshot()
12915                .sessions
12916                .iter()
12917                .find(|current| current.instance_id == instance_id)
12918            {
12919                if current.generation != session.generation {
12920                    let divergence = failure(
12921                        NodeFailureCode::StaleGeneration,
12922                        "spawn generation diverged",
12923                    );
12924                    return match self
12925                        .rollback_spawn(
12926                            &address,
12927                            Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12928                        )
12929                        .await
12930                    {
12931                        Ok(()) => {
12932                            if let Some(record_id) = record_id.as_ref() {
12933                                self.discard_record(record_id)?;
12934                            }
12935                            Err(divergence)
12936                        }
12937                        Err(recovery_error) => {
12938                            if let Some(record_id) = record_id.as_ref() {
12939                                self.mark_record_error(
12940                                    record_id,
12941                                    "spawn generation diverged and recovery failed",
12942                                )?;
12943                            }
12944                            Err(recovery_error)
12945                        }
12946                    };
12947                }
12948                if !matches!(current.status, gate4agent_types::SessionStatus::Registered) {
12949                    prepared_spawn.retain_launch_guards();
12950                    return Ok((address, runtime_policy));
12951                }
12952            }
12953            if Instant::now() >= commit_deadline {
12954                self.rollback_spawn(
12955                    &address,
12956                    Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12957                ).await.map_err(|error| {
12958                        failure(
12959                            error.code,
12960                            &format!(
12961                                "start did not commit and registration recovery failed: {}",
12962                                error.message
12963                            ),
12964                        )
12965                    })?;
12966                if let Some(record_id) = record_id.as_ref() {
12967                    self.discard_record(record_id)?;
12968                }
12969                return Err(failure(
12970                    if deadline.is_some() {
12971                        NodeFailureCode::SpawnDeadlineExceeded
12972                    } else {
12973                        NodeFailureCode::BackendBusy
12974                    },
12975                    "start did not commit before the bounded deadline; registration was removed",
12976                ));
12977            }
12978            sleep(Duration::from_millis(2)).await;
12979        }
12980    }
12981
12982    fn effective_spawn_runtime_policy(
12983        &self,
12984        runtime_policy: ProviderRuntimePolicy,
12985    ) -> ProviderRuntimePolicy {
12986        #[cfg(feature = "fixture")]
12987        let runtime_policy = if self.fixture_semantic_hook_policy {
12988            ProviderRuntimePolicy::new(true, true, true, true, false, true)
12989                .expect("monitoring Hook fixture policy is internally valid")
12990        } else {
12991            runtime_policy
12992        };
12993        runtime_policy
12994    }
12995
12996    fn verify_harness_mcp_before_start(
12997        &self,
12998        prepared: Option<&PreparedHarnessMcpSpawn>,
12999    ) -> Result<(), NodeFailure> {
13000        prepared
13001            .map(PreparedHarnessMcpSpawn::verify_helper_program)
13002            .transpose()
13003            .map(|_| ())
13004            .map_err(harness_mcp_failure)
13005    }
13006
13007    async fn rollback_spawn(
13008        &self,
13009        address: &SessionAddress,
13010        commit_timeout: Duration,
13011    ) -> Result<(), NodeFailure> {
13012        self.dispatch_bounded(
13013            ControlCommand::Remove {
13014                instance_id: address.session.instance_id,
13015            },
13016            commit_timeout,
13017        )
13018        .await?;
13019        self.wait_until_removed(address, commit_timeout).await?;
13020        if let Some(binding) = self.remove_binding(address) {
13021            SessionLifecycleHook::cleanup_session_materialization(self, address, &binding)?;
13022        }
13023        Ok(())
13024    }
13025
13026    async fn remove_session(&self, address: &SessionAddress) -> Result<(), NodeFailure> {
13027        let commit_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
13028        self.dispatch_bounded(
13029            ControlCommand::Remove {
13030                instance_id: address.session.instance_id,
13031            },
13032            commit_timeout,
13033        )
13034            .await?;
13035        self.wait_until_removed(address, commit_timeout).await?;
13036        let removed = self.remove_binding(address);
13037        if let Some(binding) = removed.as_ref() {
13038            SessionLifecycleHook::cleanup_session_materialization(self, address, binding)?;
13039        }
13040        if let Some(lease_id) = removed.and_then(|binding| binding.managed_worktree_lease_id) {
13041            let snapshot = self.managed_worktrees.lock()
13042                .unwrap_or_else(|poisoned| poisoned.into_inner())
13043                .release_session(
13044                    &lease_id,
13045                    self.incarnation_id,
13046                    address.session.instance_id,
13047                    address.session.generation,
13048                    unix_time_ms(),
13049                );
13050            if self.persist_state().is_err() {
13051                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
13052            }
13053            if let Some(snapshot) = snapshot {
13054                self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
13055            }
13056            let _ = self.cleanup_managed_worktree(&lease_id, false).await;
13057        }
13058        Ok(())
13059    }
13060
13061    /// Polls until `address`'s session leaves the engine's own session map,
13062    /// or the bounded deadline expires.
13063    ///
13064    /// A `Remove` sent while the session is not yet in a terminal status
13065    /// (e.g. still `Stopping`, mid-effect right after a `Stop`) is rejected
13066    /// once by the engine (`SessionStatus::allows_remove`) and
13067    /// then discarded -- nothing re-issues it on its own, so the removal
13068    /// that would satisfy this wait never arrives and the caller was
13069    /// guaranteed to time out even though the session settles microseconds
13070    /// later. So this re-dispatches on every poll tick: a session that
13071    /// settles mid-wait -- the common case right after a `Stop` -- still
13072    /// gets removed inside the same bounded deadline.
13073    ///
13074    /// It re-dispatches only once the status will ACCEPT the command. A
13075    /// rejected re-dispatch is not the harmless no-op this loop first
13076    /// assumed: every rejection publishes a `ControlEventKind::Command
13077    /// Rejected` to every subscriber and writes a WARN line. Ungated, one
13078    /// ordinary teardown produced 241 of them in 1.6 seconds -- the whole
13079    /// `Stopping` window at one known-doomed dispatch per 2ms tick, fanned
13080    /// out to the harness and every attached client. Gated, the same
13081    /// teardown sends exactly one `Remove`, at the first tick the engine
13082    /// can act on it.
13083    ///
13084    /// The status is read from the snapshot this loop already takes, so
13085    /// the gate costs nothing extra. It races -- the status can move
13086    /// between the read and the dispatch -- and that is fine: a rare lost
13087    /// race is one rejection, and the next tick retries.
13088    ///
13089    /// Re-dispatch is separately rate-limited to
13090    /// [`REMOVE_REDISPATCH_INTERVAL_MS`], because the status gate alone
13091    /// leaves a second burst behind it. Once the session settles, every
13092    /// 2ms tick sent another `Remove` while the accepted one was still in
13093    /// flight, and each of those landed on an instance the engine had
13094    /// already dropped -- `agent instance … is not registered`, observed
13095    /// live as a tail of rejections immediately after the `session
13096    /// removed` line. The presence poll stays at 2ms so the wait still
13097    /// exits promptly; only the sending slows down.
13098    ///
13099    /// Rate-limited rather than once-only on purpose: the status gate
13100    /// covers the status precondition, but `Gate4AgentEngine::remove` has
13101    /// others (a pending operation, an in-flight capability probe or
13102    /// history request) that clear on their own. A single shot would turn
13103    /// those into a guaranteed `BackendBusy` timeout; at 10Hz they still
13104    /// get their retries, just twenty of them across the deadline instead
13105    /// of a thousand.
13106    async fn wait_until_removed(
13107        &self,
13108        address: &SessionAddress,
13109        commit_timeout: Duration,
13110    ) -> Result<(), NodeFailure> {
13111        let deadline = Instant::now() + commit_timeout;
13112        let mut next_dispatch = Instant::now();
13113        loop {
13114            let snapshot = self.handle.snapshot();
13115            let status = snapshot
13116                .sessions
13117                .iter()
13118                .find(|session| session.instance_id == address.session.instance_id)
13119                .map(|session| session.status.clone());
13120            let Some(status) = status else {
13121                return Ok(());
13122            };
13123            let now = Instant::now();
13124            if now >= deadline {
13125                return Err(failure(
13126                    NodeFailureCode::BackendBusy,
13127                    "remove did not commit before the bounded deadline; session binding was retained",
13128                ));
13129            }
13130            if status.allows_remove() && now >= next_dispatch {
13131                let _ = self.dispatch(ControlCommand::Remove {
13132                    instance_id: address.session.instance_id,
13133                });
13134                next_dispatch = now + Duration::from_millis(REMOVE_REDISPATCH_INTERVAL_MS);
13135            }
13136            sleep(Duration::from_millis(2)).await;
13137        }
13138    }
13139
13140    fn resync(&self, after_sequence: u64) -> NodeResponse {
13141        let history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
13142        let event_sequence = history.last_sequence;
13143        let oldest_available_sequence = history.replay_floor_sequence;
13144        let events = history
13145            .events
13146            .iter()
13147            .filter(|event| event.sequence > after_sequence)
13148            .cloned()
13149            .collect();
13150        drop(history);
13151        NodeResponse::Resync {
13152            event_sequence,
13153            oldest_available_sequence,
13154            snapshot: self.snapshot(),
13155            events,
13156        }
13157    }
13158}
13159
13160/// Coarse label for one `AgentStreamChunkKindV1`, used only by
13161/// `NodeShared::publish_agent_stream_chunk`'s own debug log -- mirrors
13162/// `hatchery-harness-service`'s `agent_stream_event_kind_label` (same
13163/// "name what crossed this boundary" reasoning, one layer upstream of it).
13164fn agent_stream_chunk_kind_label(kind: &AgentStreamChunkKindV1) -> &'static str {
13165    match kind {
13166        AgentStreamChunkKindV1::Text { .. } => "text",
13167        AgentStreamChunkKindV1::Thinking { .. } => "thinking",
13168        AgentStreamChunkKindV1::InteractionPrompt { .. } => "interaction-prompt",
13169        AgentStreamChunkKindV1::ModeCatalog { .. } => "mode-catalog",
13170        AgentStreamChunkKindV1::ConfigOptions { .. } => "config-options",
13171        AgentStreamChunkKindV1::ModelCatalog { .. } => "model-catalog",
13172        AgentStreamChunkKindV1::Blocked { .. } => "blocked",
13173    }
13174}
13175
13176/// Serves this wire on the local endpoint, and -- when `call_home` names a
13177/// relay -- over an outbound connection to it as well.
13178///
13179/// The two live in one future rather than a fourth arm of `run`'s own
13180/// `select!` because they are one concern: this is where the node's wire
13181/// is served, and the direction a given connection was opened in does not
13182/// change what happens on it. It also means the existing graceful drain
13183/// below covers both without being duplicated.
13184///
13185/// Either side finishing ends the pair, which is correct: the listener
13186/// only returns on shutdown or a bind failure, and `call_home_loop` only
13187/// returns on shutdown -- a failed relay connection is retried inside it,
13188/// never propagated out.
13189async fn accept_connections(
13190    endpoint: &str,
13191    call_home: Option<std::net::SocketAddr>,
13192    identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13193    shared: Arc<NodeShared>,
13194) -> Result<(), NodeServerError> {
13195    let mut connections = JoinSet::new();
13196    let result = match call_home {
13197        None => accept_connections_inner(endpoint, shared, &mut connections).await,
13198        Some(relay) => {
13199            let listener =
13200                accept_connections_inner(endpoint, Arc::clone(&shared), &mut connections);
13201            let dialler = call_home_loop(relay, identity, Arc::clone(&shared));
13202            tokio::pin!(listener);
13203            tokio::pin!(dialler);
13204            tokio::select! {
13205                result = &mut listener => result,
13206                result = &mut dialler => result,
13207            }
13208        }
13209    };
13210    let graceful_deadline = Instant::now() + Duration::from_millis(CONNECTION_SHUTDOWN_GRACE_MS);
13211    while !connections.is_empty() {
13212        let remaining = graceful_deadline.saturating_duration_since(Instant::now());
13213        if remaining.is_zero()
13214            || timeout(remaining, connections.join_next()).await.is_err()
13215        {
13216            break;
13217        }
13218    }
13219    connections.abort_all();
13220    while connections.join_next().await.is_some() {}
13221    result
13222}
13223
13224/// Backoff ladder for a relay that will not answer, capped rather than
13225/// unbounded: a node whose relay is down is not broken and should keep
13226/// trying forever, but it should not spend a core doing it.
13227const CALL_HOME_BACKOFFS_MS: [u64; 5] = [500, 1_000, 2_000, 4_000, 8_000];
13228
13229/// Dials the relay, serves this wire over the connection, and does it
13230/// again when the connection ends.
13231///
13232/// The node is the SERVER on the socket it just opened. That inversion is
13233/// the whole point: `serve_connection` is the same function the local
13234/// listener feeds, and it neither knows nor cares which end placed the
13235/// call. The one thing this direction adds is the preface -- the relay
13236/// accepted a socket and cannot know whose it is, so the node says its
13237/// name before the handshake it would otherwise begin with.
13238///
13239/// Never returns an error. A relay that is down, refusing, or speaking a
13240/// different protocol is a condition to retry, not a reason to take the
13241/// node's local endpoint down with it; the only way out is shutdown.
13242async fn call_home_loop(
13243    relay: std::net::SocketAddr,
13244    identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13245    shared: Arc<NodeShared>,
13246) -> Result<(), NodeServerError> {
13247    let mut failures = 0_usize;
13248    loop {
13249        if shared.shutdown.load(Ordering::Acquire) {
13250            return Ok(());
13251        }
13252        let permit = tokio::select! {
13253            permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13254                permit.map_err(|_| NodeServerError::PreauthClosed)?
13255            }
13256            _ = shared.shutdown_notify.notified() => return Ok(()),
13257        };
13258        match dial_relay_and_serve(relay, identity.as_deref(), Arc::clone(&shared), permit).await {
13259            Ok(()) => {
13260                // A clean end is the relay hanging up, which it does on
13261                // its own restart. Reconnect immediately rather than
13262                // serving the backoff a peer that is plainly there.
13263                failures = 0;
13264                tracing::info!(node_id = %shared.node_id, %relay, "call-home connection closed; redialling");
13265            }
13266            Err(error) => {
13267                failures = failures.saturating_add(1);
13268                tracing::warn!(
13269                    node_id = %shared.node_id,
13270                    %relay,
13271                    cause = %error,
13272                    attempt = failures,
13273                    "call-home connection failed",
13274                );
13275                let backoff = call_home_backoff(failures);
13276                tokio::select! {
13277                    _ = sleep(backoff) => {}
13278                    _ = shared.shutdown_notify.notified() => return Ok(()),
13279                }
13280            }
13281        }
13282    }
13283}
13284
13285/// Idle time before the first keepalive probe on a call-home socket.
13286const CALL_HOME_KEEPALIVE_IDLE: Duration = Duration::from_secs(15);
13287/// Interval between keepalive probes once the link has gone quiet.
13288const CALL_HOME_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(5);
13289
13290fn call_home_keepalive() -> socket2::TcpKeepalive {
13291    let keepalive = socket2::TcpKeepalive::new()
13292        .with_time(CALL_HOME_KEEPALIVE_IDLE)
13293        .with_interval(CALL_HOME_KEEPALIVE_INTERVAL);
13294    #[cfg(not(windows))]
13295    let keepalive = keepalive.with_retries(3);
13296    keepalive
13297}
13298
13299fn enable_call_home_keepalive(stream: &tokio::net::TcpStream) -> std::io::Result<()> {
13300    socket2::SockRef::from(stream).set_tcp_keepalive(&call_home_keepalive())
13301}
13302
13303/// Same keepalive profile for any node->relay socket (restream uplink).
13304pub(crate) fn enable_tcp_keepalive(stream: &std::net::TcpStream) -> std::io::Result<()> {
13305    socket2::SockRef::from(stream).set_tcp_keepalive(&call_home_keepalive())
13306}
13307
13308/// Backoff for the `attempt`-th consecutive failure (1-based), capped.
13309fn call_home_backoff(attempt: usize) -> Duration {
13310    Duration::from_millis(
13311        CALL_HOME_BACKOFFS_MS[attempt.saturating_sub(1).min(CALL_HOME_BACKOFFS_MS.len() - 1)],
13312    )
13313}
13314
13315
13316/// Dial the call-home relay. Tunnel addresses go through the in-process
13317/// WireGuard dialer when the userspace session is up.
13318async fn dial_call_home(relay: std::net::SocketAddr) -> Result<tokio::net::TcpStream, NodeServerError> {
13319    #[cfg(feature = "wireguard")]
13320    {
13321        let std_stream = tokio::task::spawn_blocking(move || {
13322            gate4agent_node_core::kit_wireguard::tcp_connect(relay)
13323        })
13324        .await
13325        .map_err(|e| NodeServerError::CallHomeTransport(std::io::Error::other(e.to_string())))?
13326        .map_err(NodeServerError::CallHomeTransport)?;
13327        std_stream
13328            .set_nonblocking(true)
13329            .map_err(NodeServerError::CallHomeTransport)?;
13330        return tokio::net::TcpStream::from_std(std_stream).map_err(NodeServerError::CallHomeTransport);
13331    }
13332    #[cfg(not(feature = "wireguard"))]
13333    {
13334        tokio::net::TcpStream::connect(relay)
13335            .await
13336            .map_err(NodeServerError::CallHomeTransport)
13337    }
13338}
13339
13340/// One call-home attempt: connect, name this node, then serve.
13341async fn dial_relay_and_serve(
13342    relay: std::net::SocketAddr,
13343    identity: Option<&hatchery_enroll::NodeIdentity>,
13344    shared: Arc<NodeShared>,
13345    preauth_permit: OwnedSemaphorePermit,
13346) -> Result<(), NodeServerError> {
13347    let mut stream = dial_call_home(relay).await?;
13348    // Nagle off for the same reason the rest of this wire wants it off:
13349    // the traffic is small request/response frames, and coalescing them
13350    // adds latency to every one.
13351    let _ = stream.set_nodelay(true);
13352    // A relay that vanishes without a FIN/RST reaching us (tunnel drop,
13353    // relay-side socket kill whose RST was lost) would otherwise leave this
13354    // socket ESTABLISHED forever: the node mostly reads, so nothing ever
13355    // fails and the reconnect loop never runs. Kernel keepalive turns that
13356    // half-open link into a read error within a bounded time.
13357    if let Err(error) = enable_call_home_keepalive(&stream) {
13358        tracing::warn!(cause = %error, "could not enable TCP keepalive on the call-home socket");
13359    }
13360    gate4agent_node_wire::write_call_home_announce(&mut stream, &shared.node_id)
13361        .await
13362        .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13363    tracing::info!(node_id = %shared.node_id, %relay, "announced this node to the relay");
13364    let mut session_token = None;
13365    if let Some(identity) = identity {
13366        // An enrolled relay answers the announce with a signed-call-home challenge; a
13367        // token-mode relay sends its wire hello instead. Peek to tell them apart
13368        // without consuming either.
13369        let mut magic = [0_u8; 4];
13370        let peeked = timeout(Duration::from_secs(10), stream.peek(&mut magic))
13371            .await
13372            .map_err(|_| NodeServerError::Handshake("relay sent nothing after the announce".to_owned()))?
13373            .map_err(NodeServerError::CallHomeTransport)?;
13374        if peeked == 4 && &magic == hatchery_enroll::CHALLENGE_MAGIC {
13375            let mut frame = [0_u8; hatchery_enroll::CHALLENGE_LEN];
13376            timeout(Duration::from_secs(5), tokio::io::AsyncReadExt::read_exact(&mut stream, &mut frame))
13377                .await
13378                .map_err(|_| NodeServerError::Handshake("relay challenge timed out".to_owned()))?
13379                .map_err(NodeServerError::CallHomeTransport)?;
13380            let (answer, token) = identity
13381                .answer_challenge(shared.node_id.as_str(), &frame)
13382                .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13383            tokio::io::AsyncWriteExt::write_all(&mut stream, &answer).await.map_err(NodeServerError::CallHomeTransport)?;
13384            tracing::info!(node_id = %shared.node_id, "answered the relay's signed call-home challenge");
13385            session_token = Some(token);
13386        }
13387    }
13388    serve_connection(stream, shared, session_token, preauth_permit).await
13389}
13390
13391async fn accept_connections_inner(
13392    endpoint: &str,
13393    shared: Arc<NodeShared>,
13394    connections: &mut JoinSet<Result<(), NodeServerError>>,
13395) -> Result<(), NodeServerError> {
13396    let mut listener = OwnerOnlyLocalListener::bind(endpoint).await?;
13397    loop {
13398        while connections.try_join_next().is_some() {}
13399        if shared.shutdown.load(Ordering::Acquire) {
13400            return Ok(());
13401        }
13402        let preauth_permit = tokio::select! {
13403            permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13404                permit.map_err(|_| NodeServerError::PreauthClosed)?
13405            }
13406            _ = shared.shutdown_notify.notified() => return Ok(()),
13407        };
13408        let server = tokio::select! {
13409            result = listener.accept() => result?,
13410            _ = shared.shutdown_notify.notified() => return Ok(()),
13411        };
13412        let connection_shared = Arc::clone(&shared);
13413        connections.spawn(async move {
13414            serve_connection(server, connection_shared, None, preauth_permit).await
13415        });
13416        if shared.shutdown.load(Ordering::Acquire) {
13417            return Ok(());
13418        }
13419    }
13420}
13421
13422/// Serves one connection, whichever end opened it.
13423///
13424/// Generic over the stream rather than typed to `LocalServerStream`
13425/// because a call-home connection arrives on a `TcpStream` the node itself
13426/// dialled. Nothing in the body ever depended on the concrete type -- it
13427/// reads and writes framed JSON and then `tokio::io::split`s -- so this is
13428/// the whole of what welding "who dials" to "who serves" was costing.
13429async fn serve_connection<S>(
13430    mut pipe: S,
13431    shared: Arc<NodeShared>,
13432    session_token: Option<String>,
13433    preauth_permit: OwnedSemaphorePermit,
13434) -> Result<(), NodeServerError>
13435where
13436    S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
13437{
13438    // A signed call-home connection authenticates with its own per-connection token.
13439    let auth_token: &[u8] = session_token.as_deref().map_or(shared.access_token.as_bytes(), str::as_bytes);
13440    let hello_frame = timeout(
13441        Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13442        read_json_frame_limited_body_timeout(
13443            &mut pipe,
13444            MAX_NODE_HELLO_FRAME_BYTES,
13445            Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13446        ),
13447    )
13448    .await
13449    .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13450    let ClientFrame::Hello(hello) = hello_frame else {
13451        return Err(NodeServerError::Handshake("first frame must be hello".to_owned()));
13452    };
13453    if hello.build_stamp != BUILD_STAMP {
13454        return Err(NodeServerError::Handshake(format!(
13455            "build stamp mismatch: local={BUILD_STAMP} remote={}",
13456            hello.build_stamp,
13457        )));
13458    }
13459    let compatibility = match hello.compatibility.as_ref() {
13460        Some(offer) => {
13461            let mut selected = node_compatibility_support(&shared)?
13462                .negotiate(offer)
13463                .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13464            project_negotiated_provider_ids(&mut selected);
13465            Some(selected)
13466        }
13467        None => None,
13468    };
13469    let server_nonce = random_nonce().map_err(NodeServerError::Authentication)?;
13470    let server_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13471        (Some(offer), Some(selected)) => negotiated_auth_proof(
13472            auth_token,
13473            AuthDirection::Server,
13474            hello.role,
13475            &hello.client_nonce,
13476            &server_nonce,
13477            offer,
13478            selected,
13479        ),
13480        (None, None) => auth_proof(
13481            auth_token,
13482            AuthDirection::Server,
13483            hello.role,
13484            &hello.client_nonce,
13485            &server_nonce,
13486        ),
13487        _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13488    }
13489    .map_err(NodeServerError::Authentication)?;
13490    write_json_frame_limited(
13491        &mut pipe,
13492        &ServerFrame::Challenge(ServerChallenge {
13493            build_stamp: BUILD_STAMP.to_owned(),
13494            server_nonce,
13495            server_proof,
13496            compatibility: compatibility.clone(),
13497        }),
13498        MAX_NODE_HELLO_FRAME_BYTES,
13499    )
13500    .await?;
13501    let authentication = timeout(
13502        Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13503        read_json_frame_limited_body_timeout(
13504            &mut pipe,
13505            MAX_NODE_HELLO_FRAME_BYTES,
13506            Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13507        ),
13508    )
13509    .await
13510    .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13511    let ClientFrame::Authenticate(authentication) = authentication else {
13512        return Err(NodeServerError::Handshake("second frame must authenticate the challenge".to_owned()));
13513    };
13514    let expected_client_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13515        (Some(offer), Some(selected)) => negotiated_auth_proof(
13516            auth_token,
13517            AuthDirection::Client,
13518            hello.role,
13519            &hello.client_nonce,
13520            &server_nonce,
13521            offer,
13522            selected,
13523        ),
13524        (None, None) => auth_proof(
13525            auth_token,
13526            AuthDirection::Client,
13527            hello.role,
13528            &hello.client_nonce,
13529            &server_nonce,
13530        ),
13531        _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13532    }
13533    .map_err(NodeServerError::Authentication)?;
13534    if !proofs_match(&authentication.client_proof, &expected_client_proof) {
13535        return Err(NodeServerError::Handshake("access denied".to_owned()));
13536    }
13537    let selected_capabilities = compatibility
13538        .as_ref()
13539        .map(|selected| selected.capabilities.clone())
13540        .unwrap_or_default();
13541    let include_provider_runtime_status = selected_capabilities.iter().any(|capability| {
13542        capability.as_str() == NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY
13543    });
13544    let include_open_provider_ids = selected_capabilities.iter().any(|capability| {
13545        capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
13546    });
13547    let include_terminal_frame_events = selected_capabilities.iter().any(|capability| {
13548        capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
13549    });
13550    let include_agent_stream_events = selected_capabilities.iter().any(|capability| {
13551        capability.as_str() == NODE_AGENT_STREAM_EVENTS_CAPABILITY
13552    });
13553    let include_spawn_profiles = selected_capabilities.iter().any(|capability| {
13554        capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
13555    });
13556    let include_managed_worktrees = selected_capabilities.iter().any(|capability| {
13557        capability.as_str() == NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY
13558    }) && selected_capabilities.iter().any(|capability| {
13559        capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
13560    });
13561    let include_child_environment_profiles = selected_capabilities.iter().any(|capability| {
13562        capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
13563    });
13564    let include_session_bundles = selected_capabilities.iter().any(|capability| {
13565        capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
13566    });
13567    let include_history_context_packs = selected_capabilities.iter().any(|capability| {
13568        capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
13569    });
13570    let include_agent_progress = selected_capabilities.iter().any(|capability| {
13571        capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
13572    });
13573    let include_session_task_correlation = selected_capabilities.iter().any(|capability| {
13574        capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
13575    });
13576    let include_harness_mcp_proxy = selected_capabilities.iter().any(|capability| {
13577        capability.as_str() == NODE_HARNESS_MCP_READ_PROXY_CAPABILITY
13578    });
13579    let authenticated_permit = Arc::clone(&shared.authenticated_slots)
13580        .try_acquire_owned()
13581        .map_err(|_| NodeServerError::AuthenticatedConnectionLimit)?;
13582    let connection_id = shared.next_connection_id.fetch_add(1, Ordering::AcqRel);
13583    let _release_guard = ControllerReleaseGuard {
13584        shared: Arc::clone(&shared),
13585        connection_id,
13586    };
13587    drop(preauth_permit);
13588    let _authenticated_permit = authenticated_permit;
13589    let mut event_rx = shared.event_tx.subscribe();
13590    let mut terminal_event_rx = terminal_event_subscription(
13591        &shared,
13592        include_terminal_frame_events,
13593    );
13594    let mut agent_stream_event_rx = agent_stream_event_subscription(
13595        &shared,
13596        include_agent_stream_events,
13597    );
13598    let mut harness_mcp_proxy_event_rx = harness_mcp_proxy_event_subscription(
13599        &shared,
13600        include_harness_mcp_proxy,
13601    );
13602    write_json_frame(
13603        &mut pipe,
13604        &ServerFrame::Hello(NodeHello {
13605            build_stamp: BUILD_STAMP.to_owned(),
13606            incarnation_id: shared.incarnation_id,
13607            connection_id,
13608            role: hello.role,
13609            event_sequence: shared.current_sequence(),
13610            controller: shared.controller_state(),
13611            snapshot: snapshot_for_wire(
13612                &shared,
13613                include_provider_runtime_status,
13614                include_open_provider_ids,
13615                include_spawn_profiles,
13616                include_managed_worktrees,
13617                include_child_environment_profiles,
13618                include_session_bundles,
13619                include_history_context_packs,
13620                include_agent_progress,
13621                include_session_task_correlation,
13622            ),
13623            compatibility,
13624        }),
13625    )
13626    .await?;
13627    let (mut reader, mut writer) = tokio::io::split(pipe);
13628    let (frame_tx, mut frame_rx) = mpsc::channel(16);
13629    let reader_task = tokio::spawn(async move {
13630        loop {
13631            let frame = read_json_frame_limited_body_timeout::<_, ClientFrame>(
13632                &mut reader,
13633                MAX_NODE_CLIENT_FRAME_BYTES,
13634                Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13635            )
13636            .await;
13637            let terminal = frame.is_err();
13638            if frame_tx.send(frame).await.is_err() || terminal {
13639                break;
13640            }
13641        }
13642    });
13643    let _reader_abort = AbortTaskOnDrop(reader_task.abort_handle());
13644    let mut pending_events = Vec::new();
13645    let mut resync_required = false;
13646    let mut discard_events_through = 0_u64;
13647    loop {
13648        // One tick of the serve loop, whether or not it had anything to do.
13649        // Against `connection_events_sent` this separates "busy" from
13650        // "spinning": a loop turning over thousands of times a second while
13651        // sending nothing is not serving, it is burning a core.
13652        shared.connection_loop_iterations.fetch_add(1, Ordering::Relaxed);
13653        let durable_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
13654            .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
13655        for _ in 0..durable_drain_budget {
13656            match event_rx.try_recv() {
13657                Ok(event) => queue_connection_event(
13658                    &mut pending_events,
13659                    event,
13660                    discard_events_through,
13661                ),
13662                Err(broadcast::error::TryRecvError::Empty) => break,
13663                Err(broadcast::error::TryRecvError::Lagged(_)) => {
13664                    resync_required = true;
13665                    break;
13666                }
13667                Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13668            }
13669        }
13670        if let Some(receiver) = terminal_event_rx.as_mut() {
13671            match receiver.try_recv() {
13672                Ok(events) => queue_connection_event_batch(
13673                    &mut pending_events,
13674                    &events,
13675                    discard_events_through,
13676                ),
13677                Err(broadcast::error::TryRecvError::Empty) => {}
13678                Err(broadcast::error::TryRecvError::Lagged(_)) => {
13679                    resync_required = true;
13680                }
13681                Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13682            }
13683        }
13684        if let Some(receiver) = agent_stream_event_rx.as_mut() {
13685            // Loops up to the same remaining-room budget the durable
13686            // `event_rx` drain above uses, rather than one `try_recv` per
13687            // tick: each successful receive here is itself only ONE
13688            // `NodeEventEnvelope` batch (`publish_agent_stream_chunk` always
13689            // sends `vec![envelope]`, never more), so a single-shot drain
13690            // caps this connection's agent-stream throughput at one chunk
13691            // per outer-loop tick regardless of how many are already queued
13692            // -- exactly the kind of self-inflicted stall this crate's own
13693            // durable-event budget exists to avoid.
13694            let agent_stream_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
13695                .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
13696            for _ in 0..agent_stream_drain_budget {
13697                match receiver.try_recv() {
13698                    Ok(events) => queue_connection_event_batch(
13699                        &mut pending_events,
13700                        &events,
13701                        discard_events_through,
13702                    ),
13703                    Err(broadcast::error::TryRecvError::Empty) => break,
13704                    Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
13705                        tracing::warn!(
13706                            connection_id,
13707                            dropped,
13708                            "agent stream event subscription lagged; forcing a resync",
13709                        );
13710                        resync_required = true;
13711                        break;
13712                    }
13713                    Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13714                }
13715            }
13716        }
13717        if let Some(receiver) = harness_mcp_proxy_event_rx.as_mut() {
13718            // Mirrors the agent-stream drain immediately above: each
13719            // successful receive is one single-envelope batch
13720            // (`publish_transient` always sends `vec![envelope]`), so this
13721            // loops up to the same remaining-room budget rather than
13722            // stopping after one `try_recv`.
13723            let harness_mcp_proxy_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
13724                .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
13725            for _ in 0..harness_mcp_proxy_drain_budget {
13726                match receiver.try_recv() {
13727                    Ok(events) => queue_connection_event_batch(
13728                        &mut pending_events,
13729                        &events,
13730                        discard_events_through,
13731                    ),
13732                    Err(broadcast::error::TryRecvError::Empty) => break,
13733                    Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
13734                        tracing::warn!(
13735                            connection_id,
13736                            dropped,
13737                            "harness MCP proxy event subscription lagged; forcing a resync",
13738                        );
13739                        resync_required = true;
13740                        break;
13741                    }
13742                    Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13743                }
13744            }
13745        }
13746        if resync_required {
13747            let event = resync_required_event(&shared);
13748            apply_connection_resync_watermark(
13749                &mut pending_events,
13750                &mut discard_events_through,
13751                event.sequence,
13752            );
13753            write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
13754            resync_required = false;
13755            continue;
13756        }
13757        if !pending_events.is_empty() {
13758            pending_events.sort_unstable_by_key(|event| event.sequence);
13759            let burst_len = connection_event_burst_len(pending_events.len());
13760            shared
13761                .connection_events_sent
13762                .fetch_add(burst_len as u64, Ordering::Relaxed);
13763            for event in pending_events.drain(..burst_len) {
13764                let event = if include_managed_worktrees {
13765                    Some(event)
13766                } else {
13767                    project_event_without_managed_worktrees(event)
13768                };
13769                let event = event.and_then(|event| if include_open_provider_ids {
13770                    Some(event)
13771                } else {
13772                    project_event_legacy_provider_ids(&shared, event)
13773                });
13774                let event = event.map(|event| if include_child_environment_profiles {
13775                    event
13776                } else {
13777                    project_event_without_child_environment_profile(event)
13778                });
13779                let event = event.map(|event| if include_session_bundles {
13780                    event
13781                } else {
13782                    project_event_without_session_bundle(event)
13783                });
13784                let event = event.map(|event| {
13785                    project_event_history_for_wire(event, include_history_context_packs)
13786                });
13787                let event = event.map(|event| if include_history_context_packs {
13788                    event
13789                } else {
13790                    project_event_without_context_pack(event)
13791                });
13792                let event = event.map(|event| if include_session_task_correlation {
13793                    event
13794                } else {
13795                    project_event_without_session_task_binding(event)
13796                });
13797                let exact_harness_controller = hello.role == ClientRole::Operator
13798                    && shared.controller_state().is_some_and(|controller| {
13799                        controller.connection_id == connection_id
13800                    });
13801                let event = event.and_then(|event| {
13802                    if include_harness_mcp_proxy && exact_harness_controller {
13803                        Some(event)
13804                    } else {
13805                        project_event_without_harness_mcp_proxy(event)
13806                    }
13807                });
13808                if let Some(event) = event {
13809                    write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
13810                }
13811            }
13812        }
13813        tokio::select! {
13814            biased;
13815            frame = frame_rx.recv() => {
13816                let frame = match frame {
13817                    Some(frame) => frame,
13818                    None => break,
13819                };
13820                let frame = match frame {
13821                    Ok(frame) => frame,
13822                    Err(FrameError::Io(error)) if matches!(error.kind(), io::ErrorKind::BrokenPipe | io::ErrorKind::UnexpectedEof) => break,
13823                    Err(error) => return Err(error.into()),
13824                };
13825                let ClientFrame::Request(request) = frame else {
13826                    return Err(NodeServerError::Handshake("hello may only be sent once".to_owned()));
13827                };
13828                #[cfg(feature = "fixture")]
13829                let probe_spawn_managed_worktree_v2 = matches!(
13830                    &request.request,
13831                    NodeRequest::SpawnManagedWorktreeV2 { .. }
13832                );
13833                let requires_open_provider_ids =
13834                    request_requires_open_provider_ids(&shared, &request.request);
13835                let requires_child_environment_profile =
13836                    request_requires_child_environment_profile(&shared, &request.request);
13837                let requires_session_bundle =
13838                    request_requires_session_bundle(&shared, &request.request);
13839                let requires_history_context_pack =
13840                    request_requires_history_context_pack(&shared, &request.request);
13841                let mut reply = if requires_open_provider_ids && !include_open_provider_ids {
13842                    ResponseEnvelope {
13843                        request_id: request.request_id,
13844                        result: Err(failure(
13845                            NodeFailureCode::UnsupportedCapability,
13846                            "open provider IDs were not negotiated",
13847                        )),
13848                    }
13849                } else if requires_child_environment_profile
13850                    && !include_child_environment_profiles
13851                {
13852                    ResponseEnvelope {
13853                        request_id: request.request_id,
13854                        result: Err(failure(
13855                            NodeFailureCode::UnsupportedCapability,
13856                            "child environment profiles were not negotiated",
13857                        )),
13858                    }
13859                } else if requires_session_bundle && !include_session_bundles {
13860                    ResponseEnvelope {
13861                        request_id: request.request_id,
13862                        result: Err(failure(
13863                            NodeFailureCode::UnsupportedCapability,
13864                            "session bundles were not negotiated",
13865                        )),
13866                    }
13867                } else if requires_history_context_pack && !include_history_context_packs {
13868                    ResponseEnvelope {
13869                        request_id: request.request_id,
13870                        result: Err(failure(
13871                            NodeFailureCode::UnsupportedCapability,
13872                            "history context packs were not negotiated",
13873                        )),
13874                    }
13875                } else if request_uses_unnegotiated_capability(
13876                    &request.request,
13877                    &selected_capabilities,
13878                )
13879                {
13880                    ResponseEnvelope {
13881                        request_id: request.request_id,
13882                        result: Err(failure(
13883                            NodeFailureCode::UnsupportedCapability,
13884                            "request capability was not negotiated",
13885                        )),
13886                    }
13887                } else {
13888                    process_request(&shared, connection_id, hello.role, request).await
13889                };
13890                #[cfg(feature = "fixture")]
13891                if probe_spawn_managed_worktree_v2 {
13892                    shared
13893                        .fixture_spawn_managed_worktree_v2_failure_probe
13894                        .record(reply.result.as_ref().err().map(|failure| failure.code));
13895                }
13896                if !include_provider_runtime_status {
13897                    clear_response_provider_runtime_status(&mut reply);
13898                }
13899                if !include_managed_worktrees {
13900                    project_response_without_managed_worktrees(&mut reply);
13901                }
13902                if !include_open_provider_ids {
13903                    project_response_legacy_provider_ids(&shared, &mut reply);
13904                }
13905                if !include_child_environment_profiles {
13906                    project_response_without_child_environment_profile(&mut reply);
13907                }
13908                if !include_session_bundles {
13909                    project_response_without_session_bundle(&mut reply);
13910                }
13911                project_response_history_for_wire(
13912                    &mut reply,
13913                    include_history_context_packs,
13914                );
13915                if !include_history_context_packs {
13916                    project_response_without_context_pack(&mut reply);
13917                }
13918                if !include_agent_progress {
13919                    project_response_without_agent_progress(&mut reply);
13920                }
13921                if !include_session_task_correlation {
13922                    project_response_without_session_task_binding(&mut reply);
13923                }
13924                write_json_frame(&mut writer, &ServerFrame::Reply(reply)).await?;
13925            }
13926            _ = tokio::task::yield_now(), if !pending_events.is_empty() => {}
13927            event = event_rx.recv() => {
13928                match event {
13929                    Ok(event) => queue_connection_event(
13930                        &mut pending_events,
13931                        event,
13932                        discard_events_through,
13933                    ),
13934                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13935                    Err(broadcast::error::RecvError::Closed) => break,
13936                }
13937            }
13938            events = receive_terminal_event_batch(&mut terminal_event_rx) => {
13939                match events {
13940                    Ok(events) => queue_connection_event_batch(
13941                        &mut pending_events,
13942                        &events,
13943                        discard_events_through,
13944                    ),
13945                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13946                    Err(broadcast::error::RecvError::Closed) => break,
13947                }
13948            }
13949            events = receive_terminal_event_batch(&mut agent_stream_event_rx) => {
13950                match events {
13951                    Ok(events) => queue_connection_event_batch(
13952                        &mut pending_events,
13953                        &events,
13954                        discard_events_through,
13955                    ),
13956                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13957                    Err(broadcast::error::RecvError::Closed) => break,
13958                }
13959            }
13960            events = receive_terminal_event_batch(&mut harness_mcp_proxy_event_rx) => {
13961                match events {
13962                    Ok(events) => queue_connection_event_batch(
13963                        &mut pending_events,
13964                        &events,
13965                        discard_events_through,
13966                    ),
13967                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13968                    Err(broadcast::error::RecvError::Closed) => break,
13969                }
13970            }
13971            _ = shared.shutdown_notify.notified() => break,
13972        }
13973    }
13974    Ok(())
13975}
13976
13977async fn receive_terminal_event_batch(
13978    receiver: &mut Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>>,
13979) -> Result<Arc<Vec<NodeEventEnvelope>>, broadcast::error::RecvError> {
13980    match receiver {
13981        Some(receiver) => receiver.recv().await,
13982        None => std::future::pending().await,
13983    }
13984}
13985
13986fn terminal_event_subscription(
13987    shared: &NodeShared,
13988    enabled: bool,
13989) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13990    enabled.then(|| shared.terminal_event_tx.subscribe())
13991}
13992
13993/// Mirrors `terminal_event_subscription` exactly, over the agent stream's
13994/// own channel -- see `NodeShared::agent_stream_event_tx`'s own doc for why
13995/// this needs a dedicated channel rather than a flag on the terminal one.
13996fn agent_stream_event_subscription(
13997    shared: &NodeShared,
13998    enabled: bool,
13999) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14000    enabled.then(|| shared.agent_stream_event_tx.subscribe())
14001}
14002
14003/// Mirrors `agent_stream_event_subscription` exactly, over the harness MCP
14004/// proxy's own channel -- see `NodeShared::harness_mcp_proxy_event_tx`'s
14005/// own doc for why this needs a dedicated channel rather than a ride on
14006/// the durable `event_tx`.
14007fn harness_mcp_proxy_event_subscription(
14008    shared: &NodeShared,
14009    enabled: bool,
14010) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14011    enabled.then(|| shared.harness_mcp_proxy_event_tx.subscribe())
14012}
14013
14014fn queue_connection_event(
14015    pending: &mut Vec<NodeEventEnvelope>,
14016    event: NodeEventEnvelope,
14017    discard_through: u64,
14018) {
14019    if event.sequence <= discard_through
14020        && !event.event.requires_harness_mcp_proxy_capability()
14021    {
14022        return;
14023    }
14024    if let NodeEvent::TerminalFrame { address, .. } = &event.event {
14025        if let Some(index) = pending.iter().position(|current| {
14026            matches!(
14027                &current.event,
14028                NodeEvent::TerminalFrame {
14029                    address: current_address,
14030                    ..
14031                } if current_address == address
14032            )
14033        }) {
14034            pending.remove(index);
14035        }
14036    }
14037    pending.push(event);
14038}
14039
14040fn project_event_without_harness_mcp_proxy(
14041    envelope: NodeEventEnvelope,
14042) -> Option<NodeEventEnvelope> {
14043    (!envelope.event.requires_harness_mcp_proxy_capability()).then_some(envelope)
14044}
14045
14046fn queue_connection_event_batch(
14047    pending: &mut Vec<NodeEventEnvelope>,
14048    events: &[NodeEventEnvelope],
14049    discard_through: u64,
14050) {
14051    for event in events {
14052        queue_connection_event(pending, event.clone(), discard_through);
14053    }
14054}
14055
14056fn apply_connection_resync_watermark(
14057    pending: &mut Vec<NodeEventEnvelope>,
14058    discard_through: &mut u64,
14059    marker_sequence: u64,
14060) {
14061    *discard_through = (*discard_through).max(marker_sequence);
14062    pending.retain(|event| event.sequence > *discard_through);
14063}
14064
14065fn connection_event_burst_len(pending_len: usize) -> usize {
14066    pending_len.min(NODE_CONNECTION_EVENT_BURST_MAX)
14067}
14068
14069fn resync_required_event(shared: &NodeShared) -> NodeEventEnvelope {
14070    let history = shared
14071        .history
14072        .lock()
14073        .unwrap_or_else(|poisoned| poisoned.into_inner());
14074    let sequence = history.last_sequence;
14075    let oldest_available_sequence = history.replay_floor_sequence;
14076    NodeEventEnvelope {
14077        sequence,
14078        event: NodeEvent::ResyncRequired {
14079            oldest_available_sequence,
14080        },
14081    }
14082}
14083
14084fn node_compatibility_support(
14085    shared: &NodeShared,
14086) -> Result<NodeCompatibilitySupport, NodeServerError> {
14087    let mut support = node_compatibility_support_for_manifest(
14088        &shared.provider_contracts,
14089        &shared.provider_adapter_contracts,
14090    )?;
14091    if shared
14092        .delivery_store
14093        .lock()
14094        .unwrap_or_else(|poisoned| poisoned.into_inner())
14095        .is_some()
14096    {
14097        support.capabilities.push(
14098            CapabilityId::new(NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY)
14099                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14100        );
14101    }
14102    if shared.harness_mcp_registry.is_some() {
14103        support.capabilities.push(
14104            CapabilityId::new(NODE_HARNESS_MCP_READ_PROXY_CAPABILITY)
14105                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14106        );
14107    }
14108    Ok(support)
14109}
14110
14111fn node_compatibility_support_for_manifest(
14112    provider_contracts: &[ProviderContractSupport],
14113    provider_adapter_contracts: &[ProviderAdapterContractSupport],
14114) -> Result<NodeCompatibilitySupport, NodeServerError> {
14115    Ok(NodeCompatibilitySupport {
14116        build_stamp: BUILD_STAMP.to_owned(),
14117        capabilities: baseline_capabilities()?,
14118        host: platform::host_descriptor().map_err(NodeServerError::Handshake)?,
14119        path_semantics: platform::path_semantics(),
14120        local_transport: platform::local_transport(),
14121        state_schema: StateSchemaSupport {
14122            versions: ProtocolRange::new(NODE_STATE_SCHEMA_V1, NODE_STATE_SCHEMA_V10)
14123                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14124        },
14125        provider_contracts: provider_contracts.to_vec(),
14126        provider_adapter_contracts: provider_adapter_contracts.to_vec(),
14127    })
14128}
14129
14130fn baseline_capabilities() -> Result<Vec<CapabilityId>, NodeServerError> {
14131    let capabilities = [
14132        NODE_COMPATIBILITY_METADATA_CAPABILITY,
14133        CAPABILITY_HOST_DIRECTORY_BROWSE_V1,
14134        NODE_REPOSITORY_PATH_CAPABILITY,
14135        NODE_WORKSPACE_FILE_READ_CAPABILITY,
14136        NODE_WORKSPACE_FILE_WRITE_CAPABILITY,
14137        NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY,
14138        NODE_GIT_READ_CAPABILITY,
14139        NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY,
14140        NODE_PROVIDER_ID_OPEN_CAPABILITY,
14141        NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
14142        NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY,
14143        NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
14144        NODE_AGENT_STREAM_EVENTS_CAPABILITY,
14145        NODE_ACP_CONTROL_CAPABILITY,
14146        NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
14147        NODE_SPAWN_PROFILE_REVISION_CAPABILITY,
14148        NODE_WORKTREE_SELECTION_CAPABILITY,
14149        NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
14150        NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
14151        NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
14152        NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
14153        NODE_HISTORY_CONTEXT_PACK_CAPABILITY,
14154        NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
14155        NODE_NATIVE_SESSION_CATALOG_CAPABILITY,
14156        NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
14157        NODE_NATIVE_SESSION_INDEX_CAPABILITY,
14158        NODE_NATIVE_SESSION_PREVIEW_CAPABILITY,
14159        NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
14160        NODE_SESSION_TASK_CORRELATION_CAPABILITY,
14161    ]
14162        .into_iter()
14163        .map(|capability| {
14164            CapabilityId::new(capability)
14165                .map_err(|error| NodeServerError::Handshake(error.to_string()))
14166        })
14167        .collect::<Result<Vec<_>, _>>()?;
14168    #[cfg(windows)]
14169    let capabilities = {
14170        let mut capabilities = capabilities;
14171        capabilities.push(
14172            CapabilityId::new(NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY)
14173                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14174        );
14175        capabilities
14176    };
14177    Ok(capabilities)
14178}
14179
14180fn request_uses_unnegotiated_capability(
14181    request: &NodeRequest,
14182    selected_capabilities: &[CapabilityId],
14183) -> bool {
14184    let selected = |required: &str| {
14185        selected_capabilities
14186            .iter()
14187            .any(|capability| capability.as_str() == required)
14188    };
14189    request
14190        .required_capability()
14191        .is_some_and(|required| !selected(required))
14192        || (request.requires_worktree_selection_capability()
14193            && !selected(NODE_WORKTREE_SELECTION_CAPABILITY))
14194        || (request.requires_spawn_spec_defaults_overrides_capability()
14195            && !selected(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY))
14196        || (request.requires_spawn_profile_revision_capability()
14197            && !selected(NODE_SPAWN_PROFILE_REVISION_CAPABILITY))
14198        || (request.requires_child_environment_profile_capability()
14199            && !selected(NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY))
14200        || (request.requires_session_bundle_materialization_capability()
14201            && !selected(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY))
14202        || (request.requires_history_context_pack_capability()
14203            && !selected(NODE_HISTORY_CONTEXT_PACK_CAPABILITY))
14204}
14205
14206fn snapshot_for_wire(
14207    shared: &NodeShared,
14208    include_provider_runtime_status: bool,
14209    include_open_provider_ids: bool,
14210    include_spawn_profiles: bool,
14211    include_managed_worktrees: bool,
14212    include_child_environment_profiles: bool,
14213    include_session_bundles: bool,
14214    include_history_context_packs: bool,
14215    include_agent_progress: bool,
14216    include_session_task_correlation: bool,
14217) -> NodeSnapshot {
14218    let mut snapshot = shared.snapshot();
14219    if !include_provider_runtime_status {
14220        snapshot.provider_runtime_statuses.clear();
14221    }
14222    if !include_open_provider_ids {
14223        project_snapshot_legacy_provider_ids(&mut snapshot);
14224    }
14225    if !include_managed_worktrees {
14226        snapshot.managed_worktrees.clear();
14227        for workspace in &mut snapshot.workspaces {
14228            workspace.managed_worktree_profiles = None;
14229        }
14230    }
14231    if let Some(inventory) = snapshot.launch_inventory.as_mut() {
14232        if !include_spawn_profiles {
14233            inventory.spawn_profiles = None;
14234        }
14235        if !include_session_bundles {
14236            inventory.bundles = None;
14237        }
14238        if inventory.spawn_profiles.is_none()
14239            && inventory.bundles.is_none()
14240            && inventory.network_allowlists.is_none()
14241        {
14242            snapshot.launch_inventory = None;
14243        }
14244    }
14245    if !include_child_environment_profiles {
14246        clear_snapshot_child_environment_profiles(&mut snapshot);
14247    }
14248    if !include_session_bundles {
14249        clear_snapshot_session_bundles(&mut snapshot);
14250    }
14251    project_snapshot_history_for_wire(&mut snapshot, include_history_context_packs);
14252    if !include_history_context_packs {
14253        clear_snapshot_context_packs(&mut snapshot);
14254    }
14255    if !include_agent_progress {
14256        snapshot.agent_progress.clear();
14257    }
14258    if !include_session_task_correlation {
14259        clear_snapshot_session_task_bindings(&mut snapshot);
14260    }
14261    snapshot
14262}
14263
14264fn clear_snapshot_session_task_bindings(snapshot: &mut NodeSnapshot) {
14265    for record in &mut snapshot.session_records {
14266        record.task_binding = None;
14267    }
14268}
14269
14270fn project_event_without_session_task_binding(
14271    mut envelope: NodeEventEnvelope,
14272) -> NodeEventEnvelope {
14273    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14274        record.task_binding = None;
14275    }
14276    envelope
14277}
14278
14279fn project_response_without_session_task_binding(reply: &mut ResponseEnvelope) {
14280    let Ok(response) = reply.result.as_mut() else { return };
14281    match response {
14282        NodeResponse::Snapshot { snapshot, .. } => {
14283            clear_snapshot_session_task_bindings(snapshot);
14284        }
14285        NodeResponse::Resync { snapshot, events, .. } => {
14286            clear_snapshot_session_task_bindings(snapshot);
14287            for event in events {
14288                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14289                    record.task_binding = None;
14290                }
14291            }
14292        }
14293        NodeResponse::SessionRecordUpdated { record }
14294        | NodeResponse::ProviderSessionIndexed { record }
14295        | NodeResponse::NativeSessionIndexed { record, .. }
14296        | NodeResponse::SessionRecordResumed { record, .. } => {
14297            record.task_binding = None;
14298        }
14299        _ => {}
14300    }
14301}
14302
14303fn project_response_without_agent_progress(reply: &mut ResponseEnvelope) {
14304    let Ok(response) = reply.result.as_mut() else { return };
14305    match response {
14306        NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
14307            snapshot.agent_progress.clear();
14308        }
14309        _ => {}
14310    }
14311}
14312
14313fn project_event_without_managed_worktrees(
14314    mut envelope: NodeEventEnvelope,
14315) -> Option<NodeEventEnvelope> {
14316    if matches!(
14317        envelope.event,
14318        NodeEvent::ManagedWorktreeUpserted { .. }
14319            | NodeEvent::ManagedWorktreeRemoved { .. }
14320    ) {
14321        return None;
14322    }
14323    if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
14324        workspace.managed_worktree_profiles = None;
14325    }
14326    Some(envelope)
14327}
14328
14329fn project_response_without_managed_worktrees(reply: &mut ResponseEnvelope) {
14330    if matches!(
14331        reply.result,
14332        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { .. })
14333            | Ok(NodeResponse::ManagedWorktreeCleanup { .. })
14334    ) {
14335        reply.result = Err(failure(
14336            NodeFailureCode::UnsupportedCapability,
14337            "managed worktree capability was not negotiated",
14338        ));
14339        return;
14340    }
14341    let Ok(response) = reply.result.as_mut() else { return };
14342    match response {
14343        NodeResponse::Snapshot { snapshot, .. } => {
14344            snapshot.managed_worktrees.clear();
14345            for workspace in &mut snapshot.workspaces {
14346                workspace.managed_worktree_profiles = None;
14347            }
14348        }
14349        NodeResponse::Resync { snapshot, events, .. } => {
14350            snapshot.managed_worktrees.clear();
14351            for workspace in &mut snapshot.workspaces {
14352                workspace.managed_worktree_profiles = None;
14353            }
14354            *events = std::mem::take(events)
14355                .into_iter()
14356                .filter_map(project_event_without_managed_worktrees)
14357                .collect();
14358        }
14359        NodeResponse::WorkspaceRegistered { workspace }
14360        | NodeResponse::StandaloneWorkspaceCreated { workspace }
14361        | NodeResponse::WorktreeCreated { workspace, .. } => {
14362            workspace.managed_worktree_profiles = None;
14363        }
14364        NodeResponse::WorkspaceInspected { inspection } => {
14365            inspection.git.managed_worktree = None;
14366        }
14367        _ => {}
14368    }
14369}
14370
14371fn project_negotiated_provider_ids(compatibility: &mut NegotiatedNodeCompatibility) {
14372    let includes_open_provider_ids = compatibility.capabilities.iter().any(|capability| {
14373        capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
14374    });
14375    if includes_open_provider_ids {
14376        return;
14377    }
14378    compatibility
14379        .provider_contracts
14380        .retain(|contract| provider_id_is_legacy(&contract.provider));
14381    compatibility
14382        .provider_adapter_contracts
14383        .retain(|contract| provider_id_is_legacy(&contract.provider));
14384}
14385
14386fn request_requires_open_provider_ids(shared: &NodeShared, request: &NodeRequest) -> bool {
14387    if let Some(spec) = match request {
14388        NodeRequest::SpawnSpec { spec }
14389        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14390        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14391        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14392        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14393        _ => None,
14394    } {
14395        return shared
14396            .resolve_spawn_spec(spec)
14397            .is_ok_and(|resolved| !provider_id_is_legacy(&resolved.provider));
14398    }
14399    request_requires_open_provider_ids_with(
14400        request,
14401        |session| shared.validate_address(session).ok(),
14402        |record_id| shared.record(record_id).ok().map(|record| record.provider),
14403    )
14404}
14405
14406fn request_requires_child_environment_profile(
14407    shared: &NodeShared,
14408    request: &NodeRequest,
14409) -> bool {
14410    if let Some(spec) = match request {
14411        NodeRequest::SpawnSpec { spec }
14412        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14413        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14414        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14415        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14416        _ => None,
14417    } {
14418        return shared
14419            .resolve_spawn_spec(spec)
14420            .is_ok_and(|resolved| resolved.environment_profile_id.is_some());
14421    }
14422    match request {
14423        NodeRequest::Resume { session, .. }
14424        | NodeRequest::Prompt { session, .. }
14425        | NodeRequest::Paste { session, .. }
14426        | NodeRequest::Input { session, .. }
14427        | NodeRequest::TerminalBytes { session, .. }
14428        | NodeRequest::TerminalControl { session, .. }
14429        | NodeRequest::Resize { session, .. }
14430        | NodeRequest::Interrupt { session }
14431        | NodeRequest::Stop { session, .. }
14432        | NodeRequest::Remove { session }
14433        | NodeRequest::ResolveInteraction { session, .. }
14434        | NodeRequest::SetSessionMode { session, .. }
14435        | NodeRequest::SetSessionConfigOption { session, .. }
14436        | NodeRequest::SetSessionModel { session, .. }
14437        | NodeRequest::DiscoverHistory { session, .. }
14438        | NodeRequest::LoadHistory { session, .. }
14439        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14440        | NodeRequest::ExportContextPack { session } => shared
14441            .session_bindings
14442            .lock()
14443            .unwrap_or_else(|poisoned| poisoned.into_inner())
14444            .get(&session.session.instance_id)
14445            .filter(|binding| {
14446                binding.workspace_id == session.workspace_id
14447                    && binding.generation == session.session.generation
14448            })
14449            .map_or(true, |binding| binding.environment_profile.is_some()),
14450        NodeRequest::RenameSessionRecord { record_id, .. }
14451        | NodeRequest::SetSessionTask { record_id, .. }
14452        | NodeRequest::ResumeSessionRecord { record_id, .. }
14453        | NodeRequest::ForgetSessionRecord { record_id } => shared
14454            .record(record_id)
14455            .map_or(true, |record| record.environment_profile.is_some()),
14456        NodeRequest::ArmHarnessMcpReservation { .. }
14457        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14458        | NodeRequest::ActivateHarnessMcpReservation { .. }
14459        | NodeRequest::AbortHarnessMcpReservation { .. }
14460        | NodeRequest::PutHarnessMcpReplyChunk { .. }
14461        | NodeRequest::RejectHarnessMcpCall { .. }
14462        | NodeRequest::Snapshot
14463        | NodeRequest::Resync { .. }
14464        | NodeRequest::BeginDeliveryStage { .. }
14465        | NodeRequest::PutDeliveryBlobChunk { .. }
14466        | NodeRequest::CommitDeliveryStage { .. }
14467        | NodeRequest::AbortDeliveryStage { .. }
14468        | NodeRequest::BrowseHostDirectories { .. }
14469        | NodeRequest::InspectWorkspace { .. }
14470        | NodeRequest::ReadWorkspaceFile { .. }
14471        | NodeRequest::WriteWorkspaceFile { .. }
14472        | NodeRequest::CreateWorkspaceFile { .. }
14473        | NodeRequest::CreateWorkspaceDirectory { .. }
14474        | NodeRequest::ReadGitHistory { .. }
14475        | NodeRequest::ReadGitDiff { .. }
14476        | NodeRequest::AcquireController { .. }
14477        | NodeRequest::ReleaseController
14478        | NodeRequest::Spawn { .. }
14479        | NodeRequest::SpawnWithOptions { .. }
14480        | NodeRequest::SpawnSpec { .. }
14481        | NodeRequest::RegisterWorkspace { .. }
14482        | NodeRequest::CreateStandaloneWorkspace { .. }
14483        | NodeRequest::UnregisterWorkspace { .. }
14484        | NodeRequest::CreateWorktree { .. }
14485        | NodeRequest::RemoveWorktree { .. }
14486        | NodeRequest::SpawnManagedWorktree { .. }
14487        | NodeRequest::SpawnManagedWorktreeV2 { .. }
14488        | NodeRequest::CleanupManagedWorktree { .. }
14489        | NodeRequest::IndexProviderSession { .. }
14490        | NodeRequest::IndexNativeSession { .. }
14491        | NodeRequest::CatalogNativeSessions { .. }
14492        | NodeRequest::PageNativeSessions { .. }
14493        | NodeRequest::PreviewNativeSession { .. }
14494        | NodeRequest::PreviewSessionRecord { .. }
14495        | NodeRequest::ForgetContextPack { .. }
14496        | NodeRequest::ResolveDurableContextPack { .. }
14497        | NodeRequest::ReadContextPack { .. }
14498        | NodeRequest::Shutdown => false,
14499    }
14500}
14501
14502fn request_requires_session_bundle(shared: &NodeShared, request: &NodeRequest) -> bool {
14503    if let Some(spec) = match request {
14504        NodeRequest::SpawnSpec { spec }
14505        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14506        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14507        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14508        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14509        _ => None,
14510    } {
14511        return shared
14512            .resolve_spawn_spec(spec)
14513            .is_ok_and(|resolved| resolved.bundle_id.is_some());
14514    }
14515    match request {
14516        NodeRequest::Resume { session, .. }
14517        | NodeRequest::Prompt { session, .. }
14518        | NodeRequest::Paste { session, .. }
14519        | NodeRequest::Input { session, .. }
14520        | NodeRequest::TerminalBytes { session, .. }
14521        | NodeRequest::TerminalControl { session, .. }
14522        | NodeRequest::Resize { session, .. }
14523        | NodeRequest::Interrupt { session }
14524        | NodeRequest::Stop { session, .. }
14525        | NodeRequest::Remove { session }
14526        | NodeRequest::ResolveInteraction { session, .. }
14527        | NodeRequest::SetSessionMode { session, .. }
14528        | NodeRequest::SetSessionConfigOption { session, .. }
14529        | NodeRequest::SetSessionModel { session, .. }
14530        | NodeRequest::DiscoverHistory { session, .. }
14531        | NodeRequest::LoadHistory { session, .. }
14532        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14533        | NodeRequest::ExportContextPack { session } => shared
14534            .session_bindings
14535            .lock()
14536            .unwrap_or_else(|poisoned| poisoned.into_inner())
14537            .get(&session.session.instance_id)
14538            .filter(|binding| {
14539                binding.workspace_id == session.workspace_id
14540                    && binding.generation == session.session.generation
14541            })
14542            .map_or(true, |binding| binding.bundle.is_some()),
14543        NodeRequest::RenameSessionRecord { record_id, .. }
14544        | NodeRequest::SetSessionTask { record_id, .. }
14545        | NodeRequest::ResumeSessionRecord { record_id, .. }
14546        | NodeRequest::ForgetSessionRecord { record_id } => shared
14547            .record(record_id)
14548            .map_or(true, |record| record.bundle.is_some()),
14549        NodeRequest::ArmHarnessMcpReservation { .. }
14550        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14551        | NodeRequest::ActivateHarnessMcpReservation { .. }
14552        | NodeRequest::AbortHarnessMcpReservation { .. }
14553        | NodeRequest::PutHarnessMcpReplyChunk { .. }
14554        | NodeRequest::RejectHarnessMcpCall { .. }
14555        | NodeRequest::Snapshot
14556        | NodeRequest::Resync { .. }
14557        | NodeRequest::BeginDeliveryStage { .. }
14558        | NodeRequest::PutDeliveryBlobChunk { .. }
14559        | NodeRequest::CommitDeliveryStage { .. }
14560        | NodeRequest::AbortDeliveryStage { .. }
14561        | NodeRequest::BrowseHostDirectories { .. }
14562        | NodeRequest::InspectWorkspace { .. }
14563        | NodeRequest::ReadWorkspaceFile { .. }
14564        | NodeRequest::WriteWorkspaceFile { .. }
14565        | NodeRequest::CreateWorkspaceFile { .. }
14566        | NodeRequest::CreateWorkspaceDirectory { .. }
14567        | NodeRequest::ReadGitHistory { .. }
14568        | NodeRequest::ReadGitDiff { .. }
14569        | NodeRequest::AcquireController { .. }
14570        | NodeRequest::ReleaseController
14571        | NodeRequest::Spawn { .. }
14572        | NodeRequest::SpawnWithOptions { .. }
14573        | NodeRequest::SpawnSpec { .. }
14574        | NodeRequest::RegisterWorkspace { .. }
14575        | NodeRequest::CreateStandaloneWorkspace { .. }
14576        | NodeRequest::UnregisterWorkspace { .. }
14577        | NodeRequest::CreateWorktree { .. }
14578        | NodeRequest::RemoveWorktree { .. }
14579        | NodeRequest::SpawnManagedWorktree { .. }
14580        | NodeRequest::SpawnManagedWorktreeV2 { .. }
14581        | NodeRequest::CleanupManagedWorktree { .. }
14582        | NodeRequest::IndexProviderSession { .. }
14583        | NodeRequest::IndexNativeSession { .. }
14584        | NodeRequest::CatalogNativeSessions { .. }
14585        | NodeRequest::PageNativeSessions { .. }
14586        | NodeRequest::PreviewNativeSession { .. }
14587        | NodeRequest::PreviewSessionRecord { .. }
14588        | NodeRequest::ForgetContextPack { .. }
14589        | NodeRequest::ResolveDurableContextPack { .. }
14590        | NodeRequest::ReadContextPack { .. }
14591        | NodeRequest::Shutdown => false,
14592    }
14593}
14594
14595fn request_requires_history_context_pack(shared: &NodeShared, request: &NodeRequest) -> bool {
14596    if request.requires_history_context_pack_capability() {
14597        return true;
14598    }
14599    if let Some(spec) = match request {
14600        NodeRequest::SpawnSpec { spec }
14601        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14602        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14603        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14604        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14605        _ => None,
14606    } {
14607        return shared
14608            .resolve_spawn_spec(spec)
14609            .map_or(true, |resolved| resolved.context_id.is_some());
14610    }
14611    match request {
14612        NodeRequest::Resume { session, .. }
14613        | NodeRequest::Prompt { session, .. }
14614        | NodeRequest::Paste { session, .. }
14615        | NodeRequest::Input { session, .. }
14616        | NodeRequest::TerminalBytes { session, .. }
14617        | NodeRequest::TerminalControl { session, .. }
14618        | NodeRequest::Resize { session, .. }
14619        | NodeRequest::Interrupt { session }
14620        | NodeRequest::Stop { session, .. }
14621        | NodeRequest::Remove { session }
14622        | NodeRequest::ResolveInteraction { session, .. }
14623        | NodeRequest::SetSessionMode { session, .. }
14624        | NodeRequest::SetSessionConfigOption { session, .. }
14625        | NodeRequest::SetSessionModel { session, .. } => shared
14626            .session_bindings
14627            .lock()
14628            .unwrap_or_else(|poisoned| poisoned.into_inner())
14629            .get(&session.session.instance_id)
14630            .filter(|binding| {
14631                binding.workspace_id == session.workspace_id
14632                    && binding.generation == session.session.generation
14633            })
14634            .map_or(true, |binding| binding.context.is_some()),
14635        NodeRequest::RenameSessionRecord { record_id, .. }
14636        | NodeRequest::SetSessionTask { record_id, .. }
14637        | NodeRequest::ResumeSessionRecord { record_id, .. }
14638        | NodeRequest::ForgetSessionRecord { record_id } => shared
14639            .record(record_id)
14640            .map_or(true, |record| record.context.is_some()),
14641        NodeRequest::ArmHarnessMcpReservation { .. }
14642        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14643        | NodeRequest::ActivateHarnessMcpReservation { .. }
14644        | NodeRequest::AbortHarnessMcpReservation { .. }
14645        | NodeRequest::PutHarnessMcpReplyChunk { .. }
14646        | NodeRequest::RejectHarnessMcpCall { .. }
14647        | NodeRequest::Snapshot
14648        | NodeRequest::Resync { .. }
14649        | NodeRequest::BeginDeliveryStage { .. }
14650        | NodeRequest::PutDeliveryBlobChunk { .. }
14651        | NodeRequest::CommitDeliveryStage { .. }
14652        | NodeRequest::AbortDeliveryStage { .. }
14653        | NodeRequest::BrowseHostDirectories { .. }
14654        | NodeRequest::InspectWorkspace { .. }
14655        | NodeRequest::ReadWorkspaceFile { .. }
14656        | NodeRequest::WriteWorkspaceFile { .. }
14657        | NodeRequest::CreateWorkspaceFile { .. }
14658        | NodeRequest::CreateWorkspaceDirectory { .. }
14659        | NodeRequest::ReadGitHistory { .. }
14660        | NodeRequest::ReadGitDiff { .. }
14661        | NodeRequest::AcquireController { .. }
14662        | NodeRequest::ReleaseController
14663        | NodeRequest::Spawn { .. }
14664        | NodeRequest::SpawnWithOptions { .. }
14665        | NodeRequest::SpawnSpec { .. }
14666        | NodeRequest::RegisterWorkspace { .. }
14667        | NodeRequest::CreateStandaloneWorkspace { .. }
14668        | NodeRequest::UnregisterWorkspace { .. }
14669        | NodeRequest::CreateWorktree { .. }
14670        | NodeRequest::RemoveWorktree { .. }
14671        | NodeRequest::SpawnManagedWorktree { .. }
14672        | NodeRequest::SpawnManagedWorktreeV2 { .. }
14673        | NodeRequest::CleanupManagedWorktree { .. }
14674        | NodeRequest::IndexProviderSession { .. }
14675        | NodeRequest::IndexNativeSession { .. }
14676        | NodeRequest::CatalogNativeSessions { .. }
14677        | NodeRequest::PageNativeSessions { .. }
14678        | NodeRequest::PreviewNativeSession { .. }
14679        | NodeRequest::PreviewSessionRecord { .. }
14680        | NodeRequest::DiscoverHistory { .. }
14681        | NodeRequest::LoadHistory { .. }
14682        | NodeRequest::ExportContextPackForSessionRecord { .. }
14683        | NodeRequest::ExportContextPack { .. }
14684        | NodeRequest::ForgetContextPack { .. }
14685        | NodeRequest::ResolveDurableContextPack { .. }
14686        | NodeRequest::ReadContextPack { .. }
14687        | NodeRequest::Shutdown => false,
14688    }
14689}
14690
14691fn request_requires_open_provider_ids_with(
14692    request: &NodeRequest,
14693    provider_for_session: impl Fn(&SessionAddress) -> Option<AgentId>,
14694    provider_for_record: impl Fn(&SessionRecordId) -> Option<AgentId>,
14695) -> bool {
14696    match request {
14697        NodeRequest::Spawn { provider, .. } | NodeRequest::SpawnWithOptions { provider, .. } => !provider_id_is_legacy(provider),
14698        NodeRequest::IndexProviderSession { provider, .. } => !provider_id_is_legacy(provider),
14699        NodeRequest::CatalogNativeSessions { route, .. }
14700        | NodeRequest::PageNativeSessions { route, .. } => {
14701            !provider_id_is_legacy(&route.provider)
14702        }
14703        NodeRequest::PreviewNativeSession { selection, .. }
14704        | NodeRequest::IndexNativeSession { selection, .. } => {
14705            !provider_id_is_legacy(&selection.route.provider)
14706        }
14707        NodeRequest::SpawnSpec { .. }
14708        | NodeRequest::SpawnSpecWithHarnessMcp { .. } => false,
14709        NodeRequest::SpawnManagedWorktree { .. }
14710        | NodeRequest::SpawnManagedWorktreeV2 { .. } => false,
14711        NodeRequest::Resume { session, .. }
14712        | NodeRequest::Prompt { session, .. }
14713        | NodeRequest::Paste { session, .. }
14714        | NodeRequest::Input { session, .. }
14715        | NodeRequest::TerminalBytes { session, .. }
14716        | NodeRequest::TerminalControl { session, .. }
14717        | NodeRequest::Resize { session, .. }
14718        | NodeRequest::Interrupt { session }
14719        | NodeRequest::Stop { session, .. }
14720        | NodeRequest::Remove { session }
14721        | NodeRequest::ResolveInteraction { session, .. }
14722        | NodeRequest::SetSessionMode { session, .. }
14723        | NodeRequest::SetSessionConfigOption { session, .. }
14724        | NodeRequest::SetSessionModel { session, .. }
14725        | NodeRequest::DiscoverHistory { session, .. }
14726        | NodeRequest::LoadHistory { session, .. }
14727        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14728        | NodeRequest::ExportContextPack { session } => provider_for_session(session)
14729            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
14730        NodeRequest::RenameSessionRecord { record_id, .. }
14731        | NodeRequest::SetSessionTask { record_id, .. }
14732        | NodeRequest::ResumeSessionRecord { record_id, .. }
14733        | NodeRequest::ForgetSessionRecord { record_id } => provider_for_record(record_id)
14734            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
14735        NodeRequest::PreviewSessionRecord { record_id, .. } => provider_for_record(record_id)
14736            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
14737        NodeRequest::ArmHarnessMcpReservation { .. }
14738        | NodeRequest::ActivateHarnessMcpReservation { .. }
14739        | NodeRequest::AbortHarnessMcpReservation { .. }
14740        | NodeRequest::PutHarnessMcpReplyChunk { .. }
14741        | NodeRequest::RejectHarnessMcpCall { .. }
14742        | NodeRequest::Snapshot
14743        | NodeRequest::Resync { .. }
14744        | NodeRequest::BeginDeliveryStage { .. }
14745        | NodeRequest::PutDeliveryBlobChunk { .. }
14746        | NodeRequest::CommitDeliveryStage { .. }
14747        | NodeRequest::AbortDeliveryStage { .. }
14748        | NodeRequest::BrowseHostDirectories { .. }
14749        | NodeRequest::InspectWorkspace { .. }
14750        | NodeRequest::ReadWorkspaceFile { .. }
14751        | NodeRequest::WriteWorkspaceFile { .. }
14752        | NodeRequest::CreateWorkspaceFile { .. }
14753        | NodeRequest::CreateWorkspaceDirectory { .. }
14754        | NodeRequest::ReadGitHistory { .. }
14755        | NodeRequest::ReadGitDiff { .. }
14756        | NodeRequest::AcquireController { .. }
14757        | NodeRequest::ReleaseController
14758        | NodeRequest::RegisterWorkspace { .. }
14759        | NodeRequest::CreateStandaloneWorkspace { .. }
14760        | NodeRequest::UnregisterWorkspace { .. }
14761        | NodeRequest::CreateWorktree { .. }
14762        | NodeRequest::RemoveWorktree { .. }
14763        | NodeRequest::CleanupManagedWorktree { .. }
14764        | NodeRequest::Shutdown => false,
14765        NodeRequest::ForgetContextPack { .. } => true,
14766        // No session/record to cheaply resolve a provider from here either;
14767        // the resolved receipt can carry an open-id provider, so require the
14768        // same capability as ForgetContextPack unconditionally. `ReadContextPack`
14769        // is the same digest-keyed lookup one step further into the same
14770        // catalog, so it inherits the same conservative answer.
14771        NodeRequest::ResolveDurableContextPack { .. }
14772        | NodeRequest::ReadContextPack { .. } => true,
14773    }
14774}
14775
14776fn project_snapshot_legacy_provider_ids(snapshot: &mut NodeSnapshot) {
14777    snapshot.enabled_providers.retain(provider_id_is_legacy);
14778    snapshot.provider_runtime_statuses = ProviderRuntimeStatuses::new(
14779        snapshot
14780            .provider_runtime_statuses
14781            .as_slice()
14782            .iter()
14783            .filter(|status| provider_id_is_legacy(status.provider()))
14784            .cloned(),
14785    )
14786    .expect("a filtered provider runtime projection remains valid");
14787    snapshot
14788        .session_records
14789        .retain(|record| provider_id_is_legacy(&record.provider));
14790    for workspace in &mut snapshot.workspaces {
14791        workspace
14792            .sessions
14793            .retain(|session| provider_id_is_legacy(&session.agent_id));
14794    }
14795}
14796
14797fn clear_snapshot_child_environment_profiles(snapshot: &mut NodeSnapshot) {
14798    for record in &mut snapshot.session_records {
14799        record.environment_profile = None;
14800    }
14801    if let Some(profiles) = snapshot
14802        .launch_inventory
14803        .as_mut()
14804        .and_then(|inventory| inventory.spawn_profiles.as_mut())
14805    {
14806        for profile in profiles {
14807            profile.environment_profile = None;
14808        }
14809    }
14810}
14811
14812fn project_event_without_child_environment_profile(
14813    mut envelope: NodeEventEnvelope,
14814) -> NodeEventEnvelope {
14815    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14816        record.environment_profile = None;
14817    }
14818    envelope
14819}
14820
14821fn project_response_without_child_environment_profile(reply: &mut ResponseEnvelope) {
14822    let contains_environment_profile = match reply.result.as_ref() {
14823        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
14824            receipt.environment_profile.is_some()
14825        }
14826        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14827            receipt.spawn.environment_profile.is_some()
14828        }
14829        _ => false,
14830    };
14831    if contains_environment_profile {
14832        reply.result = Err(failure(
14833            NodeFailureCode::UnsupportedCapability,
14834            "child environment profiles were not negotiated",
14835        ));
14836        return;
14837    }
14838    let Ok(response) = reply.result.as_mut() else {
14839        return;
14840    };
14841    match response {
14842        NodeResponse::Snapshot { snapshot, .. } => {
14843            clear_snapshot_child_environment_profiles(snapshot);
14844        }
14845        NodeResponse::Resync { snapshot, events, .. } => {
14846            clear_snapshot_child_environment_profiles(snapshot);
14847            for event in events {
14848                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14849                    record.environment_profile = None;
14850                }
14851            }
14852        }
14853        NodeResponse::SessionRecordUpdated { record }
14854        | NodeResponse::ProviderSessionIndexed { record }
14855        | NodeResponse::NativeSessionIndexed { record, .. }
14856        | NodeResponse::SessionRecordResumed { record, .. } => {
14857            record.environment_profile = None;
14858        }
14859        NodeResponse::Armed { .. }
14860        | NodeResponse::Spawned { .. }
14861        | NodeResponse::Activated { .. }
14862        | NodeResponse::Aborted { .. }
14863        | NodeResponse::ReplyChunkAccepted { .. }
14864        | NodeResponse::CallRejected { .. }
14865        | NodeResponse::DeliveryStageBegun { .. }
14866        | NodeResponse::DeliveryBlobChunkAccepted { .. }
14867        | NodeResponse::DeliveryCommitted { .. }
14868        | NodeResponse::DeliveryStageAborted { .. }
14869        | NodeResponse::WorkspaceInspected { .. }
14870        | NodeResponse::HostDirectoriesBrowsed { .. }
14871        | NodeResponse::WorkspaceFileRead { .. }
14872        | NodeResponse::WorkspaceFileWritten { .. }
14873        | NodeResponse::WorkspaceFileCreated { .. }
14874        | NodeResponse::WorkspaceDirectoryCreated { .. }
14875        | NodeResponse::GitHistoryRead { .. }
14876        | NodeResponse::GitDiffRead { .. }
14877        | NodeResponse::Controller { .. }
14878        | NodeResponse::SpawnAccepted { .. }
14879        | NodeResponse::SpawnSpecAccepted { .. }
14880        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14881        | NodeResponse::ManagedWorktreeCleanup { .. }
14882        | NodeResponse::NativeSessionsCataloged { .. }
14883        | NodeResponse::NativeSessionsPaged { .. }
14884        | NodeResponse::NativeSessionPreviewed { .. }
14885        | NodeResponse::SessionRecordPreviewed { .. }
14886        | NodeResponse::HistoryDiscovered { .. }
14887        | NodeResponse::HistoryLoaded { .. }
14888        | NodeResponse::ContextPackForSessionRecordExported { .. }
14889        | NodeResponse::ContextPackExported { .. }
14890        | NodeResponse::ContextPackForgotten { .. }
14891        | NodeResponse::DurableContextPackResolved { .. }
14892        | NodeResponse::ContextPackBytesRead { .. }
14893        | NodeResponse::SessionRecordForgotten { .. }
14894        | NodeResponse::WorkspaceRegistered { .. }
14895        | NodeResponse::StandaloneWorkspaceCreated { .. }
14896        | NodeResponse::WorkspaceUnregistered { .. }
14897        | NodeResponse::WorktreeCreated { .. }
14898        | NodeResponse::WorktreeRemoved { .. }
14899        | NodeResponse::Accepted
14900        | NodeResponse::ShuttingDown => {}
14901    }
14902}
14903
14904fn clear_snapshot_session_bundles(snapshot: &mut NodeSnapshot) {
14905    for record in &mut snapshot.session_records {
14906        record.bundle = None;
14907    }
14908}
14909
14910fn project_event_without_session_bundle(
14911    mut envelope: NodeEventEnvelope,
14912) -> NodeEventEnvelope {
14913    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14914        record.bundle = None;
14915    }
14916    envelope
14917}
14918
14919fn project_response_without_session_bundle(reply: &mut ResponseEnvelope) {
14920    let contains_bundle = match reply.result.as_ref() {
14921        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.bundle.is_some(),
14922        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14923            receipt.spawn.bundle.is_some()
14924        }
14925        _ => false,
14926    };
14927    if contains_bundle {
14928        reply.result = Err(failure(
14929            NodeFailureCode::UnsupportedCapability,
14930            "session bundles were not negotiated",
14931        ));
14932        return;
14933    }
14934    let Ok(response) = reply.result.as_mut() else {
14935        return;
14936    };
14937    match response {
14938        NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_session_bundles(snapshot),
14939        NodeResponse::Resync { snapshot, events, .. } => {
14940            clear_snapshot_session_bundles(snapshot);
14941            for event in events {
14942                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14943                    record.bundle = None;
14944                }
14945            }
14946        }
14947        NodeResponse::SessionRecordUpdated { record }
14948        | NodeResponse::ProviderSessionIndexed { record }
14949        | NodeResponse::NativeSessionIndexed { record, .. }
14950        | NodeResponse::SessionRecordResumed { record, .. } => record.bundle = None,
14951        NodeResponse::Armed { .. }
14952        | NodeResponse::Spawned { .. }
14953        | NodeResponse::Activated { .. }
14954        | NodeResponse::Aborted { .. }
14955        | NodeResponse::ReplyChunkAccepted { .. }
14956        | NodeResponse::CallRejected { .. }
14957        | NodeResponse::DeliveryStageBegun { .. }
14958        | NodeResponse::DeliveryBlobChunkAccepted { .. }
14959        | NodeResponse::DeliveryCommitted { .. }
14960        | NodeResponse::DeliveryStageAborted { .. }
14961        | NodeResponse::WorkspaceInspected { .. }
14962        | NodeResponse::HostDirectoriesBrowsed { .. }
14963        | NodeResponse::WorkspaceFileRead { .. }
14964        | NodeResponse::WorkspaceFileWritten { .. }
14965        | NodeResponse::WorkspaceFileCreated { .. }
14966        | NodeResponse::WorkspaceDirectoryCreated { .. }
14967        | NodeResponse::GitHistoryRead { .. }
14968        | NodeResponse::GitDiffRead { .. }
14969        | NodeResponse::Controller { .. }
14970        | NodeResponse::SpawnAccepted { .. }
14971        | NodeResponse::SpawnSpecAccepted { .. }
14972        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14973        | NodeResponse::ManagedWorktreeCleanup { .. }
14974        | NodeResponse::NativeSessionsCataloged { .. }
14975        | NodeResponse::NativeSessionsPaged { .. }
14976        | NodeResponse::NativeSessionPreviewed { .. }
14977        | NodeResponse::SessionRecordPreviewed { .. }
14978        | NodeResponse::HistoryDiscovered { .. }
14979        | NodeResponse::HistoryLoaded { .. }
14980        | NodeResponse::ContextPackForSessionRecordExported { .. }
14981        | NodeResponse::ContextPackExported { .. }
14982        | NodeResponse::ContextPackForgotten { .. }
14983        | NodeResponse::DurableContextPackResolved { .. }
14984        | NodeResponse::ContextPackBytesRead { .. }
14985        | NodeResponse::SessionRecordForgotten { .. }
14986        | NodeResponse::WorkspaceRegistered { .. }
14987        | NodeResponse::StandaloneWorkspaceCreated { .. }
14988        | NodeResponse::WorkspaceUnregistered { .. }
14989        | NodeResponse::WorktreeCreated { .. }
14990        | NodeResponse::WorktreeRemoved { .. }
14991        | NodeResponse::Accepted
14992        | NodeResponse::ShuttingDown => {}
14993    }
14994}
14995
14996fn project_snapshot_history_for_wire(
14997    snapshot: &mut NodeSnapshot,
14998    include_history_metadata: bool,
14999) {
15000    for workspace in &mut snapshot.workspaces {
15001        for session in &mut workspace.sessions {
15002            if include_history_metadata {
15003                session.history.loaded = None;
15004            } else {
15005                session.history = Default::default();
15006            }
15007        }
15008    }
15009}
15010
15011fn project_event_history_for_wire(
15012    mut envelope: NodeEventEnvelope,
15013    include_history_metadata: bool,
15014) -> NodeEventEnvelope {
15015    if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
15016        for session in &mut workspace.sessions {
15017            if include_history_metadata {
15018                session.history.loaded = None;
15019            } else {
15020                session.history = Default::default();
15021            }
15022        }
15023    }
15024    envelope
15025}
15026
15027fn project_response_history_for_wire(
15028    reply: &mut ResponseEnvelope,
15029    include_history_metadata: bool,
15030) {
15031    let Ok(response) = reply.result.as_mut() else {
15032        return;
15033    };
15034    match response {
15035        NodeResponse::Snapshot { snapshot, .. } => {
15036            project_snapshot_history_for_wire(snapshot, include_history_metadata);
15037        }
15038        NodeResponse::Resync {
15039            snapshot, events, ..
15040        } => {
15041            project_snapshot_history_for_wire(snapshot, include_history_metadata);
15042            for event in events {
15043                *event = project_event_history_for_wire(
15044                    event.clone(),
15045                    include_history_metadata,
15046                );
15047            }
15048        }
15049        NodeResponse::WorkspaceRegistered { workspace }
15050        | NodeResponse::StandaloneWorkspaceCreated { workspace }
15051        | NodeResponse::WorktreeCreated { workspace, .. } => {
15052            for session in &mut workspace.sessions {
15053                if include_history_metadata {
15054                    session.history.loaded = None;
15055                } else {
15056                    session.history = Default::default();
15057                }
15058            }
15059        }
15060        _ => {}
15061    }
15062}
15063
15064fn clear_snapshot_context_packs(snapshot: &mut NodeSnapshot) {
15065    for record in &mut snapshot.session_records {
15066        record.context_id = None;
15067        record.context = None;
15068        record.exported_context = None;
15069    }
15070}
15071
15072fn project_event_without_context_pack(
15073    mut envelope: NodeEventEnvelope,
15074) -> NodeEventEnvelope {
15075    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15076        record.context_id = None;
15077        record.context = None;
15078        record.exported_context = None;
15079    }
15080    envelope
15081}
15082
15083fn project_response_without_context_pack(reply: &mut ResponseEnvelope) {
15084    let contains_context = match reply.result.as_ref() {
15085        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.context.is_some(),
15086        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15087            receipt.spawn.context.is_some()
15088        }
15089        Ok(NodeResponse::HistoryDiscovered { .. })
15090        | Ok(NodeResponse::HistoryLoaded { .. })
15091        | Ok(NodeResponse::ContextPackForSessionRecordExported { .. })
15092        | Ok(NodeResponse::ContextPackExported { .. })
15093        | Ok(NodeResponse::ContextPackForgotten { .. })
15094        | Ok(NodeResponse::DurableContextPackResolved { .. }) => true,
15095        _ => false,
15096    };
15097    if contains_context {
15098        reply.result = Err(failure(
15099            NodeFailureCode::UnsupportedCapability,
15100            "history context packs were not negotiated",
15101        ));
15102        return;
15103    }
15104    let Ok(response) = reply.result.as_mut() else {
15105        return;
15106    };
15107    match response {
15108        NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_context_packs(snapshot),
15109        NodeResponse::Resync {
15110            snapshot, events, ..
15111        } => {
15112            clear_snapshot_context_packs(snapshot);
15113            for event in events {
15114                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15115                    record.context_id = None;
15116                    record.context = None;
15117                    record.exported_context = None;
15118                }
15119            }
15120        }
15121        NodeResponse::SessionRecordUpdated { record }
15122        | NodeResponse::ProviderSessionIndexed { record }
15123        | NodeResponse::NativeSessionIndexed { record, .. }
15124        | NodeResponse::SessionRecordResumed { record, .. } => {
15125            record.context_id = None;
15126            record.context = None;
15127            record.exported_context = None;
15128        }
15129        _ => {}
15130    }
15131}
15132
15133fn project_response_legacy_provider_ids(shared: &NodeShared, reply: &mut ResponseEnvelope) {
15134    let contains_open_record = match reply.result.as_ref() {
15135        Ok(NodeResponse::SessionRecordUpdated { record })
15136        | Ok(NodeResponse::ProviderSessionIndexed { record })
15137        | Ok(NodeResponse::SessionRecordResumed { record, .. }) => {
15138            !provider_id_is_legacy(&record.provider)
15139        }
15140        Ok(NodeResponse::NativeSessionIndexed { selection, record }) => {
15141            !provider_id_is_legacy(&selection.route.provider)
15142                || !provider_id_is_legacy(&record.provider)
15143        }
15144        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
15145            !provider_id_is_legacy(&receipt.provider)
15146        }
15147        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15148            !provider_id_is_legacy(&receipt.spawn.provider)
15149        }
15150        Ok(NodeResponse::NativeSessionsCataloged { route, .. })
15151        | Ok(NodeResponse::NativeSessionsPaged { route, .. }) => {
15152            !provider_id_is_legacy(&route.provider)
15153        }
15154        Ok(NodeResponse::NativeSessionPreviewed { selection, .. }) => {
15155            !provider_id_is_legacy(&selection.route.provider)
15156        }
15157        _ => false,
15158    };
15159    if contains_open_record {
15160        reply.result = Err(failure(
15161            NodeFailureCode::UnsupportedCapability,
15162            "open provider IDs were not negotiated",
15163        ));
15164        return;
15165    }
15166    let Ok(response) = reply.result.as_mut() else {
15167        return;
15168    };
15169    match response {
15170        NodeResponse::Snapshot { snapshot, .. } => {
15171            project_snapshot_legacy_provider_ids(snapshot);
15172        }
15173        NodeResponse::Resync { snapshot, events, .. } => {
15174            project_snapshot_legacy_provider_ids(snapshot);
15175            *events = std::mem::take(events)
15176                .into_iter()
15177                .filter_map(|event| project_event_legacy_provider_ids(shared, event))
15178                .collect();
15179        }
15180        NodeResponse::WorkspaceRegistered { workspace }
15181        | NodeResponse::StandaloneWorkspaceCreated { workspace }
15182        | NodeResponse::WorktreeCreated { workspace, .. } => {
15183            workspace
15184                .sessions
15185                .retain(|session| provider_id_is_legacy(&session.agent_id));
15186        }
15187        NodeResponse::Armed { .. }
15188        | NodeResponse::Spawned { .. }
15189        | NodeResponse::Activated { .. }
15190        | NodeResponse::Aborted { .. }
15191        | NodeResponse::ReplyChunkAccepted { .. }
15192        | NodeResponse::CallRejected { .. }
15193        | NodeResponse::DeliveryStageBegun { .. }
15194        | NodeResponse::DeliveryBlobChunkAccepted { .. }
15195        | NodeResponse::DeliveryCommitted { .. }
15196        | NodeResponse::DeliveryStageAborted { .. }
15197        | NodeResponse::WorkspaceInspected { .. }
15198        | NodeResponse::HostDirectoriesBrowsed { .. }
15199        | NodeResponse::WorkspaceFileRead { .. }
15200        | NodeResponse::WorkspaceFileWritten { .. }
15201        | NodeResponse::WorkspaceFileCreated { .. }
15202        | NodeResponse::WorkspaceDirectoryCreated { .. }
15203        | NodeResponse::GitHistoryRead { .. }
15204        | NodeResponse::GitDiffRead { .. }
15205        | NodeResponse::Controller { .. }
15206        | NodeResponse::SpawnAccepted { .. }
15207        | NodeResponse::SpawnSpecAccepted { .. }
15208        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15209        | NodeResponse::ManagedWorktreeCleanup { .. }
15210        | NodeResponse::NativeSessionsCataloged { .. }
15211        | NodeResponse::NativeSessionsPaged { .. }
15212        | NodeResponse::NativeSessionPreviewed { .. }
15213        | NodeResponse::SessionRecordPreviewed { .. }
15214        | NodeResponse::HistoryDiscovered { .. }
15215        | NodeResponse::HistoryLoaded { .. }
15216        | NodeResponse::ContextPackForSessionRecordExported { .. }
15217        | NodeResponse::ContextPackExported { .. }
15218        | NodeResponse::ContextPackForgotten { .. }
15219        | NodeResponse::DurableContextPackResolved { .. }
15220        | NodeResponse::ContextPackBytesRead { .. }
15221        | NodeResponse::SessionRecordUpdated { .. }
15222        | NodeResponse::ProviderSessionIndexed { .. }
15223        | NodeResponse::NativeSessionIndexed { .. }
15224        | NodeResponse::SessionRecordResumed { .. }
15225        | NodeResponse::SessionRecordForgotten { .. }
15226        | NodeResponse::WorkspaceUnregistered { .. }
15227        | NodeResponse::WorktreeRemoved { .. }
15228        | NodeResponse::Accepted
15229        | NodeResponse::ShuttingDown => {}
15230    }
15231}
15232
15233fn project_event_legacy_provider_ids(
15234    shared: &NodeShared,
15235    mut envelope: NodeEventEnvelope,
15236) -> Option<NodeEventEnvelope> {
15237    let include = match &mut envelope.event {
15238        NodeEvent::HarnessMcpReadCall { .. } => true,
15239        NodeEvent::Control { address, .. }
15240        | NodeEvent::AgentStream { address, .. }
15241        | NodeEvent::TerminalFrame { address, .. } => shared
15242            .validate_address(address)
15243            .is_ok_and(|provider| provider_id_is_legacy(&provider)),
15244        NodeEvent::WorkspaceAdded { workspace } => {
15245            workspace
15246                .sessions
15247                .retain(|session| provider_id_is_legacy(&session.agent_id));
15248            true
15249        }
15250        NodeEvent::SessionRecordUpserted { record } => {
15251            provider_id_is_legacy(&record.provider)
15252        }
15253        NodeEvent::SessionRecordHistorySummarized { .. } => false,
15254        NodeEvent::SessionRecordRemoved { .. } => shared
15255            .history
15256            .lock()
15257            .unwrap_or_else(|poisoned| poisoned.into_inner())
15258            .removed_record_providers
15259            .get(&envelope.sequence)
15260            .is_some_and(provider_id_is_legacy),
15261        NodeEvent::ControllerChanged { .. }
15262        | NodeEvent::ManagedWorktreeUpserted { .. }
15263        | NodeEvent::ManagedWorktreeRemoved { .. }
15264        | NodeEvent::WorkspaceRemoved { .. }
15265        | NodeEvent::ResyncRequired { .. } => true,
15266    };
15267    include.then_some(envelope)
15268}
15269
15270fn clear_response_provider_runtime_status(reply: &mut ResponseEnvelope) {
15271    let Ok(response) = reply.result.as_mut() else {
15272        return;
15273    };
15274    match response {
15275        NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
15276            snapshot.provider_runtime_statuses.clear();
15277        }
15278        NodeResponse::Armed { .. }
15279        | NodeResponse::Spawned { .. }
15280        | NodeResponse::Activated { .. }
15281        | NodeResponse::Aborted { .. }
15282        | NodeResponse::ReplyChunkAccepted { .. }
15283        | NodeResponse::CallRejected { .. }
15284        | NodeResponse::DeliveryStageBegun { .. }
15285        | NodeResponse::DeliveryBlobChunkAccepted { .. }
15286        | NodeResponse::DeliveryCommitted { .. }
15287        | NodeResponse::DeliveryStageAborted { .. }
15288        | NodeResponse::WorkspaceInspected { .. }
15289        | NodeResponse::HostDirectoriesBrowsed { .. }
15290        | NodeResponse::WorkspaceFileRead { .. }
15291        | NodeResponse::WorkspaceFileWritten { .. }
15292        | NodeResponse::WorkspaceFileCreated { .. }
15293        | NodeResponse::WorkspaceDirectoryCreated { .. }
15294        | NodeResponse::GitHistoryRead { .. }
15295        | NodeResponse::GitDiffRead { .. }
15296        | NodeResponse::Controller { .. }
15297        | NodeResponse::SpawnAccepted { .. }
15298        | NodeResponse::SpawnSpecAccepted { .. }
15299        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15300        | NodeResponse::ManagedWorktreeCleanup { .. }
15301        | NodeResponse::NativeSessionsCataloged { .. }
15302        | NodeResponse::NativeSessionsPaged { .. }
15303        | NodeResponse::NativeSessionPreviewed { .. }
15304        | NodeResponse::SessionRecordPreviewed { .. }
15305        | NodeResponse::HistoryDiscovered { .. }
15306        | NodeResponse::HistoryLoaded { .. }
15307        | NodeResponse::ContextPackForSessionRecordExported { .. }
15308        | NodeResponse::ContextPackExported { .. }
15309        | NodeResponse::ContextPackForgotten { .. }
15310        | NodeResponse::DurableContextPackResolved { .. }
15311        | NodeResponse::ContextPackBytesRead { .. }
15312        | NodeResponse::SessionRecordUpdated { .. }
15313        | NodeResponse::ProviderSessionIndexed { .. }
15314        | NodeResponse::NativeSessionIndexed { .. }
15315        | NodeResponse::SessionRecordResumed { .. }
15316        | NodeResponse::SessionRecordForgotten { .. }
15317        | NodeResponse::WorkspaceRegistered { .. }
15318        | NodeResponse::StandaloneWorkspaceCreated { .. }
15319        | NodeResponse::WorkspaceUnregistered { .. }
15320        | NodeResponse::WorktreeCreated { .. }
15321        | NodeResponse::WorktreeRemoved { .. }
15322        | NodeResponse::Accepted
15323        | NodeResponse::ShuttingDown => {}
15324    }
15325}
15326
15327struct ControllerReleaseGuard {
15328    shared: Arc<NodeShared>,
15329    connection_id: u64,
15330}
15331
15332impl Drop for ControllerReleaseGuard {
15333    fn drop(&mut self) {
15334        self.shared.release_controller(self.connection_id);
15335    }
15336}
15337
15338struct AbortTaskOnDrop(AbortHandle);
15339
15340impl Drop for AbortTaskOnDrop {
15341    fn drop(&mut self) {
15342        self.0.abort();
15343    }
15344}
15345
15346/// Reads an unsigned env-config override, following the pattern of every
15347/// other bounded knob in this module: missing/unparsable/zero falls back to
15348/// `default`, and the resolved value is always clamped to `max` so a bad
15349/// override cannot reopen a bound this budget exists to enforce.
15350fn env_config_bounded(name: &str, default: u64, max: u64) -> u64 {
15351    std::env::var(name)
15352        .ok()
15353        .and_then(|value| value.parse::<u64>().ok())
15354        .filter(|value| *value > 0)
15355        .unwrap_or(default)
15356        .min(max)
15357}
15358
15359fn workspace_inspection_time_budget_ms() -> u64 {
15360    env_config_bounded(
15361        "GATE4AGENT_NODE_WORKSPACE_INSPECTION_BUDGET_MS",
15362        WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT,
15363        WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX,
15364    )
15365}
15366
15367fn workspace_inspection_entry_cap() -> usize {
15368    env_config_bounded(
15369        "GATE4AGENT_NODE_WORKSPACE_INSPECTION_ENTRY_CAP",
15370        WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT as u64,
15371        WORKSPACE_INSPECTION_ENTRY_CAP_MAX as u64,
15372    ) as usize
15373}
15374
15375/// Tracks the shared walk-side budget across the whole recursive walk: a
15376/// wall-clock deadline (shared with the git phase that runs after the walk)
15377/// and a cap on directory entries visited, whether or not they were pushed
15378/// to the response. Cheap to check per entry — one `Instant::now()` and one
15379/// integer compare.
15380struct WorkspaceWalkBudget {
15381    deadline: Instant,
15382    entry_cap: usize,
15383    entries_visited: u64,
15384    time_budget_exceeded: bool,
15385    entry_cap_exceeded: bool,
15386}
15387
15388impl WorkspaceWalkBudget {
15389    fn new(deadline: Instant, entry_cap: usize) -> Self {
15390        Self {
15391            deadline,
15392            entry_cap,
15393            entries_visited: 0,
15394            time_budget_exceeded: false,
15395            entry_cap_exceeded: false,
15396        }
15397    }
15398
15399    fn is_exhausted(&self) -> bool {
15400        self.time_budget_exceeded || self.entry_cap_exceeded
15401    }
15402
15403    /// Records one visited directory entry (pushed to the response or not)
15404    /// and reports whether the walk must stop now.
15405    fn record_visit(&mut self) -> bool {
15406        self.entries_visited += 1;
15407        if self.entries_visited as usize >= self.entry_cap {
15408            self.entry_cap_exceeded = true;
15409        }
15410        if Instant::now() >= self.deadline {
15411            self.time_budget_exceeded = true;
15412        }
15413        self.is_exhausted()
15414    }
15415}
15416
15417fn collect_workspace_entries(
15418    root: &Path,
15419    deadline: Instant,
15420    entry_cap: usize,
15421) -> (Vec<WorkspaceEntry>, bool, WorkspaceWalkBudget) {
15422    let mut entries = Vec::new();
15423    let mut truncated = false;
15424    let mut budget = WorkspaceWalkBudget::new(deadline, entry_cap);
15425    walk_workspace_directory(root, None, 0, &mut entries, &mut truncated, &mut budget);
15426    // DFS pre-order emits `x/child` between prefix-named siblings `x` and
15427    // `x-y` ('-' sorts below '/'), while the bounded operator projections
15428    // require strict global byte order over relative paths.
15429    entries.sort_by(|left, right| left.relative_path.as_utf8().cmp(&right.relative_path.as_utf8()));
15430    (entries, truncated, budget)
15431}
15432
15433/// Builds the additive `WorkspaceInspectionTruncationV1` for an
15434/// `inspect_workspace` response — `None` once the walk and git phase both
15435/// completed inside their shared budget.
15436fn workspace_inspection_truncation(
15437    walk_budget: &WorkspaceWalkBudget,
15438    git_time_budget_exceeded: bool,
15439    elapsed: Duration,
15440) -> Option<WorkspaceInspectionTruncationV1> {
15441    if !walk_budget.time_budget_exceeded
15442        && !walk_budget.entry_cap_exceeded
15443        && !git_time_budget_exceeded
15444    {
15445        return None;
15446    }
15447    Some(WorkspaceInspectionTruncationV1 {
15448        walk_time_budget_exceeded: walk_budget.time_budget_exceeded,
15449        walk_entry_cap_exceeded: walk_budget.entry_cap_exceeded,
15450        git_time_budget_exceeded,
15451        entries_visited: walk_budget.entries_visited,
15452        elapsed_ms: u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX),
15453    })
15454}
15455
15456fn walk_workspace_directory(
15457    directory: &Path,
15458    relative_directory: Option<&RepositoryPath>,
15459    depth: usize,
15460    entries: &mut Vec<WorkspaceEntry>,
15461    truncated: &mut bool,
15462    budget: &mut WorkspaceWalkBudget,
15463) {
15464    if budget.is_exhausted() {
15465        *truncated = true;
15466        return;
15467    }
15468    let read_dir = match std::fs::read_dir(directory) {
15469        Ok(read_dir) => read_dir,
15470        Err(_) => {
15471            *truncated = true;
15472            return;
15473        }
15474    };
15475    let mut children = Vec::new();
15476    for child in read_dir {
15477        match child {
15478            Ok(child) => children.push(child),
15479            Err(_) => *truncated = true,
15480        }
15481    }
15482    children.sort_by_key(|child| child.file_name());
15483    for child in children {
15484        if budget.record_visit() {
15485            *truncated = true;
15486            return;
15487        }
15488        if entries.len() >= WORKSPACE_TREE_MAX_ENTRIES {
15489            *truncated = true;
15490            return;
15491        }
15492        let file_type = match child.file_type() {
15493            Ok(file_type) => file_type,
15494            Err(_) => {
15495                *truncated = true;
15496                continue;
15497            }
15498        };
15499        if file_type.is_symlink() {
15500            continue;
15501        }
15502        let name = match child.file_name().into_string() {
15503            Ok(name) => name,
15504            Err(_) => {
15505                *truncated = true;
15506                continue;
15507            }
15508        };
15509        if file_type.is_dir() && is_skipped_workspace_directory(&name, &child.path()) {
15510            continue;
15511        }
15512        let relative_path = match relative_directory {
15513            None => windows_repository_path(name),
15514            Some(parent) => parent
15515                .as_utf8()
15516                .and_then(|parent| windows_repository_path(format!("{parent}/{name}"))),
15517        };
15518        let Some(relative_path) = relative_path else {
15519            *truncated = true;
15520            continue;
15521        };
15522        let kind = if file_type.is_dir() {
15523            WorkspaceEntryKind::Directory
15524        } else if file_type.is_file() {
15525            WorkspaceEntryKind::File
15526        } else {
15527            continue;
15528        };
15529        entries.push(WorkspaceEntry {
15530            relative_path: relative_path.clone(),
15531            kind,
15532        });
15533        if file_type.is_dir() {
15534            if depth + 1 < WORKSPACE_TREE_MAX_DEPTH {
15535                walk_workspace_directory(
15536                    &child.path(),
15537                    Some(&relative_path),
15538                    depth + 1,
15539                    entries,
15540                    truncated,
15541                    budget,
15542                );
15543            } else {
15544                *truncated = true;
15545            }
15546        }
15547    }
15548}
15549
15550fn windows_repository_path(value: String) -> Option<RepositoryPath> {
15551    if value.contains('\\') {
15552        return None;
15553    }
15554    RepositoryPath::utf8(value).ok()
15555}
15556
15557fn windows_repository_path_from_bytes(value: &[u8]) -> Option<RepositoryPath> {
15558    windows_repository_path(std::str::from_utf8(value).ok()?.to_owned())
15559}
15560
15561/// Excludes a directory from the workspace tree walk. The three literal
15562/// names are the common convention-based cases; the `CACHEDIR.TAG` check
15563/// beneath them catches every build-output directory regardless of name
15564/// (cargo writes `CACHEDIR.TAG` into every `--target-dir` it creates, so
15565/// this covers `target-a2`, `target-a2-tui`, and any future renamed build
15566/// directory without maintaining a name heuristic list). The name check
15567/// runs first and returns early so a directory rejected by name never pays
15568/// for the extra `CACHEDIR.TAG` stat.
15569fn is_skipped_workspace_directory(name: &str, path: &Path) -> bool {
15570    if name.eq_ignore_ascii_case(".git")
15571        || name.eq_ignore_ascii_case("target")
15572        || name.eq_ignore_ascii_case("node_modules")
15573    {
15574        return true;
15575    }
15576    path.join("CACHEDIR.TAG").is_file()
15577}
15578
15579fn context_pack_source_status_is_usable(status: &SessionStatus) -> bool {
15580    matches!(
15581        status,
15582        SessionStatus::Running | SessionStatus::Exited { exit_code: Some(0) }
15583    )
15584}
15585
15586#[derive(Clone, Debug, Eq, PartialEq)]
15587enum ContextPackRepositoryHead {
15588    NotRepository,
15589    Unborn,
15590    Commit(String),
15591}
15592
15593#[derive(Clone, Debug, Eq, PartialEq)]
15594struct ContextPackRepositoryObservation {
15595    head: ContextPackRepositoryHead,
15596    git: GitSnapshot,
15597    files: Vec<ContextPackRepositoryFileSource>,
15598}
15599
15600fn stable_context_pack_repository(
15601    first: ContextPackRepositoryObservation,
15602    second: ContextPackRepositoryObservation,
15603) -> Result<ContextPackRepository, NodeFailure> {
15604    if first != second {
15605        return Err(failure(
15606            NodeFailureCode::ContextPackMaterializationFailed,
15607            "context repository changed during bounded capture",
15608        ));
15609    }
15610    Ok(ContextPackRepository::from_sources(
15611        &second.git,
15612        second.files,
15613    ))
15614}
15615
15616/// Appends a "skipped: budget exhausted" diagnostic and returns `true` once
15617/// (and only once) the shared walk+git `deadline` has already passed. Every
15618/// remaining phase in `inspect_git_workspace` checks this immediately
15619/// before issuing its own git call, so a budget spent by the walk (or by an
15620/// earlier git call) skips the rest of the chain instead of paying for it
15621/// anyway.
15622fn git_budget_exhausted(snapshot: &mut GitSnapshot, deadline: Instant, stage: &str) -> bool {
15623    if Instant::now() < deadline {
15624        return false;
15625    }
15626    append_git_diagnostic(
15627        snapshot,
15628        &format!("{stage} skipped: workspace inspection time budget exhausted"),
15629    );
15630    true
15631}
15632
15633/// Deadline-aware sibling of `run_git_bounded`, used only by
15634/// `inspect_git_workspace`'s six-call chain: caps the per-call timeout to
15635/// whatever remains of the shared `deadline` (never more than
15636/// `GIT_COMMAND_TIMEOUT_MS`), so a slow walk phase that already spent most
15637/// of the budget cannot still hand a full per-call timeout to every git
15638/// probe behind it. `inspect_git_workspace` already guards each call site
15639/// with `git_budget_exhausted`, so `deadline` is guaranteed not to have
15640/// passed yet here.
15641async fn run_git_bounded_with_deadline(
15642    root: &str,
15643    arguments: &[&str],
15644    output_limit: usize,
15645    deadline: Instant,
15646) -> io::Result<GitCommandOutput> {
15647    let remaining = deadline.checked_duration_since(Instant::now());
15648    let timeout_ms = remaining
15649        .and_then(|remaining| u64::try_from(remaining.as_millis()).ok())
15650        .unwrap_or(0)
15651        .clamp(1, GIT_COMMAND_TIMEOUT_MS);
15652    run_git_read_bounded(root, arguments, output_limit, timeout_ms).await
15653}
15654
15655/// Runs the bounded six-call git probe chain used by `inspect_workspace`.
15656/// `deadline` is the same absolute instant the preceding directory walk was
15657/// bounded by, so time already spent walking counts against what remains
15658/// for git enrichment. Returns the resulting `GitSnapshot` plus whether the
15659/// shared budget (not an individual per-call timeout) is what cut the git
15660/// phase short.
15661async fn inspect_git_workspace(root: &str, deadline: Instant) -> (GitSnapshot, bool) {
15662    let mut snapshot = GitSnapshot {
15663        is_repository: false,
15664        branch: None,
15665        status: Vec::new(),
15666        recent_commits: Vec::new(),
15667        worktrees: Vec::new(),
15668        managed_worktree: None,
15669        truncated: false,
15670        diagnostic: None,
15671    };
15672    if git_budget_exhausted(&mut snapshot, deadline, "git repository probe") {
15673        return (snapshot, true);
15674    }
15675    let repository = match run_git_bounded_with_deadline(
15676        root,
15677        &["rev-parse", "--is-inside-work-tree"],
15678        4 * 1_024,
15679        deadline,
15680    )
15681    .await
15682    {
15683        Ok(output) => output,
15684        Err(error) => {
15685            append_git_diagnostic(
15686                &mut snapshot,
15687                &format!("git inspection unavailable: {error}"),
15688            );
15689            return (snapshot, false);
15690        }
15691    };
15692    snapshot.truncated |= repository.truncated;
15693    if repository.timed_out {
15694        append_git_diagnostic(&mut snapshot, "git repository probe timed out");
15695        return (snapshot, false);
15696    }
15697    if !repository.success
15698        || String::from_utf8_lossy(&repository.stdout).trim() != "true"
15699    {
15700        let stderr = String::from_utf8_lossy(&repository.stderr);
15701        if !stderr.to_ascii_lowercase().contains("not a git repository") {
15702            append_git_diagnostic(&mut snapshot, stderr.trim());
15703        }
15704        return (snapshot, false);
15705    }
15706    snapshot.is_repository = true;
15707
15708    if git_budget_exhausted(&mut snapshot, deadline, "git branch query") {
15709        return (snapshot, true);
15710    }
15711    match run_git_bounded_with_deadline(root, &["branch", "--show-current"], 4 * 1_024, deadline).await {
15712        Ok(output) => {
15713            snapshot.truncated |= output.truncated;
15714            if output.timed_out {
15715                append_git_diagnostic(&mut snapshot, "git branch query timed out");
15716            } else if output.success {
15717                let branch = String::from_utf8_lossy(&output.stdout).trim().to_owned();
15718                if !branch.is_empty() {
15719                    snapshot.branch = Some(truncate_git_field(
15720                        &branch,
15721                        MAX_REPOSITORY_PATH_BYTES,
15722                        &mut snapshot.truncated,
15723                    ));
15724                }
15725            }
15726        }
15727        Err(error) => append_git_diagnostic(
15728            &mut snapshot,
15729            &format!("git branch query failed: {error}"),
15730        ),
15731    }
15732    if snapshot.branch.is_none() && !git_budget_exhausted(&mut snapshot, deadline, "git detached-HEAD probe") {
15733        if let Ok(output) = run_git_bounded_with_deadline(root, &["rev-parse", "--short", "HEAD"], 4 * 1_024, deadline).await {
15734            snapshot.truncated |= output.truncated;
15735            if output.success && !output.timed_out {
15736                let head = String::from_utf8_lossy(&output.stdout).trim().to_owned();
15737                if !head.is_empty() {
15738                    snapshot.branch = Some(format!("detached:{head}"));
15739                }
15740            }
15741        }
15742    }
15743
15744    if git_budget_exhausted(&mut snapshot, deadline, "git status") {
15745        return (snapshot, true);
15746    }
15747    match run_git_bounded_with_deadline(
15748        root,
15749        &[
15750            "status",
15751            "--porcelain=v1",
15752            "-z",
15753            "--renames",
15754            "--untracked-files=normal",
15755            "--",
15756            ".",
15757        ],
15758        GIT_OUTPUT_MAX_BYTES,
15759        deadline,
15760    )
15761    .await
15762    {
15763        Ok(output) => {
15764            snapshot.truncated |= output.truncated;
15765            if output.timed_out {
15766                append_git_diagnostic(&mut snapshot, "git status timed out");
15767            } else if output.success {
15768                parse_git_status(&output.stdout, &mut snapshot);
15769            } else {
15770                append_git_diagnostic(
15771                    &mut snapshot,
15772                    String::from_utf8_lossy(&output.stderr).trim(),
15773                );
15774            }
15775        }
15776        Err(error) => append_git_diagnostic(
15777            &mut snapshot,
15778            &format!("git status failed: {error}"),
15779        ),
15780    }
15781
15782    if git_budget_exhausted(&mut snapshot, deadline, "git log") {
15783        return (snapshot, true);
15784    }
15785    let log_limit = (GIT_COMMIT_MAX_ENTRIES + 1).to_string();
15786    match run_git_bounded_with_deadline(
15787        root,
15788        &["log", "-n", &log_limit, "--pretty=format:%H%x1f%s", "--", "."],
15789        16 * 1_024,
15790        deadline,
15791    )
15792    .await
15793    {
15794        Ok(output) => {
15795            snapshot.truncated |= output.truncated;
15796            if output.timed_out {
15797                append_git_diagnostic(&mut snapshot, "git log timed out");
15798            } else if output.success {
15799                parse_git_commits(&output.stdout, &mut snapshot);
15800            } else {
15801                let stderr = String::from_utf8_lossy(&output.stderr);
15802                if !stderr.to_ascii_lowercase().contains("does not have any commits") {
15803                    append_git_diagnostic(&mut snapshot, stderr.trim());
15804                }
15805            }
15806        }
15807        Err(error) => append_git_diagnostic(
15808            &mut snapshot,
15809            &format!("git log failed: {error}"),
15810        ),
15811    }
15812
15813    if git_budget_exhausted(&mut snapshot, deadline, "git worktree list") {
15814        return (snapshot, true);
15815    }
15816    match list_git_worktrees_with_deadline(root, deadline).await {
15817        Ok(worktrees) => {
15818            snapshot.worktrees = worktrees.into_iter().map(protocol_worktree).collect()
15819        }
15820        Err(error) => append_git_diagnostic(
15821            &mut snapshot,
15822            &format!("git worktree list failed: {}", error.message),
15823        ),
15824    }
15825    (snapshot, false)
15826}
15827
15828fn parse_git_status(output: &[u8], snapshot: &mut GitSnapshot) {
15829    let mut remaining = output;
15830    while !remaining.is_empty() {
15831        if snapshot.status.len() >= GIT_STATUS_MAX_ENTRIES {
15832            snapshot.truncated = true;
15833            break;
15834        }
15835        let Some(record_end) = remaining.iter().position(|byte| *byte == 0) else {
15836            snapshot.truncated = true;
15837            break;
15838        };
15839        let record = &remaining[..record_end];
15840        remaining = &remaining[record_end + 1..];
15841        let has_previous_path = record
15842            .get(..2)
15843            .is_some_and(|status| status.iter().any(|byte| matches!(*byte, b'R' | b'C')));
15844        let previous_path_bytes = if has_previous_path {
15845            let Some(previous_end) = remaining.iter().position(|byte| *byte == 0) else {
15846                snapshot.truncated = true;
15847                break;
15848            };
15849            let previous = &remaining[..previous_end];
15850            remaining = &remaining[previous_end + 1..];
15851            Some(previous)
15852        } else {
15853            None
15854        };
15855        if record.len() < 4
15856            || record[2] != b' '
15857            || !is_git_status_code(record[0])
15858            || !is_git_status_code(record[1])
15859        {
15860            snapshot.truncated = true;
15861            continue;
15862        }
15863        let Some(path) = windows_repository_path_from_bytes(&record[3..]) else {
15864            snapshot.truncated = true;
15865            continue;
15866        };
15867        let previous_path = match previous_path_bytes {
15868            Some(previous) => {
15869                let Some(previous) = windows_repository_path_from_bytes(previous) else {
15870                    snapshot.truncated = true;
15871                    continue;
15872                };
15873                Some(previous)
15874            }
15875            None => None,
15876        };
15877        snapshot.status.push(GitStatusEntry {
15878            index_status: (record[0] as char).to_string(),
15879            worktree_status: (record[1] as char).to_string(),
15880            path,
15881            previous_path,
15882        });
15883    }
15884}
15885
15886fn is_git_status_code(byte: u8) -> bool {
15887    matches!(byte, b' ' | b'M' | b'T' | b'A' | b'D' | b'R' | b'C' | b'U' | b'?' | b'!')
15888}
15889
15890fn parse_git_commits(output: &[u8], snapshot: &mut GitSnapshot) {
15891    for line in String::from_utf8_lossy(output).lines() {
15892        let Some((id, summary)) = line.split_once('\u{1f}') else {
15893            continue;
15894        };
15895        if snapshot.recent_commits.len() >= GIT_COMMIT_MAX_ENTRIES {
15896            snapshot.truncated = true;
15897            break;
15898        }
15899        let id = truncate_git_field(id, 64, &mut snapshot.truncated);
15900        let summary = truncate_git_field(summary, 512, &mut snapshot.truncated);
15901        snapshot.recent_commits.push(GitCommitSummary { id, summary });
15902    }
15903}
15904
15905fn truncate_git_field(value: &str, max_bytes: usize, truncated: &mut bool) -> String {
15906    if value.len() <= max_bytes {
15907        return value.to_owned();
15908    }
15909    *truncated = true;
15910    let mut boundary = max_bytes;
15911    while boundary > 0 && !value.is_char_boundary(boundary) {
15912        boundary -= 1;
15913    }
15914    value[..boundary].to_owned()
15915}
15916
15917fn append_git_diagnostic(snapshot: &mut GitSnapshot, message: &str) {
15918    if message.is_empty() {
15919        return;
15920    }
15921    let normalized = message
15922        .chars()
15923        .map(|character| if character.is_control() { ' ' } else { character })
15924        .collect::<String>();
15925    let mut diagnostic = snapshot.diagnostic.take().unwrap_or_default();
15926    if !diagnostic.is_empty() {
15927        diagnostic.push_str("; ");
15928    }
15929    diagnostic.push_str(normalized.trim());
15930    snapshot.diagnostic = Some(truncate_git_field(
15931        &diagnostic,
15932        GIT_DIAGNOSTIC_MAX_BYTES,
15933        &mut snapshot.truncated,
15934    ));
15935}
15936
15937async fn run_git_bounded(
15938    root: &str,
15939    arguments: &[&str],
15940    output_limit: usize,
15941) -> io::Result<GitCommandOutput> {
15942    run_git_read_bounded(root, arguments, output_limit, GIT_COMMAND_TIMEOUT_MS).await
15943}
15944
15945async fn read_git_history_bounded(
15946    root: &str,
15947    path: Option<&RepositoryPath>,
15948    before: Option<&GitObjectId>,
15949    limit: u16,
15950) -> Result<GitHistoryPage, NodeFailure> {
15951    let path = path
15952        .map(|path| {
15953            path.as_utf8().ok_or_else(|| {
15954                failure(
15955                    NodeFailureCode::InvalidRepositoryPath,
15956                    "git history path must use UTF-8",
15957                )
15958            })
15959        })
15960        .transpose()?
15961        .unwrap_or(".");
15962    let count = (usize::from(limit) + 1).to_string();
15963    let start = before
15964        .map(|revision| format!("{}^", revision.as_str()))
15965        .unwrap_or_else(|| "HEAD".to_owned());
15966    let arguments = [
15967        "--no-pager",
15968        "log",
15969        "--no-decorate",
15970        "--date=iso-strict",
15971        "--pretty=format:%H%x1f%P%x1f%s%x1f%an%x1f%ae%x1f%aI%x1f%cn%x1f%ce%x1f%cI%x1e",
15972        "-n",
15973        count.as_str(),
15974        start.as_str(),
15975        "--",
15976        path,
15977    ];
15978    let output = run_git_bounded(root, &arguments, 256 * 1_024)
15979        .await
15980        .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git history command failed"))?;
15981    if output.timed_out {
15982        return Err(failure(NodeFailureCode::GitReadTimedOut, "git history exceeded its bounded deadline"));
15983    }
15984    if !output.success {
15985        return Err(failure(NodeFailureCode::GitReadFailed, "git history could not be read"));
15986    }
15987    let mut commits = parse_git_history(&output.stdout);
15988    let has_more = commits.len() > usize::from(limit) || output.truncated;
15989    commits.truncate(usize::from(limit));
15990    let next_before = has_more.then(|| commits.last().map(|commit| commit.id.clone())).flatten();
15991    Ok(GitHistoryPage { commits, next_before, truncated: output.truncated })
15992}
15993
15994fn parse_git_history(output: &[u8]) -> Vec<GitCommitDetails> {
15995    String::from_utf8_lossy(output)
15996        .split('\u{1e}')
15997        .filter_map(|record| {
15998            let fields = record.trim_matches(['\r', '\n']).split('\u{1f}').collect::<Vec<_>>();
15999            if fields.len() != 9 {
16000                return None;
16001            }
16002            let id = GitObjectId::new(fields[0].to_owned()).ok()?;
16003            let parents = if fields[1].is_empty() {
16004                Vec::new()
16005            } else {
16006                fields[1]
16007                    .split(' ')
16008                    .map(|value| GitObjectId::new(value.to_owned()))
16009                    .collect::<Result<Vec<_>, _>>()
16010                    .ok()?
16011            };
16012            Some(GitCommitDetails {
16013                id,
16014                parents,
16015                subject: bounded_git_text(fields[2], 512),
16016                author_name: bounded_git_text(fields[3], 256),
16017                author_email: bounded_git_text(fields[4], 320),
16018                authored_at: bounded_git_text(fields[5], 64),
16019                committer_name: bounded_git_text(fields[6], 256),
16020                committer_email: bounded_git_text(fields[7], 320),
16021                committed_at: bounded_git_text(fields[8], 64),
16022                signature_status: GitSignatureStatus::NoSignature,
16023                signer: None,
16024            })
16025        })
16026        .take(usize::from(MAX_GIT_HISTORY_COMMITS) + 1)
16027        .collect()
16028}
16029
16030fn bounded_git_text(value: &str, max_bytes: usize) -> String {
16031    let normalized = value
16032        .chars()
16033        .map(|character| if character.is_control() { ' ' } else { character })
16034        .collect::<String>();
16035    if normalized.len() <= max_bytes {
16036        return normalized;
16037    }
16038    let mut boundary = max_bytes;
16039    while boundary > 0 && !normalized.is_char_boundary(boundary) {
16040        boundary -= 1;
16041    }
16042    normalized[..boundary].to_owned()
16043}
16044
16045async fn read_git_diff_bounded(root: &str, request: GitDiffRequest) -> Result<GitDiff, NodeFailure> {
16046    let path = request.path.as_ref().map(|path| {
16047        path.as_utf8()
16048            .ok_or_else(|| failure(NodeFailureCode::InvalidRepositoryPath, "git diff path must use UTF-8"))
16049    }).transpose()?;
16050    let mut arguments = match &request.mode {
16051        GitDiffMode::Working => vec!["--no-pager", "-c", "diff.external=", "diff", "--no-ext-diff", "--no-textconv"],
16052        GitDiffMode::Staged => vec!["--no-pager", "-c", "diff.external=", "diff", "--cached", "--no-ext-diff", "--no-textconv"],
16053        GitDiffMode::Commit { .. } => vec!["--no-pager", "-c", "diff.external=", "show", "--format=fuller", "--no-ext-diff", "--no-textconv"],
16054    };
16055    if let GitDiffMode::Commit { revision } = &request.mode {
16056        arguments.push(revision.as_str());
16057    }
16058    arguments.push("--");
16059    if let Some(path) = path {
16060        arguments.push(path);
16061    }
16062    let output = run_git_bounded(root, &arguments, MAX_GIT_DIFF_BYTES)
16063        .await
16064        .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git diff command failed"))?;
16065    if output.timed_out {
16066        return Err(failure(NodeFailureCode::GitReadTimedOut, "git diff exceeded its bounded deadline"));
16067    }
16068    if !output.success {
16069        return Err(failure(NodeFailureCode::GitReadFailed, "git diff could not be read"));
16070    }
16071    Ok(GitDiff {
16072        mode: request.mode,
16073        path: request.path,
16074        text: String::from_utf8_lossy(&output.stdout).into_owned(),
16075        truncated: output.truncated,
16076    })
16077}
16078
16079async fn process_request(shared: &NodeShared, connection_id: u64, role: ClientRole, envelope: RequestEnvelope) -> ResponseEnvelope {
16080    let result = process_request_inner(shared, connection_id, role, envelope.request).await;
16081    ResponseEnvelope { request_id: envelope.request_id, result }
16082}
16083
16084async fn process_request_inner(shared: &NodeShared, connection_id: u64, role: ClientRole, request: NodeRequest) -> Result<NodeResponse, NodeFailure> {
16085    if !request.harness_mcp_contract_is_valid_at(unix_time_ms()) {
16086        return Err(failure(NodeFailureCode::InvalidRequest, "harness MCP request is invalid or stale"));
16087    }
16088    let read_only = matches!(
16089        &request,
16090        NodeRequest::Snapshot
16091            | NodeRequest::Resync { .. }
16092            | NodeRequest::BrowseHostDirectories { .. }
16093            | NodeRequest::InspectWorkspace { .. }
16094            | NodeRequest::ReadWorkspaceFile { .. }
16095            | NodeRequest::ReadGitHistory { .. }
16096            | NodeRequest::ReadGitDiff { .. }
16097            | NodeRequest::CatalogNativeSessions { .. }
16098            | NodeRequest::PageNativeSessions { .. }
16099            | NodeRequest::PreviewNativeSession { .. }
16100            | NodeRequest::PreviewSessionRecord { .. }
16101    );
16102    let _mutation_guard = if read_only {
16103        None
16104    } else {
16105        let guard = shared.mutation_gate.lock().await;
16106        if shared.shutdown.load(Ordering::Acquire) {
16107            return Err(failure(NodeFailureCode::ShuttingDown, "node shutdown has begun"));
16108        }
16109        Some(guard)
16110    };
16111    match request {
16112        NodeRequest::Snapshot => Ok(NodeResponse::Snapshot {
16113            event_sequence: shared.current_sequence(),
16114            controller: shared.controller_state(),
16115            snapshot: shared.snapshot(),
16116        }),
16117        NodeRequest::Resync { after_sequence } => Ok(shared.resync(after_sequence)),
16118        NodeRequest::ArmHarnessMcpReservation {
16119            reservation_id,
16120            activation_digest,
16121            spawn_spec,
16122            launch,
16123            expires_at_unix_ms,
16124        } => {
16125            shared.require_controller(connection_id, role)?;
16126            let registry = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16127                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16128            })?;
16129            let expires_at_unix_ms = registry
16130                .arm(reservation_id.clone(), activation_digest.clone(), spawn_spec, launch, expires_at_unix_ms)
16131                .await
16132                .map_err(harness_mcp_failure)?;
16133            Ok(NodeResponse::Armed { reservation_id, activation_digest, expires_at_unix_ms })
16134        }
16135        NodeRequest::SpawnSpecWithHarnessMcp {
16136            reservation_id,
16137            activation_digest,
16138            spec,
16139            deadline_unix_ms,
16140        } => {
16141            shared.require_controller(connection_id, role)?;
16142            let receipt = shared
16143                .spawn_from_spec_with_harness_mcp(
16144                    reservation_id.clone(), activation_digest.clone(), spec, deadline_unix_ms,
16145                )
16146                .await?;
16147            Ok(NodeResponse::Spawned { reservation_id, activation_digest, receipt })
16148        }
16149        NodeRequest::ActivateHarnessMcpReservation {
16150            reservation_id,
16151            activation_digest,
16152            record_id,
16153            session,
16154        } => {
16155            shared.require_controller(connection_id, role)?;
16156            let provider_root_pid = shared.handle.snapshot().sessions.iter().find(|current| {
16157                current.instance_id == session.session.instance_id
16158                    && current.generation == session.session.generation
16159            }).and_then(|current| current.process_id).ok_or_else(|| {
16160                failure(NodeFailureCode::BindingMismatch, "provider root process is unavailable")
16161            })?;
16162            shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16163                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16164            })?.activate(
16165                &reservation_id, &activation_digest, &record_id, &session, provider_root_pid,
16166            ).map_err(harness_mcp_failure)?;
16167            Ok(NodeResponse::Activated { reservation_id, activation_digest, record_id, session })
16168        }
16169        NodeRequest::AbortHarnessMcpReservation { reservation_id, activation_digest } => {
16170            shared.require_controller(connection_id, role)?;
16171            let instance_id = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16172                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16173            })?.abort(&reservation_id, &activation_digest).map_err(harness_mcp_failure)?;
16174            if let Some(instance_id) = instance_id {
16175                SessionLifecycleHook::on_harness_reservation_aborted(&*shared, instance_id);
16176            }
16177            Ok(NodeResponse::Aborted { reservation_id, activation_digest })
16178        }
16179        NodeRequest::PutHarnessMcpReplyChunk {
16180            reservation_id, activation_digest, record_id, session, call_id,
16181            offset, final_chunk, chunk_hex,
16182        } => {
16183            shared.require_controller(connection_id, role)?;
16184            let (next_offset, completed) = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16185                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16186            })?.put_reply_chunk(
16187                &reservation_id, &activation_digest, &record_id, &session, &call_id,
16188                offset, final_chunk, &chunk_hex,
16189            ).map_err(harness_mcp_failure)?;
16190            Ok(NodeResponse::ReplyChunkAccepted {
16191                reservation_id, activation_digest, record_id, session, call_id,
16192                next_offset, completed,
16193            })
16194        }
16195        NodeRequest::RejectHarnessMcpCall {
16196            reservation_id, activation_digest, record_id, session, call_id, reason,
16197        } => {
16198            shared.require_controller(connection_id, role)?;
16199            shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16200                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16201            })?.reject_call(
16202                &reservation_id, &activation_digest, &record_id, &session, &call_id, reason,
16203            ).map_err(harness_mcp_failure)?;
16204            Ok(NodeResponse::CallRejected {
16205                reservation_id, activation_digest, record_id, session, call_id,
16206            })
16207        }
16208        NodeRequest::BeginDeliveryStage { manifest } => {
16209            shared.require_controller(connection_id, role)?;
16210            shared.begin_delivery_stage(manifest)
16211        }
16212        NodeRequest::PutDeliveryBlobChunk {
16213            stage_id,
16214            blob_digest,
16215            offset,
16216            chunk_hex,
16217        } => {
16218            shared.require_controller(connection_id, role)?;
16219            shared.put_delivery_blob_chunk(stage_id, blob_digest, offset, chunk_hex)
16220        }
16221        NodeRequest::CommitDeliveryStage { stage_id } => {
16222            shared.require_controller(connection_id, role)?;
16223            let receipt = shared.commit_delivery_stage(stage_id)?;
16224            Ok(NodeResponse::DeliveryCommitted { receipt })
16225        }
16226        NodeRequest::AbortDeliveryStage { stage_id } => {
16227            shared.require_controller(connection_id, role)?;
16228            shared.abort_delivery_stage(&stage_id)?;
16229            Ok(NodeResponse::DeliveryStageAborted { stage_id })
16230        }
16231        NodeRequest::BrowseHostDirectories { directory, after } => {
16232            let listing = shared.browse_host_directories(directory, after).await?;
16233            Ok(NodeResponse::HostDirectoriesBrowsed { listing })
16234        }
16235        NodeRequest::InspectWorkspace { workspace_id } => {
16236            let inspection = shared.inspect_workspace(workspace_id).await?;
16237            Ok(NodeResponse::WorkspaceInspected { inspection })
16238        }
16239        NodeRequest::ReadWorkspaceFile { workspace_id, path } => {
16240            let file = shared.read_workspace_file(workspace_id, path).await?;
16241            Ok(NodeResponse::WorkspaceFileRead { file })
16242        }
16243        NodeRequest::WriteWorkspaceFile { workspace_id, path, expected_revision, text } => {
16244            shared.require_controller(connection_id, role)?;
16245            let file = shared
16246                .write_workspace_file(workspace_id, path, expected_revision, text)
16247                .await?;
16248            Ok(NodeResponse::WorkspaceFileWritten { file })
16249        }
16250        NodeRequest::CreateWorkspaceFile { workspace_id, path } => {
16251            shared.require_controller(connection_id, role)?;
16252            let file = shared.create_workspace_file(workspace_id, path).await?;
16253            Ok(NodeResponse::WorkspaceFileCreated { file })
16254        }
16255        NodeRequest::CreateWorkspaceDirectory { workspace_id, path } => {
16256            shared.require_controller(connection_id, role)?;
16257            let entry = shared
16258                .create_workspace_directory(workspace_id.clone(), path)
16259                .await?;
16260            Ok(NodeResponse::WorkspaceDirectoryCreated { workspace_id, entry })
16261        }
16262        NodeRequest::ReadGitHistory { workspace_id, path, before, limit } => {
16263            if role != ClientRole::Operator {
16264                return Err(failure(NodeFailureCode::ObserverReadOnly, "git history requires an operator connection"));
16265            }
16266            if !(1..=MAX_GIT_HISTORY_COMMITS).contains(&limit) {
16267                return Err(failure(NodeFailureCode::InvalidRequest, "git history limit is invalid"));
16268            }
16269            let page = shared
16270                .read_git_history(workspace_id.clone(), path, before, limit)
16271                .await?;
16272            Ok(NodeResponse::GitHistoryRead { workspace_id, page })
16273        }
16274        NodeRequest::ReadGitDiff { workspace_id, request } => {
16275            if role != ClientRole::Operator {
16276                return Err(failure(NodeFailureCode::ObserverReadOnly, "git diff requires an operator connection"));
16277            }
16278            let diff = shared.read_git_diff(workspace_id.clone(), request).await?;
16279            Ok(NodeResponse::GitDiffRead { workspace_id, diff })
16280        }
16281        NodeRequest::CatalogNativeSessions { route, limit } => {
16282            if role != ClientRole::Operator {
16283                return Err(failure(
16284                    NodeFailureCode::ObserverReadOnly,
16285                    "native session catalog requires an operator connection",
16286                ));
16287            }
16288            if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit) {
16289                return Err(failure(
16290                    NodeFailureCode::InvalidRequest,
16291                    "native session catalog limit is invalid",
16292                ));
16293            }
16294            let (entries, summary) = shared
16295                .catalog_native_sessions(route.clone(), limit)
16296                .await?;
16297            Ok(NodeResponse::NativeSessionsCataloged {
16298                route,
16299                entries,
16300                summary: Some(summary),
16301            })
16302        }
16303        NodeRequest::PageNativeSessions {
16304            route,
16305            window,
16306            catalog_revision,
16307            recent_cutoff_unix_ms,
16308            after_selection_id,
16309            limit,
16310        } => {
16311            if role != ClientRole::Operator {
16312                return Err(failure(
16313                    NodeFailureCode::ObserverReadOnly,
16314                    "native session catalog paging requires an operator connection",
16315                ));
16316            }
16317            if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit)
16318                || after_selection_id
16319                    .as_deref()
16320                    .is_some_and(|cursor| validate_candidate_id(cursor).is_err())
16321            {
16322                return Err(failure(
16323                    NodeFailureCode::InvalidRequest,
16324                    "native session catalog page request is invalid",
16325                ));
16326            }
16327            let page = shared
16328                .page_native_sessions(
16329                    route.clone(),
16330                    window,
16331                    catalog_revision,
16332                    recent_cutoff_unix_ms,
16333                    after_selection_id,
16334                    limit,
16335                )
16336                .await?;
16337            Ok(NodeResponse::NativeSessionsPaged {
16338                route,
16339                page,
16340            })
16341        }
16342        NodeRequest::PreviewNativeSession {
16343            selection,
16344            message_limit,
16345        } => {
16346            if role != ClientRole::Operator {
16347                return Err(failure(
16348                    NodeFailureCode::ObserverReadOnly,
16349                    "native session preview requires an operator connection",
16350                ));
16351            }
16352            if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16353                .contains(&message_limit)
16354                || selection.validate().is_err()
16355            {
16356                return Err(failure(
16357                    NodeFailureCode::InvalidRequest,
16358                    "native session preview request is invalid",
16359                ));
16360            }
16361            let preview = shared
16362                .preview_native_session(selection.clone(), message_limit)
16363                .await?;
16364            Ok(NodeResponse::NativeSessionPreviewed {
16365                selection,
16366                preview,
16367            })
16368        }
16369        NodeRequest::IndexNativeSession {
16370            selection,
16371            display_name,
16372        } => {
16373            shared.require_controller(connection_id, role)?;
16374            let record = shared
16375                .index_native_session(selection.clone(), display_name)
16376                .await?;
16377            Ok(NodeResponse::NativeSessionIndexed { selection, record })
16378        }
16379        NodeRequest::PreviewSessionRecord {
16380            record_id,
16381            message_limit,
16382        } => {
16383            if role != ClientRole::Operator {
16384                return Err(failure(
16385                    NodeFailureCode::ObserverReadOnly,
16386                    "native session preview requires an operator connection",
16387                ));
16388            }
16389            if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16390                .contains(&message_limit)
16391            {
16392                return Err(failure(
16393                    NodeFailureCode::InvalidRequest,
16394                    "native session preview request is invalid",
16395                ));
16396            }
16397            let record = shared.record(&record_id)?;
16398            let working_directory = shared.workspace_root(&record.workspace_id)?;
16399            if !platform::roots_equal(
16400                &working_directory,
16401                windows_path_text(&record.canonical_root),
16402            ) {
16403                return Err(failure(
16404                    NodeFailureCode::SessionRecordConflict,
16405                    "managed session workspace root changed; refusing to preview another directory",
16406                ));
16407            }
16408            let identity = record.provider_session.clone().ok_or_else(|| {
16409                failure(
16410                    NodeFailureCode::BackendOperationFailed,
16411                    "native session preview is unavailable for this session record",
16412                )
16413            })?;
16414            if identity.key != ProviderSessionKey::SessionId {
16415                return Err(failure(
16416                    NodeFailureCode::BackendOperationFailed,
16417                    "native session preview is unavailable for this session identity",
16418                ));
16419            }
16420            let preview = shared
16421                .preview_native_session_by_session_id(
16422                    record.workspace_id.clone(), record.provider.clone(), identity.id.clone(), message_limit,
16423                )
16424                .await?;
16425            if preview.session_id != identity.id {
16426                return Err(failure(
16427                    NodeFailureCode::SessionRecordConflict,
16428                    "native session preview identity changed while loading",
16429                ));
16430            }
16431            shared.revalidate_session_record_preview(&record, &identity)?;
16432            let preview: gate4agent_types::SessionRecordPreview = preview.into();
16433            shared.publish(NodeEvent::SessionRecordHistorySummarized {
16434                record_id: record_id.clone(),
16435                summary: SessionHistorySummaryV1::from(&preview),
16436            });
16437            Ok(NodeResponse::SessionRecordPreviewed {
16438                record_id,
16439                preview,
16440            })
16441        }
16442        NodeRequest::AcquireController { lease_ms } => {
16443            let controller = shared.acquire_controller(connection_id, role, lease_ms)?;
16444            Ok(NodeResponse::Controller { controller: Some(controller) })
16445        }
16446        NodeRequest::ReleaseController => {
16447            shared.release_controller(connection_id);
16448            Ok(NodeResponse::Controller { controller: shared.controller_state() })
16449        }
16450        NodeRequest::RegisterWorkspace { workspace_id, root } => {
16451            shared.require_controller(connection_id, role)?;
16452            let root = require_windows_path(root)?;
16453            shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16454            let workspace = shared.register_workspace(workspace_id, root).await?;
16455            Ok(NodeResponse::WorkspaceRegistered { workspace })
16456        }
16457        NodeRequest::CreateStandaloneWorkspace {
16458            workspace_id,
16459            root,
16460            initial_branch,
16461        } => {
16462            shared.require_controller(connection_id, role)?;
16463            let root = require_windows_path(root)?;
16464            shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16465            let workspace = shared
16466                .create_standalone_workspace(workspace_id, root, initial_branch)
16467                .await?;
16468            Ok(NodeResponse::StandaloneWorkspaceCreated { workspace })
16469        }
16470        NodeRequest::UnregisterWorkspace { workspace_id } => {
16471            shared.require_controller(connection_id, role)?;
16472            shared.reject_managed_reservation(Some(&workspace_id), None)?;
16473            shared.unregister_workspace(&workspace_id)?;
16474            Ok(NodeResponse::WorkspaceUnregistered { workspace_id })
16475        }
16476        NodeRequest::CreateWorktree {
16477            source_workspace_id,
16478            workspace_id,
16479            target_root,
16480            branch,
16481            base,
16482        } => {
16483            shared.require_controller(connection_id, role)?;
16484            let target_root = require_windows_path(target_root)?;
16485            shared.reject_managed_reservation(Some(&workspace_id), Some(&target_root))?;
16486            let (worktree, workspace) = shared
16487                .create_worktree(
16488                    source_workspace_id,
16489                    workspace_id,
16490                    target_root,
16491                    branch,
16492                    base,
16493                )
16494                .await?;
16495            Ok(NodeResponse::WorktreeCreated { worktree, workspace })
16496        }
16497        NodeRequest::RemoveWorktree {
16498            source_workspace_id,
16499            target_root,
16500        } => {
16501            shared.require_controller(connection_id, role)?;
16502            let native_target_root = require_windows_path(target_root.clone())?;
16503            shared.reject_managed_reservation(None, Some(&native_target_root))?;
16504            let workspace_id = shared
16505                .remove_worktree(source_workspace_id, native_target_root)
16506                .await?;
16507            Ok(NodeResponse::WorktreeRemoved {
16508                target_root,
16509                workspace_id,
16510            })
16511        }
16512        NodeRequest::Spawn { workspace_id, provider, mode, terminal_size, initial_prompt } => {
16513            shared.require_controller(connection_id, role)?;
16514            if !terminal_size.is_valid() {
16515                return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
16516            }
16517            if let Some(prompt) = initial_prompt.as_deref() {
16518                validate_node_text("spawn initial prompt", prompt)?;
16519            }
16520            match shared
16521                .spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt)
16522                .await
16523            {
16524                Ok(session) => {
16525                    shared.mark_spawn_provisional(session.clone());
16526                    Ok(NodeResponse::SpawnAccepted { session })
16527                }
16528                Err(err) => {
16529                    tracing::warn!(
16530                        %provider,
16531                        %workspace_id,
16532                        ?mode,
16533                        code = ?err.code,
16534                        message = %err.message,
16535                        "session spawn refused"
16536                    );
16537                    Err(err)
16538                }
16539            }
16540        }
16541        NodeRequest::SpawnWithOptions { workspace_id, provider, mode, terminal_size, initial_prompt, options } => {
16542            shared.require_controller(connection_id, role)?;
16543            if !terminal_size.is_valid() {
16544                return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
16545            }
16546            if let Some(prompt) = initial_prompt.as_deref() {
16547                validate_node_text("spawn initial prompt", prompt)?;
16548            }
16549            let plan = backend_paths::plan_launch_options(
16550                provider.as_str(),
16551                mode,
16552                options.model.is_some(),
16553                options.effort.is_some(),
16554            )
16555            .map_err(|r| {
16556                tracing::warn!(%provider, reason = %r.message, "launch option path refused");
16557                failure(NodeFailureCode::InvalidRequest, "launch option path refused")
16558            })?;
16559            let selection = spawn_selection(&options)?;
16560            let pty_selection = match plan {
16561                backend_paths::LaunchOptionsPlan::PtyFlags => selection,
16562                _ => None,
16563            };
16564            let spawned = SPAWN_SESSION_OPTIONS
16565                .scope(pty_selection, shared.spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt))
16566                .await;
16567            match spawned {
16568                Ok(session) => {
16569                    if plan == backend_paths::LaunchOptionsPlan::AcpAfterSpawn {
16570                        shared
16571                            .pending_acp_options
16572                            .lock()
16573                            .unwrap_or_else(|p| p.into_inner())
16574                            .insert(session.session.instance_id, (options.model.clone(), options.effort.clone()));
16575                    }
16576                    shared.mark_spawn_provisional(session.clone());
16577                    tracing::info!(%provider, model = ?options.model, effort = ?options.effort, "session spawned with launch options");
16578                    Ok(NodeResponse::SpawnAccepted { session })
16579                }
16580                Err(err) => {
16581                    tracing::warn!(%provider, %workspace_id, code = ?err.code, message = %err.message, "session spawn with options refused");
16582                    Err(err)
16583                }
16584            }
16585        }
16586        NodeRequest::SpawnSpec { spec } => {
16587            shared.require_controller(connection_id, role)?;
16588            let receipt = shared.spawn_from_spec(spec).await?;
16589            Ok(NodeResponse::SpawnSpecAccepted { receipt })
16590        }
16591        NodeRequest::SpawnManagedWorktree { request } => {
16592            shared.require_controller(connection_id, role)?;
16593            let receipt = shared.spawn_managed_worktree(request).await?;
16594            Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
16595        }
16596        NodeRequest::SpawnManagedWorktreeV2 { request } => {
16597            shared.require_controller(connection_id, role)?;
16598            let receipt = shared.spawn_managed_worktree_v2(request).await?;
16599            Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
16600        }
16601        NodeRequest::CleanupManagedWorktree { lease_id } => {
16602            shared.require_controller(connection_id, role)?;
16603            let lease = shared.cleanup_managed_worktree(&lease_id, true).await?;
16604            Ok(NodeResponse::ManagedWorktreeCleanup { lease })
16605        }
16606        NodeRequest::DiscoverHistory { session, limit } => {
16607            controlled_session(shared, connection_id, role, &session)?;
16608            let candidates = shared.discover_history(&session, limit).await?;
16609            Ok(NodeResponse::HistoryDiscovered {
16610                session,
16611                candidates,
16612            })
16613        }
16614        NodeRequest::LoadHistory {
16615            session,
16616            candidate_id,
16617        } => {
16618            controlled_session(shared, connection_id, role, &session)?;
16619            let loaded = shared
16620                .load_history(&session, candidate_id)
16621                .await?;
16622            Ok(NodeResponse::HistoryLoaded {
16623                session,
16624                session_id: loaded.session_id,
16625                message_count: loaded.message_count,
16626                completed_turn_count: loaded.completed_turn_count,
16627            })
16628        }
16629        NodeRequest::ExportContextPackForSessionRecord { record_id, session } => {
16630            shared.require_controller(connection_id, role)?;
16631            let context = shared
16632                .export_context_pack_for_session_record(&record_id, &session, false)
16633                .await?;
16634            Ok(NodeResponse::ContextPackForSessionRecordExported {
16635                record_id,
16636                session,
16637                context,
16638            })
16639        }
16640        NodeRequest::ExportContextPack { session } => {
16641            controlled_session(shared, connection_id, role, &session)?;
16642            let context = shared.export_context_pack(&session).await?;
16643            Ok(NodeResponse::ContextPackExported { context })
16644        }
16645        NodeRequest::ForgetContextPack { context_id } => {
16646            shared.require_controller(connection_id, role)?;
16647            shared.forget_context_pack(&context_id)?;
16648            Ok(NodeResponse::ContextPackForgotten { context_id })
16649        }
16650        NodeRequest::ResolveDurableContextPack { context_id } => {
16651            // Read-only, idempotent, no controller lease required: this lets
16652            // a caller preflight "is this durably exported pack still here"
16653            // without holding the Node's mutation controller.
16654            let context = shared
16655                .context_catalog
16656                .read()
16657                .unwrap_or_else(|poisoned| poisoned.into_inner())
16658                .get(&context_id)
16659                .map(|pack| pack.receipt().clone())
16660                .ok_or_else(|| {
16661                    failure(
16662                        NodeFailureCode::UnknownContextPack,
16663                        "context pack is not present",
16664                    )
16665                })?;
16666            Ok(NodeResponse::DurableContextPackResolved { context })
16667        }
16668        NodeRequest::ReadContextPack { digest } => {
16669            // Read-only fetch of the pack's own bytes, keyed by content digest
16670            // rather than the caller's `SpawnContextId`: same no-controller-lease
16671            // rationale as `ResolveDurableContextPack` above, one step further
16672            // down the same catalog.
16673            let (id, byte_len, bytes) = shared
16674                .context_catalog
16675                .read()
16676                .unwrap_or_else(|poisoned| poisoned.into_inner())
16677                .get_by_digest(&digest)
16678                .map(|pack| {
16679                    (pack.receipt().id.clone(), pack.receipt().byte_len, pack.bytes().to_vec())
16680                })
16681                .ok_or_else(|| {
16682                    failure(
16683                        NodeFailureCode::UnknownContextPack,
16684                        &format!("context pack {digest} not on this node"),
16685                    )
16686                })?;
16687            tracing::debug!(
16688                digest = digest.as_str(),
16689                byte_len,
16690                "read context pack bytes",
16691            );
16692            Ok(NodeResponse::ContextPackBytesRead {
16693                pack: ContextPackBytesRead {
16694                    digest,
16695                    id,
16696                    byte_len,
16697                    bytes,
16698                },
16699            })
16700        }
16701        NodeRequest::Resume { session, terminal_size, initial_prompt } => {
16702            let provider = controlled_session(shared, connection_id, role, &session)?;
16703            if let Some(prompt) = initial_prompt.as_deref() {
16704                validate_node_text("resume initial prompt", prompt)?;
16705            }
16706            let runtime_requirement = if initial_prompt.is_some() {
16707                ProviderRuntimeRequirement::ResumeWithPrompt
16708            } else {
16709                ProviderRuntimeRequirement::Resume
16710            };
16711            shared.require_session_runtime_policy(&session, runtime_requirement)?;
16712            let runtime_policy = shared
16713                .admit_provider_runtime(&provider, runtime_requirement)
16714                .await?;
16715            let working_directory = shared.bound_workspace_root(&session)?;
16716            let request = ResumeLaunchRequest {
16717                working_directory,
16718                terminal_size,
16719                initial_prompt,
16720            };
16721            request
16722                .validate()
16723                .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
16724            let command = shared.prepare_command(ControlCommand::Resume {
16725                instance_id: session.session.instance_id,
16726                target: ResumeTarget::CurrentProvider,
16727                runtime_policy,
16728                request,
16729            });
16730            shared.arm_resume(&session, command.id, runtime_policy)?;
16731            let dispatch = shared.dispatch_envelope(command);
16732            if let Err(error) = dispatch {
16733                shared.clear_armed_resume(&session);
16734                return Err(error);
16735            }
16736            Ok(NodeResponse::Accepted)
16737        }
16738        NodeRequest::RenameSessionRecord {
16739            record_id,
16740            display_name,
16741        } => {
16742            shared.require_controller(connection_id, role)?;
16743            let record = shared.rename_session_record(&record_id, display_name)?;
16744            Ok(NodeResponse::SessionRecordUpdated { record })
16745        }
16746        NodeRequest::SetSessionTask {
16747            record_id,
16748            expected_revision,
16749            target,
16750        } => {
16751            shared.require_controller(connection_id, role)?;
16752            let record = shared.set_session_task(&record_id, expected_revision, target)?;
16753            Ok(NodeResponse::SessionRecordUpdated { record })
16754        }
16755        NodeRequest::IndexProviderSession {
16756            workspace_id,
16757            provider,
16758            identity,
16759            display_name,
16760        } => {
16761            shared.require_controller(connection_id, role)?;
16762            let record = shared.index_provider_session(
16763                workspace_id,
16764                provider,
16765                identity,
16766                display_name,
16767            )?;
16768            Ok(NodeResponse::ProviderSessionIndexed { record })
16769        }
16770        NodeRequest::ResumeSessionRecord {
16771            record_id,
16772            terminal_size,
16773            initial_prompt,
16774        } => {
16775            shared.require_controller(connection_id, role)?;
16776            if let Some(prompt) = initial_prompt.as_deref() {
16777                validate_node_text("managed resume initial prompt", prompt)?;
16778            }
16779            let (record, session) = shared
16780                .resume_session_record(
16781                    &record_id,
16782                    terminal_size,
16783                    initial_prompt,
16784                )
16785                .await?;
16786            Ok(NodeResponse::SessionRecordResumed { record, session })
16787        }
16788        NodeRequest::ForgetSessionRecord { record_id } => {
16789            shared.require_controller(connection_id, role)?;
16790            shared.forget_session_record(&record_id).await?;
16791            Ok(NodeResponse::SessionRecordForgotten { record_id })
16792        }
16793        NodeRequest::Prompt { session, text } => {
16794            let agent_id = controlled_session(shared, connection_id, role, &session)?;
16795            validate_node_text("prompt", &text)?;
16796            shared.require_session_runtime_policy(
16797                &session,
16798                ProviderRuntimeRequirement::SemanticPrompt,
16799            )?;
16800            let framing = prompt_framing(&agent_id);
16801            shared
16802                .dispatch_input_bounded(
16803                    &session,
16804                    InputAction::SubmitPrompt(PromptPayload { text, framing }),
16805                )
16806                .await?;
16807            Ok(NodeResponse::Accepted)
16808        }
16809        NodeRequest::Paste { session, text } => {
16810            controlled_session(shared, connection_id, role, &session)?;
16811            validate_node_text("paste", &text)?;
16812            shared.require_session_runtime_policy(
16813                &session,
16814                ProviderRuntimeRequirement::SemanticPrompt,
16815            )?;
16816            shared
16817                .dispatch_input_bounded(
16818                    &session,
16819                    InputAction::InsertDraft(PromptPayload {
16820                        text,
16821                        framing: PromptFraming::BracketedPaste,
16822                    }),
16823                )
16824                .await?;
16825            Ok(NodeResponse::Accepted)
16826        }
16827        NodeRequest::Input { session, text } => {
16828            controlled_session(shared, connection_id, role, &session)?;
16829            validate_node_text("terminal input", &text)?;
16830            shared
16831                .dispatch_input_bounded(
16832                    &session,
16833                    InputAction::TerminalText(TerminalText { text }),
16834                )
16835                .await?;
16836            Ok(NodeResponse::Accepted)
16837        }
16838        NodeRequest::TerminalBytes { session, bytes } => {
16839            controlled_session(shared, connection_id, role, &session)?;
16840            shared.sessionize_spawn(session.session.instance_id);
16841            let action = terminal_bytes_action(bytes)?;
16842            shared
16843                .dispatch_input_bounded(&session, action)
16844                .await?;
16845            Ok(NodeResponse::Accepted)
16846        }
16847        NodeRequest::TerminalControl { session, control } => {
16848            controlled_session(shared, connection_id, role, &session)?;
16849            shared
16850                .dispatch_input_bounded(&session, InputAction::TerminalControl(control))
16851                .await?;
16852            Ok(NodeResponse::Accepted)
16853        }
16854        NodeRequest::Resize { session, size } => {
16855            controlled_session(shared, connection_id, role, &session)?;
16856            shared.sessionize_spawn(session.session.instance_id);
16857            if !size.is_valid() {
16858                return Err(failure(NodeFailureCode::InvalidRequest, "terminal size is invalid"));
16859            }
16860            shared.dispatch_resize_bounded(&session, size).await?;
16861            Ok(NodeResponse::Accepted)
16862        }
16863        NodeRequest::Interrupt { session } => {
16864            controlled_session(shared, connection_id, role, &session)?;
16865            shared
16866                .dispatch_input_bounded(
16867                    &session,
16868                    InputAction::TerminalControl(TerminalControl::Interrupt),
16869                )
16870                .await?;
16871            Ok(NodeResponse::Accepted)
16872        }
16873        NodeRequest::Stop { session, force } => {
16874            controlled_session(shared, connection_id, role, &session)?;
16875            shared.sessionize_spawn(session.session.instance_id);
16876            shared.dispatch(ControlCommand::Stop { instance_id: session.session.instance_id, force })?;
16877            shared.retire_session_grok_leader(session.session.instance_id);
16878            Ok(NodeResponse::Accepted)
16879        }
16880        NodeRequest::Remove { session } => {
16881            controlled_session(shared, connection_id, role, &session)?;
16882            shared.remove_session(&session).await?;
16883            Ok(NodeResponse::Accepted)
16884        }
16885        NodeRequest::ResolveInteraction { session, correlation_id, response } => {
16886            shared.require_controller(connection_id, role)?;
16887            let snapshot = shared.require_acp_session(&session)?;
16888            let interaction = snapshot
16889                .provider
16890                .interactions
16891                .iter()
16892                .find(|interaction| {
16893                    interaction_correlation(
16894                        session.session.instance_id,
16895                        session.session.generation,
16896                        interaction.id.0,
16897                    ) == correlation_id
16898                })
16899                .ok_or_else(|| failure(
16900                    NodeFailureCode::InvalidRequest,
16901                    "unknown interaction correlation",
16902                ))?;
16903            if interaction.status != ProviderInteractionStatus::Pending {
16904                return Err(failure(
16905                    NodeFailureCode::BackendOperationFailed,
16906                    "interaction has already been resolved",
16907                ));
16908            }
16909            response
16910                .validate_for(interaction.interaction_kind)
16911                .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
16912            let interaction_id = interaction.id;
16913            let response_kind = response.kind();
16914            let response_outcome = response.outcome();
16915            shared.dispatch(ControlCommand::ResolveInteraction {
16916                instance_id: session.session.instance_id,
16917                generation: session.session.generation,
16918                interaction_id,
16919                response,
16920            })?;
16921            // The one line that lets an operator compare what THIS node
16922            // recorded against what the provider itself reports doing --
16923            // measured live on a kimi run where our side resolved `true`
16924            // while the agent reported "rejected" and neither layer had a
16925            // line to line up against the other.
16926            tracing::info!(
16927                node_id = %shared.node_id,
16928                session = ?session.session,
16929                correlation_id = %correlation_id,
16930                interaction_id = interaction_id.0,
16931                response_kind = ?response_kind,
16932                outcome = ?response_outcome,
16933                "interaction resolved",
16934            );
16935            Ok(NodeResponse::Accepted)
16936        }
16937        NodeRequest::SetSessionMode { session, mode_id } => {
16938            shared.require_controller(connection_id, role)?;
16939            shared.require_acp_session(&session)?;
16940            shared.dispatch(ControlCommand::SetSessionMode {
16941                instance_id: session.session.instance_id,
16942                mode_id,
16943            })?;
16944            Ok(NodeResponse::Accepted)
16945        }
16946        NodeRequest::SetSessionConfigOption {
16947            session,
16948            option_id,
16949            value_json,
16950        } => {
16951            shared.require_controller(connection_id, role)?;
16952            shared.require_acp_session(&session)?;
16953            shared.dispatch(ControlCommand::SetSessionConfigOption {
16954                instance_id: session.session.instance_id,
16955                option_id,
16956                value_json,
16957            })?;
16958            Ok(NodeResponse::Accepted)
16959        }
16960        NodeRequest::SetSessionModel { session, model_id } => {
16961            shared.require_controller(connection_id, role)?;
16962            shared.require_acp_session(&session)?;
16963            shared.dispatch(ControlCommand::SetSessionModel {
16964                instance_id: session.session.instance_id,
16965                model_id,
16966            })?;
16967            Ok(NodeResponse::Accepted)
16968        }
16969        NodeRequest::Shutdown => {
16970            shared.require_controller(connection_id, role)?;
16971            shared.begin_shutdown_locked().await?;
16972            Ok(NodeResponse::ShuttingDown)
16973        }
16974    }
16975}
16976
16977fn controlled_session(shared: &NodeShared, connection_id: u64, role: ClientRole, session: &SessionAddress) -> Result<AgentId, NodeFailure> {
16978    shared.require_controller(connection_id, role)?;
16979    shared.validate_address(session)
16980}
16981
16982/// Selects the sole history candidate matching `provider_session_id` when
16983/// the managed record carries a verified identity, or the sole candidate in
16984/// the discovered set at all when it does not (see
16985/// `export_context_pack_for_session_record_inner`'s identity match). Either
16986/// way, more than one match refuses instead of guessing which one is exact.
16987fn select_history_candidate(
16988    candidates: &[HistoryCandidateSummary],
16989    provider_session_id: Option<&str>,
16990) -> Result<String, NodeFailure> {
16991    let mut matches = candidates.iter().filter(|candidate| match provider_session_id {
16992        Some(id) => candidate.session_id_hint == id,
16993        None => true,
16994    });
16995    let candidate = matches.next().ok_or_else(|| {
16996        failure(
16997            NodeFailureCode::SessionRecordConflict,
16998            if provider_session_id.is_some() {
16999                "no history candidate matches the managed provider session"
17000            } else {
17001                "no history candidate exists for the managed session"
17002            },
17003        )
17004    })?;
17005    if matches.next().is_some() {
17006        return Err(failure(
17007            NodeFailureCode::SessionRecordConflict,
17008            if provider_session_id.is_some() {
17009                "history identity is ambiguous for the managed provider session"
17010            } else {
17011                "history is ambiguous without a verified provider session identity"
17012            },
17013        ));
17014    }
17015    Ok(candidate.id.clone())
17016}
17017
17018fn resolved_spawn_profile_summary(
17019    profile: &SpawnProfileDefaults,
17020    environment_profiles: &BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>,
17021) -> Option<SpawnProfileSummary> {
17022    let environment_profile = match profile.environment_profile_id.as_ref() {
17023        None => None,
17024        Some(profile_id) => {
17025            let binding = environment_profiles.get(profile_id)?;
17026            if binding.provider != profile.provider
17027                || binding.native_profile_id(profile.mode).is_none()
17028            {
17029                return None;
17030            }
17031            Some(ResolvedEnvironmentProfileReceipt {
17032                profile_id: binding.id.clone(),
17033                profile_revision: binding.revision.clone(),
17034                network_allowlist: None,
17035                browser_profile_id: None,
17036            })
17037        }
17038    };
17039    Some(SpawnProfileSummary {
17040        id: profile.profile_id.clone(),
17041        revision: profile.revision.clone(),
17042        environment_profile,
17043    })
17044}
17045
17046/// True if `current` is an acceptable version of `expected` at export
17047/// revalidation/commit time.
17048///
17049/// When `allow_clean_detachment` is `false` (the on-demand,
17050/// `NodeRequest::ExportContextPackForSessionRecord` API path), `current` must
17051/// be byte-identical to `expected` and still actively bound to `session` —
17052/// the original, unchanged invariant.
17053///
17054/// When `true` (the reactive auto-export-at-exit path, `§2.1`), `current` may
17055/// ALSO be the exact Live -> Dormant transition that the same clean exit this
17056/// export reacts to produces on its own: `state` Live -> Dormant,
17057/// `active_session` `Some(session)` -> `None`, `updated_at_unix_ms`
17058/// advancing. Every other field — including `provider_session`, `context`,
17059/// `bundle`, `task_binding` — must still match exactly. A record that changed
17060/// in any other way (rebind, a different active session, an identity
17061/// conflict, capacity eviction) is rejected either way; this widens nothing
17062/// about which sessions are eligible, only how the record's own expected
17063/// detachment is tolerated mid-export.
17064fn session_record_export_target_matches(
17065    expected: &ManagedSessionRecord,
17066    current: &ManagedSessionRecord,
17067    session: &SessionAddress,
17068    allow_clean_detachment: bool,
17069) -> bool {
17070    if current == expected && current.active_session.as_ref() == Some(session) {
17071        return current.state == ManagedSessionState::Live;
17072    }
17073    allow_clean_detachment
17074        && current.state == ManagedSessionState::Dormant
17075        && current.active_session.is_none()
17076        && current.record_id == expected.record_id
17077        && current.display_name == expected.display_name
17078        && current.provider == expected.provider
17079        && current.mode == expected.mode
17080        && current.workspace_id == expected.workspace_id
17081        && current.canonical_root == expected.canonical_root
17082        && current.provider_session == expected.provider_session
17083        && current.environment_profile == expected.environment_profile
17084        && current.bundle == expected.bundle
17085        && current.context_id == expected.context_id
17086        && current.context == expected.context
17087        && current.exported_context == expected.exported_context
17088        && current.task_binding == expected.task_binding
17089        && current.created_at_unix_ms == expected.created_at_unix_ms
17090        && current.last_error == expected.last_error
17091}
17092
17093fn session_record_context_export_binding_is_exact(
17094    expected: &ManagedSessionRecord,
17095    current: &ManagedSessionRecord,
17096    identity: Option<&ProviderSessionIdentity>,
17097    session: &SessionAddress,
17098    binding: &SessionBinding,
17099    provider: &AgentId,
17100    current_root: &str,
17101    allow_clean_detachment: bool,
17102) -> bool {
17103    session_record_export_target_matches(expected, current, session, allow_clean_detachment)
17104        && current.provider_session.as_ref() == identity
17105        && current.workspace_id == session.workspace_id
17106        && provider == &current.provider
17107        && binding.workspace_id == session.workspace_id
17108        && binding.generation == session.session.generation
17109        && binding.record_id.as_ref() == Some(&current.record_id)
17110        && platform::roots_equal(
17111            current_root,
17112            windows_path_text(&current.canonical_root),
17113        )
17114}
17115
17116fn session_record_context_export_source_is_usable(
17117    record: &ManagedSessionRecord,
17118    status: &SessionStatus,
17119    allow_clean_detachment: bool,
17120) -> bool {
17121    (record.state == ManagedSessionState::Live
17122        || (allow_clean_detachment && record.state == ManagedSessionState::Dormant))
17123        && context_pack_source_status_is_usable(status)
17124}
17125
17126fn prompt_framing(_agent_id: &AgentId) -> PromptFraming {
17127    PromptFraming::BracketedPaste
17128}
17129
17130fn validate_node_text(field: &str, text: &str) -> Result<(), NodeFailure> {
17131    if text.len() > MAX_NODE_TEXT_BYTES {
17132        return Err(failure(
17133            NodeFailureCode::InvalidRequest,
17134            &format!(
17135                "{field} length {} exceeds the {MAX_NODE_TEXT_BYTES}-byte node limit",
17136                text.len(),
17137            ),
17138        ));
17139    }
17140    Ok(())
17141}
17142
17143fn validate_terminal_bytes(bytes: &[u8]) -> Result<(), NodeFailure> {
17144    if bytes.is_empty() || bytes.len() > MAX_NODE_TERMINAL_BYTES {
17145        return Err(failure(
17146            NodeFailureCode::InvalidRequest,
17147            &format!(
17148                "terminal byte sequence must contain 1..={MAX_NODE_TERMINAL_BYTES} bytes; received {}",
17149                bytes.len(),
17150            ),
17151        ));
17152    }
17153    Ok(())
17154}
17155
17156fn terminal_bytes_action(bytes: Vec<u8>) -> Result<InputAction, NodeFailure> {
17157    validate_terminal_bytes(&bytes)?;
17158    Ok(InputAction::TerminalBytes(bytes))
17159}
17160
17161fn validate_workspace_request_root(root: &str) -> Result<(), NodeFailure> {
17162    if root.is_empty()
17163        || root.len() > MAX_WORKSPACE_ROOT_BYTES
17164        || root.chars().any(char::is_control)
17165    {
17166        return Err(failure(
17167            NodeFailureCode::InvalidWorkspaceRoot,
17168            &format!(
17169                "workspace root must contain 1..={MAX_WORKSPACE_ROOT_BYTES} bytes and no control characters",
17170            ),
17171        ));
17172    }
17173    Ok(())
17174}
17175
17176fn canonical_standalone_workspace_candidate(
17177    workspace_id: &WorkspaceId,
17178    root: &str,
17179) -> Result<String, NodeServerError> {
17180    let path = Path::new(root);
17181    match std::fs::symlink_metadata(path) {
17182        Ok(_) => WorkspaceConfig::new(workspace_id.clone(), path)
17183            .map(|workspace| workspace.canonical_root),
17184        Err(error) if error.kind() == io::ErrorKind::NotFound => {
17185            if !path.is_absolute() {
17186                return Err(NodeServerError::InvalidWorkspaceRoot {
17187                    workspace_id: workspace_id.clone(),
17188                    path: root.to_owned(),
17189                    message: "path is not absolute".to_owned(),
17190                });
17191            }
17192            let name = path.file_name().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
17193                workspace_id: workspace_id.clone(),
17194                path: root.to_owned(),
17195                message: "path has no standalone directory name".to_owned(),
17196            })?;
17197            let parent = path.parent().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
17198                workspace_id: workspace_id.clone(),
17199                path: root.to_owned(),
17200                message: "path has no parent directory".to_owned(),
17201            })?;
17202            let canonical = std::fs::canonicalize(parent)
17203                .map(|parent| parent.join(name))
17204                .map_err(|error| NodeServerError::InvalidWorkspaceRoot {
17205                    workspace_id: workspace_id.clone(),
17206                    path: root.to_owned(),
17207                    message: error.to_string(),
17208                })?;
17209            let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
17210                NodeServerError::InvalidWorkspaceRoot {
17211                    workspace_id: workspace_id.clone(),
17212                    path: path.to_string_lossy().into_owned(),
17213                    message: "canonical path is not valid Unicode".to_owned(),
17214                }
17215            })?;
17216            let canonical_root = platform::normalize_canonical_root(canonical_root);
17217            validate_workspace_root(workspace_id, &canonical_root)?;
17218            Ok(canonical_root)
17219        }
17220        Err(error) => Err(NodeServerError::InvalidWorkspaceRoot {
17221            workspace_id: workspace_id.clone(),
17222            path: root.to_owned(),
17223            message: error.to_string(),
17224        }),
17225    }
17226}
17227
17228fn validate_git_revision(field: &str, revision: Option<&str>) -> Result<(), NodeFailure> {
17229    let Some(revision) = revision else {
17230        return Ok(());
17231    };
17232    if revision.is_empty()
17233        || revision.len() > MAX_REPOSITORY_PATH_BYTES
17234        || revision.chars().any(char::is_control)
17235    {
17236        return Err(failure(
17237            NodeFailureCode::InvalidRequest,
17238            &format!(
17239                "{field} must contain 1..={MAX_REPOSITORY_PATH_BYTES} bytes and no control characters",
17240            ),
17241        ));
17242    }
17243    Ok(())
17244}
17245
17246fn git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
17247    let code = match error.kind {
17248        GitWorktreeErrorKind::Invalid => NodeFailureCode::InvalidRequest,
17249        GitWorktreeErrorKind::NotRepository => NodeFailureCode::NotGitRepository,
17250        GitWorktreeErrorKind::Conflict => NodeFailureCode::WorktreeConflict,
17251        GitWorktreeErrorKind::Protected => NodeFailureCode::WorktreeProtected,
17252        GitWorktreeErrorKind::Dirty => NodeFailureCode::WorktreeDirty,
17253        GitWorktreeErrorKind::Locked => NodeFailureCode::WorktreeLocked,
17254        GitWorktreeErrorKind::Failed => NodeFailureCode::BackendOperationFailed,
17255    };
17256    failure(code, &error.message)
17257}
17258
17259fn standalone_workspace_failure(error: StandaloneWorkspaceError) -> NodeFailure {
17260    let code = match error.kind {
17261        StandaloneWorkspaceErrorKind::InvalidRoot => NodeFailureCode::InvalidWorkspaceRoot,
17262        StandaloneWorkspaceErrorKind::InvalidInitialBranch => NodeFailureCode::InvalidRequest,
17263        StandaloneWorkspaceErrorKind::GitInitializationFailed => {
17264            NodeFailureCode::BackendOperationFailed
17265        }
17266        StandaloneWorkspaceErrorKind::RecoveryRequired => {
17267            NodeFailureCode::StandaloneWorkspaceRecoveryRequired
17268        }
17269    };
17270    failure(code, error.message)
17271}
17272
17273fn managed_git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
17274    let (code, message) = match error.kind {
17275        GitWorktreeErrorKind::Invalid => (
17276            NodeFailureCode::InvalidRequest,
17277            "managed Git input is invalid",
17278        ),
17279        GitWorktreeErrorKind::NotRepository => (
17280            NodeFailureCode::NotGitRepository,
17281            "managed source is not a Git repository",
17282        ),
17283        GitWorktreeErrorKind::Conflict => (
17284            NodeFailureCode::WorktreeConflict,
17285            "managed Git identity conflicts with existing repository state",
17286        ),
17287        GitWorktreeErrorKind::Protected => (
17288            NodeFailureCode::ManagedWorktreeOwnershipConflict,
17289            "managed worktree ownership validation failed",
17290        ),
17291        GitWorktreeErrorKind::Dirty => (
17292            NodeFailureCode::WorktreeDirty,
17293            "managed worktree contains uncommitted changes",
17294        ),
17295        GitWorktreeErrorKind::Locked => (
17296            NodeFailureCode::WorktreeLocked,
17297            "managed worktree is locked",
17298        ),
17299        GitWorktreeErrorKind::Failed => (
17300            NodeFailureCode::BackendOperationFailed,
17301            "managed Git operation failed",
17302        ),
17303    };
17304    NodeFailure { code, message: message.to_owned() }
17305}
17306
17307fn managed_spawn_receipt(
17308    resolved: &ResolvedSpawnSpec,
17309    incarnation_id: NodeIncarnationId,
17310    session: SessionAddress,
17311    lease: ManagedWorktreeLeaseSnapshot,
17312    environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
17313    bundle: Option<ResolvedBundleReceipt>,
17314    context: Option<ResolvedContextPackReceipt>,
17315) -> ManagedWorktreeSpawnReceipt {
17316    let mut managed = resolved.clone();
17317    managed.target.worktree_id = Some(lease.workspace_id.clone());
17318    ManagedWorktreeSpawnReceipt {
17319        spawn: managed.receipt_with_materialization(
17320            incarnation_id,
17321            session,
17322            environment_profile,
17323            bundle,
17324            context,
17325        ),
17326        lease,
17327    }
17328}
17329
17330fn validate_selected_worktree(
17331    source_root: &str,
17332    selected_root: &str,
17333    worktrees: &[NativeGitWorktreeSnapshot],
17334) -> Result<(), NodeFailure> {
17335    if !worktrees
17336        .iter()
17337        .any(|worktree| worktree_paths_equal(&worktree.path, source_root) && !worktree.is_bare)
17338    {
17339        return Err(failure(
17340            NodeFailureCode::WorktreeProtected,
17341            "source workspace is not an authoritative non-bare Git worktree root",
17342        ));
17343    }
17344    if !worktrees.iter().any(|worktree| {
17345        worktree_paths_equal(&worktree.path, selected_root)
17346            && !worktree.is_main
17347            && !worktree.is_bare
17348    }) {
17349        return Err(failure(
17350            NodeFailureCode::WorktreeProtected,
17351            "selected workspace is not an eligible Git worktree in the source repository",
17352        ));
17353    }
17354    Ok(())
17355}
17356
17357fn workspace_file_failure(error: WorkspaceFileReadError) -> NodeFailure {
17358    let code = match error.kind() {
17359        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17360        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17361            NodeFailureCode::RepositoryPathUnsafe
17362        }
17363        WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17364        WorkspaceFileReadErrorKind::NotRegularFile => {
17365            NodeFailureCode::RepositoryFileNotRegular
17366        }
17367        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17368            NodeFailureCode::RepositoryFileReadFailed
17369        }
17370        WorkspaceFileReadErrorKind::RevisionConflict => {
17371            NodeFailureCode::RepositoryFileRevisionConflict
17372        }
17373        WorkspaceFileReadErrorKind::AlreadyExists
17374        | WorkspaceFileReadErrorKind::ParentNotFound
17375        | WorkspaceFileReadErrorKind::ParentNotDirectory
17376        | WorkspaceFileReadErrorKind::Canceled => {
17377            NodeFailureCode::RepositoryFileReadFailed
17378        }
17379    };
17380    failure(code, "repository file read failed")
17381}
17382
17383fn workspace_file_write_failure(error: WorkspaceFileReadError) -> NodeFailure {
17384    let code = match error.kind() {
17385        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17386        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17387            NodeFailureCode::RepositoryPathUnsafe
17388        }
17389        WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17390        WorkspaceFileReadErrorKind::NotRegularFile => {
17391            NodeFailureCode::RepositoryFileNotRegular
17392        }
17393        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17394            NodeFailureCode::RepositoryFileWriteFailed
17395        }
17396        WorkspaceFileReadErrorKind::RevisionConflict => {
17397            NodeFailureCode::RepositoryFileRevisionConflict
17398        }
17399        WorkspaceFileReadErrorKind::AlreadyExists
17400        | WorkspaceFileReadErrorKind::ParentNotFound
17401        | WorkspaceFileReadErrorKind::ParentNotDirectory
17402        | WorkspaceFileReadErrorKind::Canceled => {
17403            NodeFailureCode::RepositoryFileWriteFailed
17404        }
17405    };
17406    failure(code, "repository file write failed")
17407}
17408
17409async fn settle_workspace_entry_create<T>(
17410    mut task: tokio::task::JoinHandle<Result<T, WorkspaceFileReadError>>,
17411    commit_state: Arc<AtomicU8>,
17412    deadline: Duration,
17413    timeout_message: &'static str,
17414    task_failure_message: &'static str,
17415) -> Result<T, NodeFailure> {
17416    let joined = match timeout(deadline, &mut task).await {
17417        Ok(joined) => joined,
17418        Err(_) => {
17419            if commit_state
17420                .compare_exchange(
17421                    WORKSPACE_ENTRY_CREATE_PENDING,
17422                    WORKSPACE_ENTRY_CREATE_CANCELED,
17423                    Ordering::AcqRel,
17424                    Ordering::Acquire,
17425                )
17426                .is_ok()
17427            {
17428                return Err(failure(
17429                    NodeFailureCode::RepositoryEntryCreateTimedOut,
17430                    timeout_message,
17431                ));
17432            }
17433            debug_assert_eq!(
17434                commit_state.load(Ordering::Acquire),
17435                WORKSPACE_ENTRY_CREATE_COMMITTING,
17436            );
17437            task.await
17438        }
17439    };
17440    joined
17441        .map_err(|_| {
17442            failure(
17443                NodeFailureCode::RepositoryEntryCreateFailed,
17444                task_failure_message,
17445            )
17446        })?
17447        .map_err(workspace_entry_create_failure)
17448}
17449
17450fn workspace_entry_create_failure(error: WorkspaceFileReadError) -> NodeFailure {
17451    let code = match error.kind() {
17452        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17453        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17454            NodeFailureCode::RepositoryPathUnsafe
17455        }
17456        WorkspaceFileReadErrorKind::AlreadyExists => {
17457            NodeFailureCode::RepositoryEntryAlreadyExists
17458        }
17459        WorkspaceFileReadErrorKind::ParentNotFound | WorkspaceFileReadErrorKind::NotFound => {
17460            NodeFailureCode::RepositoryParentNotFound
17461        }
17462        WorkspaceFileReadErrorKind::ParentNotDirectory
17463        | WorkspaceFileReadErrorKind::NotRegularFile => {
17464            NodeFailureCode::RepositoryParentNotDirectory
17465        }
17466        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17467            NodeFailureCode::RepositoryEntryCreateFailed
17468        }
17469        WorkspaceFileReadErrorKind::RevisionConflict => {
17470            NodeFailureCode::RepositoryEntryCreateFailed
17471        }
17472        WorkspaceFileReadErrorKind::Canceled => {
17473            NodeFailureCode::RepositoryEntryCreateTimedOut
17474        }
17475    };
17476    failure(code, "repository entry create failed")
17477}
17478
17479fn workspace_file_revision(bytes: &[u8]) -> WorkspaceFileRevision {
17480    let value = digest(&SHA256, bytes)
17481        .as_ref()
17482        .iter()
17483        .map(|byte| format!("{byte:02x}"))
17484        .collect();
17485    WorkspaceFileRevision::new(value).expect("SHA-256 produces a valid workspace revision")
17486}
17487
17488fn managed_spawn_request_digest_v2(
17489    request: &ManagedWorktreeSpawnRequestV2,
17490) -> Result<String, NodeFailure> {
17491    let encoded = serde_json::to_vec(request).map_err(|_| {
17492        failure(
17493            NodeFailureCode::InvalidRequest,
17494            "managed worktree V2 request could not be canonicalized",
17495        )
17496    })?;
17497    let mut value = String::with_capacity(71);
17498    value.push_str("sha256:");
17499    for byte in digest(&SHA256, &encoded).as_ref() {
17500        use std::fmt::Write as _;
17501        write!(&mut value, "{byte:02x}").expect("writing to a String cannot fail");
17502    }
17503    Ok(value)
17504}
17505
17506fn native_session_preview_failure(error: NativeSessionPreviewError) -> NodeFailure {
17507    let code = match error {
17508        NativeSessionPreviewError::InvalidLimit => NodeFailureCode::InvalidRequest,
17509        NativeSessionPreviewError::WorkspaceUnavailable => NodeFailureCode::UnknownWorkspace,
17510        NativeSessionPreviewError::StaleCatalog => NodeFailureCode::StaleNativeSessionCatalog,
17511        NativeSessionPreviewError::UnsupportedProvider
17512        | NativeSessionPreviewError::PreviewUnavailable
17513        | NativeSessionPreviewError::SessionNotFound
17514        | NativeSessionPreviewError::AmbiguousSession => {
17515            NodeFailureCode::BackendOperationFailed
17516        }
17517    };
17518    failure(code, "native session preview failed")
17519}
17520
17521fn host_directory_failure(error: HostDirectoryBrowseError) -> NodeFailure {
17522    let code = match error.kind() {
17523        HostDirectoryBrowseErrorKind::Invalid => NodeFailureCode::HostDirectoryInvalid,
17524        HostDirectoryBrowseErrorKind::ReadFailed => NodeFailureCode::HostDirectoryReadFailed,
17525    };
17526    failure(code, "host directory browse failed")
17527}
17528
17529fn delivery_failure(error: DeliveryStoreError) -> NodeFailure {
17530    let code = match error {
17531        DeliveryStoreError::InvalidManifest => NodeFailureCode::DeliveryManifestInvalid,
17532        DeliveryStoreError::UnknownStage => NodeFailureCode::UnknownDeliveryStage,
17533        DeliveryStoreError::Capacity | DeliveryStoreError::StageConflict => {
17534            NodeFailureCode::DeliveryStageConflict
17535        }
17536        DeliveryStoreError::UnexpectedBlob => NodeFailureCode::DeliveryBlobUnexpected,
17537        DeliveryStoreError::ChunkOutOfOrder | DeliveryStoreError::ChunkOverflow => {
17538            NodeFailureCode::DeliveryChunkOutOfOrder
17539        }
17540        DeliveryStoreError::BlobDigestMismatch => {
17541            NodeFailureCode::DeliveryBlobDigestMismatch
17542        }
17543        DeliveryStoreError::BundleDigestMismatch => {
17544            NodeFailureCode::DeliveryBundleDigestMismatch
17545        }
17546        DeliveryStoreError::StageIncomplete => NodeFailureCode::DeliveryStageIncomplete,
17547        DeliveryStoreError::Unavailable
17548        | DeliveryStoreError::Corrupt
17549        | DeliveryStoreError::Storage(_) => NodeFailureCode::DeliveryStageStorageFailed,
17550    };
17551    failure(code, "delivery operation failed")
17552}
17553
17554fn validate_workspace_root(
17555    workspace_id: &WorkspaceId,
17556    canonical_root: &str,
17557) -> Result<(), NodeServerError> {
17558    #[cfg(windows)]
17559    if canonical_root.starts_with(r"\\") {
17560        return Err(NodeServerError::InvalidWorkspaceRoot {
17561            workspace_id: workspace_id.clone(),
17562            path: canonical_root.to_owned(),
17563            message: "UNC workspace roots are unsupported by Windows PTY providers".to_owned(),
17564        });
17565    }
17566    if !platform::workspace_root_supported(canonical_root)
17567        || canonical_root.len() > WORKING_DIRECTORY_MAX_BYTES
17568    {
17569        return Err(NodeServerError::InvalidWorkspaceRoot {
17570            workspace_id: workspace_id.clone(),
17571            path: canonical_root.to_owned(),
17572            message: "canonical path must be absolute, bounded, and free of control characters"
17573                .to_owned(),
17574        });
17575    }
17576    Ok(())
17577}
17578
17579fn failure(code: NodeFailureCode, _message: &str) -> NodeFailure {
17580    NodeFailure {
17581        code,
17582        message: node_failure_category(code).to_owned(),
17583    }
17584}
17585
17586fn harness_mcp_failure(error: HarnessMcpProxyError) -> NodeFailure {
17587    let code = match error {
17588        HarnessMcpProxyError::Unavailable => NodeFailureCode::HarnessMcpUnavailable,
17589        HarnessMcpProxyError::NotFound => NodeFailureCode::ReservationNotFound,
17590        HarnessMcpProxyError::Conflict => NodeFailureCode::ReservationConflict,
17591        HarnessMcpProxyError::Expired => NodeFailureCode::ReservationExpired,
17592        HarnessMcpProxyError::BindingMismatch => NodeFailureCode::BindingMismatch,
17593        HarnessMcpProxyError::NotActivated => NodeFailureCode::NotActivated,
17594        HarnessMcpProxyError::CallNotFound => NodeFailureCode::CallNotFound,
17595        HarnessMcpProxyError::ChunkOutOfOrder => NodeFailureCode::ChunkOutOfOrder,
17596        HarnessMcpProxyError::ResponseTooLarge => NodeFailureCode::ResponseTooLarge,
17597    };
17598    failure(code, "harness MCP proxy operation failed")
17599}
17600
17601/// Numbers each harness-MCP overlay this process constructs, in
17602/// construction order, so two overlays built back to back in one node process
17603/// still get distinct trace files.
17604static NEXT_HARNESS_MCP_TRACE_SEQUENCE: AtomicU64 = AtomicU64::new(1);
17605
17606/// Builds the optional trace environment entry of one harness-MCP overlay.
17607/// The caller's launch description names both the entry and the node-process
17608/// variable that opts in: when that variable holds a non-empty directory, the
17609/// entry names a trace file under it. Returns `None` when the launch has no
17610/// trace, or the directory is unset, empty, or not valid Unicode. Never creates
17611/// the directory; that is the operator's job.
17612fn harness_mcp_trace_env_entry(launch: &HarnessMcpLaunchV1) -> Option<(OsString, OsString)> {
17613    let trace = launch.trace.as_ref()?;
17614    let trace_dir = std::env::var(&trace.dir_env).ok()?;
17615    if trace_dir.is_empty() {
17616        return None;
17617    }
17618    let sequence = NEXT_HARNESS_MCP_TRACE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
17619    let path = std::path::Path::new(&trace_dir).join(format!("harness-mcp-{sequence}.trace"));
17620    Some((OsString::from(trace.env.as_str()), path.into_os_string()))
17621}
17622
17623fn spawn_deadline_remaining(deadline: Instant) -> Result<Duration, NodeFailure> {
17624    let remaining = deadline.saturating_duration_since(Instant::now());
17625    if remaining.is_zero() {
17626        Err(failure(
17627            NodeFailureCode::SpawnDeadlineExceeded,
17628            "spawn deadline elapsed",
17629        ))
17630    } else {
17631        Ok(remaining)
17632    }
17633}
17634
17635fn spawn_dispatch_timeout(deadline: Option<Instant>) -> Result<Duration, NodeFailure> {
17636    let bounded = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
17637    match deadline {
17638        Some(deadline) => Ok(spawn_deadline_remaining(deadline)?.min(bounded)),
17639        None => Ok(bounded),
17640    }
17641}
17642
17643fn spawn_dispatch_error(error: NodeFailure, deadline: Option<Instant>) -> NodeFailure {
17644    if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
17645        failure(
17646            NodeFailureCode::SpawnDeadlineExceeded,
17647            "spawn dispatch exceeded the spawn deadline",
17648        )
17649    } else {
17650        error
17651    }
17652}
17653
17654fn spawn_runtime_capabilities(
17655    capabilities: &SpawnRequiredCapabilities,
17656) -> Result<Vec<ProviderRuntimeCapability>, NodeFailure> {
17657    capabilities
17658        .iter()
17659        .map(|capability| match capability.as_str() {
17660            SPAWN_RUNTIME_RAW_PTY_LIFECYCLE => Ok(ProviderRuntimeCapability::RawPtyLifecycle),
17661            SPAWN_RUNTIME_SEMANTIC_READINESS => Ok(ProviderRuntimeCapability::SemanticReadiness),
17662            SPAWN_RUNTIME_STRUCTURED_PROMPT => Ok(ProviderRuntimeCapability::StructuredPrompt),
17663            SPAWN_RUNTIME_PROVIDER_SESSION_IDENTITY => {
17664                Ok(ProviderRuntimeCapability::ProviderSessionIdentity)
17665            }
17666            SPAWN_RUNTIME_SEMANTIC_RESUME => Ok(ProviderRuntimeCapability::SemanticResume),
17667            _ => Err(failure(
17668                NodeFailureCode::UnsupportedSpawnCapability,
17669                "spawn required capability is unknown",
17670            )),
17671        })
17672        .collect()
17673}
17674
17675fn persistence_failure(_error: io::Error) -> NodeFailure {
17676    NodeFailure {
17677        code: NodeFailureCode::BackendOperationFailed,
17678        message: DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned(),
17679    }
17680}
17681
17682fn node_failure_category(code: NodeFailureCode) -> &'static str {
17683    match code {
17684        NodeFailureCode::InvalidRequest => "invalid-request",
17685        NodeFailureCode::UnsupportedCapability => "unsupported-capability",
17686        NodeFailureCode::HarnessMcpUnavailable => "harness-mcp-unavailable",
17687        NodeFailureCode::ReservationNotFound => "reservation-not-found",
17688        NodeFailureCode::ReservationConflict => "reservation-conflict",
17689        NodeFailureCode::ReservationExpired => "reservation-expired",
17690        NodeFailureCode::BindingMismatch => "binding-mismatch",
17691        NodeFailureCode::NotActivated => "not-activated",
17692        NodeFailureCode::CallNotFound => "call-not-found",
17693        NodeFailureCode::ChunkOutOfOrder => "chunk-out-of-order",
17694        NodeFailureCode::ResponseTooLarge => "response-too-large",
17695        NodeFailureCode::DeliveryManifestInvalid => "delivery-manifest-invalid",
17696        NodeFailureCode::UnknownDeliveryStage => "unknown-delivery-stage",
17697        NodeFailureCode::DeliveryStageConflict => "delivery-stage-conflict",
17698        NodeFailureCode::DeliveryBlobUnexpected => "delivery-blob-unexpected",
17699        NodeFailureCode::DeliveryChunkOutOfOrder => "delivery-chunk-out-of-order",
17700        NodeFailureCode::DeliveryBlobDigestMismatch => "delivery-blob-digest-mismatch",
17701        NodeFailureCode::DeliveryBundleDigestMismatch => "delivery-bundle-digest-mismatch",
17702        NodeFailureCode::DeliveryStageIncomplete => "delivery-stage-incomplete",
17703        NodeFailureCode::DeliveryStageStorageFailed => "delivery-stage-storage-failed",
17704        NodeFailureCode::Unauthorized => "unauthorized",
17705        NodeFailureCode::ObserverReadOnly => "observer-read-only",
17706        NodeFailureCode::ControllerBusy => "controller-busy",
17707        NodeFailureCode::ControllerRequired => "controller-required",
17708        NodeFailureCode::UnknownWorkspace => "unknown-workspace",
17709        NodeFailureCode::HostDirectoryInvalid => "host-directory-invalid",
17710        NodeFailureCode::HostDirectoryReadFailed => "host-directory-read-failed",
17711        NodeFailureCode::HostDirectoryReadTimedOut => "host-directory-read-timed-out",
17712        NodeFailureCode::InvalidRepositoryPath => "invalid-repository-path",
17713        NodeFailureCode::RepositoryFileNotFound => "repository-file-not-found",
17714        NodeFailureCode::RepositoryFileNotRegular => "repository-file-not-regular",
17715        NodeFailureCode::RepositoryPathUnsafe => "repository-path-unsafe",
17716        NodeFailureCode::RepositoryFileReadTimedOut => "repository-file-read-timed-out",
17717        NodeFailureCode::RepositoryFileReadFailed => "repository-file-read-failed",
17718        NodeFailureCode::RepositoryFileWriteTimedOut => "repository-file-write-timed-out",
17719        NodeFailureCode::RepositoryFileWriteFailed => "repository-file-write-failed",
17720        NodeFailureCode::RepositoryFileRevisionConflict => "repository-file-revision-conflict",
17721        NodeFailureCode::RepositoryEntryAlreadyExists => "repository-entry-already-exists",
17722        NodeFailureCode::RepositoryParentNotFound => "repository-parent-not-found",
17723        NodeFailureCode::RepositoryParentNotDirectory => "repository-parent-not-directory",
17724        NodeFailureCode::RepositoryEntryCreateTimedOut => "repository-entry-create-timed-out",
17725        NodeFailureCode::RepositoryEntryCreateFailed => "repository-entry-create-failed",
17726        NodeFailureCode::GitReadTimedOut => "git-read-timed-out",
17727        NodeFailureCode::GitReadFailed => "git-read-failed",
17728        NodeFailureCode::UnknownSpawnProfile => "unknown-spawn-profile",
17729        NodeFailureCode::SpawnProfileRevisionMismatch => "spawn-profile-revision-mismatch",
17730        NodeFailureCode::SpawnTargetMismatch => "spawn-target-mismatch",
17731        NodeFailureCode::SpawnIdempotencyConflict => "spawn-idempotency-conflict",
17732        NodeFailureCode::SpawnIdempotencyCapacity => "spawn-idempotency-capacity",
17733        NodeFailureCode::SpawnDeadlineExceeded => "spawn-deadline-exceeded",
17734        NodeFailureCode::UnsupportedSpawnCapability => "unsupported-spawn-capability",
17735        NodeFailureCode::UnsupportedTransport => "unsupported-transport",
17736        NodeFailureCode::TurnInFlight => "turn-in-flight",
17737        NodeFailureCode::UnknownEnvironmentProfile => "unknown-environment-profile",
17738        NodeFailureCode::UnknownNetworkAllowlist => "unknown-network-allowlist",
17739        NodeFailureCode::UnsupportedNetworkAllowlistMapping => {
17740            "unsupported-network-allowlist-mapping"
17741        }
17742        NodeFailureCode::BrowserStationProbeUnavailable => {
17743            "browser-station-probe-unavailable"
17744        }
17745        NodeFailureCode::BrowserStationUnreachable => "browser-station-unreachable",
17746        NodeFailureCode::BrowserStationProfileBusy => "browser-station-profile-busy",
17747        NodeFailureCode::EnvironmentProfileBindingMismatch => {
17748            "environment-profile-binding-mismatch"
17749        }
17750        NodeFailureCode::UnknownBundle => "unknown-bundle",
17751        NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
17752        NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
17753        NodeFailureCode::UnknownContextPack => "unknown-context-pack",
17754        NodeFailureCode::ContextPackBusy => "context-pack-busy",
17755        NodeFailureCode::ContextPackMaterializationFailed => {
17756            "context-pack-materialization-failed"
17757        }
17758        NodeFailureCode::InvalidWorkspaceRoot => "invalid-workspace-root",
17759        NodeFailureCode::DuplicateWorkspaceId => "duplicate-workspace-id",
17760        NodeFailureCode::DuplicateWorkspaceRoot => "duplicate-workspace-root",
17761        NodeFailureCode::WorkspaceBusy => "workspace-busy",
17762        NodeFailureCode::LastWorkspace => "last-workspace",
17763        NodeFailureCode::NotGitRepository => "not-git-repository",
17764        NodeFailureCode::WorktreeConflict => "worktree-conflict",
17765        NodeFailureCode::WorktreeProtected => "worktree-protected",
17766        NodeFailureCode::WorktreeDirty => "worktree-dirty",
17767        NodeFailureCode::WorktreeLocked => "worktree-locked",
17768        NodeFailureCode::UnknownManagedWorktreeLease => "unknown-managed-worktree-lease",
17769        NodeFailureCode::ManagedWorktreeBusy => "managed-worktree-busy",
17770        NodeFailureCode::ManagedWorktreeOwnershipConflict => "managed-worktree-ownership-conflict",
17771        NodeFailureCode::ManagedWorktreeProfileRevisionMismatch => {
17772            "managed-worktree-profile-revision-mismatch"
17773        }
17774        NodeFailureCode::ManagedWorktreeRecoveryRequired => "managed-worktree-recovery-required",
17775        NodeFailureCode::StandaloneWorkspaceRecoveryRequired => {
17776            "standalone-workspace-recovery-required"
17777        }
17778        NodeFailureCode::UnknownSession => "unknown-session",
17779        NodeFailureCode::UnknownSessionRecord => "unknown-session-record",
17780        NodeFailureCode::SessionRecordNotResumable => "session-record-not-resumable",
17781        NodeFailureCode::SessionRecordBusy => "session-record-busy",
17782        NodeFailureCode::SessionRecordConflict => "session-record-conflict",
17783        NodeFailureCode::SessionWorkspaceMismatch => "session-workspace-mismatch",
17784        NodeFailureCode::WorkspaceRegistrationRequired => {
17785            "workspace-registration-required"
17786        }
17787        NodeFailureCode::StaleNativeSessionCatalog => "stale-native-session-catalog",
17788        NodeFailureCode::StaleGeneration => "stale-generation",
17789        NodeFailureCode::BackendBusy => "backend-busy",
17790        NodeFailureCode::BackendDisconnected => "backend-disconnected",
17791        NodeFailureCode::BackendOperationFailed => "backend-operation-failed",
17792        NodeFailureCode::ShuttingDown => "shutting-down",
17793    }
17794}
17795
17796fn durable_state_server_error(error: io::Error, category: &'static str) -> NodeServerError {
17797    NodeServerError::DurableState(io::Error::new(error.kind(), category))
17798}
17799
17800fn durable_state_load_error(error: io::Error) -> NodeServerError {
17801    let category = match session_registry::state_load_refusal(&error) {
17802        Some(session_registry::StateLoadRefusal::UnsupportedSchema) => {
17803            DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR
17804        }
17805        Some(session_registry::StateLoadRefusal::PathSemanticsUnsupported) => {
17806            DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR
17807        }
17808        Some(session_registry::StateLoadRefusal::NodeIdentityMismatch) => {
17809            DURABLE_STATE_CONFLICT_ERROR
17810        }
17811        _ => DURABLE_STATE_LOAD_FAILED_ERROR,
17812    };
17813    durable_state_server_error(error, category)
17814}
17815
17816fn unix_time_ms() -> u64 {
17817    std::time::SystemTime::now()
17818        .duration_since(std::time::UNIX_EPOCH)
17819        .unwrap_or_default()
17820        .as_millis()
17821        .min(u64::MAX as u128) as u64
17822}
17823
17824fn detached_record_state(record: &ManagedSessionRecord) -> ManagedSessionState {
17825    if record.provider_session.is_some() {
17826        ManagedSessionState::Dormant
17827    } else {
17828        ManagedSessionState::Unavailable
17829    }
17830}
17831
17832fn active_record_state(
17833    identity_admitted: bool,
17834    provider_session_is_known: bool,
17835) -> ManagedSessionState {
17836    if identity_admitted && !provider_session_is_known {
17837        ManagedSessionState::IdentityPending
17838    } else {
17839        ManagedSessionState::Live
17840    }
17841}
17842
17843fn managed_worktree_record_holders(
17844    records: &[ManagedSessionRecord],
17845) -> Vec<(SessionRecordId, WorkspaceId)> {
17846    records
17847        .iter()
17848        .filter(|record| record.provider_session.is_some())
17849        .map(|record| (record.record_id.clone(), record.workspace_id.clone()))
17850        .collect()
17851}
17852
17853fn managed_spawn_record_ownership(
17854    binding: &SessionBinding,
17855    effective_runtime_policy: ProviderRuntimePolicy,
17856) -> (Option<SessionRecordId>, Option<SessionRecordId>) {
17857    debug_assert_eq!(binding.runtime_policy, effective_runtime_policy);
17858    if effective_runtime_policy.provider_session_identity {
17859        (binding.record_id.clone(), None)
17860    } else {
17861        (None, binding.record_id.clone())
17862    }
17863}
17864
17865#[derive(Debug, Error)]
17866pub enum NodeServerError {
17867    #[error("node endpoint must be a bounded absolute local transport path")]
17868    InvalidEndpoint,
17869    #[error("node access token must contain 1..=4096 bytes")]
17870    InvalidAccessToken,
17871    #[error("node HTTP observer must listen on a loopback address: {0}")]
17872    InvalidApiListen(std::net::SocketAddr),
17873    #[error("node browser bridge must listen on a loopback address: {0}")]
17874    InvalidBridgeListen(std::net::SocketAddr),
17875    #[error("node browser bridge token must contain 1..=4096 bytes")]
17876    InvalidBridgeToken,
17877    #[error("bridge underlay requires --bridge-listen (local HTTP+WS door)")]
17878    BridgeUnderlayRequiresBridgeListen,
17879    #[error("bridge underlay requires GATE4AGENT_BRIDGE_TOKEN (application barrier)")]
17880    BridgeUnderlayRequiresBridgeToken,
17881    #[error("mesh underlay auth token must contain 1..=4096 bytes")]
17882    InvalidBridgeUnderlayToken,
17883    #[error("mesh underlay transport key must be 32 bytes (64 hex digits)")]
17884    InvalidBridgeUnderlayKey,
17885    #[error("node call-home relay must be loopback or a concrete C2 tunnel address with a nonzero port: {0}")]
17886    InvalidCallHome(std::net::SocketAddr),
17887    #[error("node could not reach its call-home relay: {0}")]
17888    CallHomeTransport(#[source] std::io::Error),
17889    #[error("node durable state path must be an absolute file path: {0}")]
17890    InvalidStatePath(String),
17891    #[error("harness MCP helper must be an exact reviewed absolute regular file")]
17892    InvalidHarnessMcpHelper,
17893    #[error("network allowlist catalog path must be an absolute regular file: {0}")]
17894    InvalidNetworkAllowlistCatalogPath(String),
17895    #[error("network allowlist catalog could not be read: {0}")]
17896    NetworkAllowlistCatalogIo(#[source] std::io::Error),
17897    #[error("network allowlist catalog line {line}: {message}")]
17898    InvalidNetworkAllowlistCatalogEntry { line: usize, message: String },
17899    #[error("network allowlist catalog: {0}")]
17900    InvalidNetworkAllowlistCatalog(String),
17901    #[error("network allowlist catalog exceeds the {max}-entry limit")]
17902    NetworkAllowlistCatalogCapacity { max: usize },
17903    #[error("the local state directory is unavailable; supply an explicit state path")]
17904    LocalStateDirectoryUnavailable,
17905    #[error("the local runtime directory is unavailable; supply an explicit endpoint")]
17906    LocalRuntimeDirectoryUnavailable,
17907    #[error("node durable state failed: {0}")]
17908    DurableState(io::Error),
17909    #[error("node requires at least one configured workspace")]
17910    NoWorkspaces,
17911    #[error("workspace '{workspace_id}' root '{path}' is invalid: {message}")]
17912    InvalidWorkspaceRoot {
17913        workspace_id: WorkspaceId,
17914        path: String,
17915        message: String,
17916    },
17917    #[error("duplicate workspace ID: {0}")]
17918    DuplicateWorkspaceId(WorkspaceId),
17919    #[error("workspaces '{first}' and '{second}' resolve to the same root '{root}'")]
17920    DuplicateWorkspaceRoot {
17921        first: WorkspaceId,
17922        second: WorkspaceId,
17923        root: String,
17924    },
17925    #[error("workspace '{0}' uses managed worktrees but has no local profile")]
17926    ManagedWorktreeProfileRequired(WorkspaceId),
17927    #[error("workspace '{0}' has managed worktree profiles but is not in managed mode")]
17928    ManagedWorktreeProfileModeMismatch(WorkspaceId),
17929    #[error("workspace '{workspace_id}' has duplicate managed worktree profile '{profile_id}'")]
17930    DuplicateManagedWorktreeProfile {
17931        workspace_id: WorkspaceId,
17932        profile_id: WorktreeProfileId,
17933    },
17934    #[error("workspace '{workspace_id}' exceeds the {max}-profile managed worktree limit")]
17935    ManagedWorktreeProfileCapacity {
17936        workspace_id: WorkspaceId,
17937        max: usize,
17938    },
17939    #[error("workspace '{workspace_id}' managed worktree profile '{profile_id}' is invalid: {message}")]
17940    InvalidManagedWorktreeProfile {
17941        workspace_id: WorkspaceId,
17942        profile_id: WorktreeProfileId,
17943        message: String,
17944    },
17945    #[error("duplicate node environment profile: {0}")]
17946    DuplicateEnvironmentProfile(SpawnEnvironmentProfileId),
17947    #[error("node environment profile provider is unavailable: {0}")]
17948    EnvironmentProfileProviderUnavailable(AgentId),
17949    #[error("node environment profile capacity is {max}")]
17950    EnvironmentProfileCapacity { max: usize },
17951    #[error("node environment profiles reuse native profile ID '{0}'")]
17952    DuplicateNativeEnvironmentProfile(String),
17953    #[error("node native environment profile is invalid: {0}")]
17954    NativeEnvironmentProfile(String),
17955    #[error("node session-environment materialization root must be absolute")]
17956    InvalidSessionEnvironmentRoot,
17957    #[error("node session-environment materializer is unavailable")]
17958    SessionEnvironmentMaterializerRequired,
17959    #[error("node session-environment materializer failed to initialize")]
17960    SessionEnvironmentMaterializer,
17961    #[error("node bundle catalog is invalid: {0}")]
17962    BundleCatalog(String),
17963    #[error("node delivery store failed to initialize: {0}")]
17964    DeliveryStore(String),
17965    #[error("node context pack store failed to initialize")]
17966    ContextPackStore,
17967    #[error("active agent registry failed: {0}")]
17968    Registry(String),
17969    #[error("node provider contract manifest is invalid: {0}")]
17970    ProviderContractManifest(String),
17971    #[error("named pipe I/O failed: {0}")]
17972    Io(#[from] io::Error),
17973    #[error("node HTTP observer failed: {0}")]
17974    HttpApi(io::Error),
17975    #[error("node browser bridge failed: {0}")]
17976    BridgeApi(io::Error),
17977    #[error(transparent)]
17978    Frame(#[from] FrameError),
17979    #[error("node handshake failed: {0}")]
17980    Handshake(String),
17981    #[error("node authentication frame was not received before the bounded deadline")]
17982    AuthenticationTimedOut,
17983    #[error("node authentication primitive failed: {0}")]
17984    Authentication(String),
17985    #[error("node incarnation identity generation failed: {0}")]
17986    IncarnationIdentity(String),
17987    #[error("node pre-authentication limiter is closed")]
17988    PreauthClosed,
17989    #[error("node authenticated connection limit was reached")]
17990    AuthenticatedConnectionLimit,
17991    #[error("node Ctrl+C/CtrlBreak signal stream closed")]
17992    SignalStreamClosed,
17993    #[error("node stop-all dispatch failed: {0:?}")]
17994    ShutdownDispatch(NodeFailure),
17995    #[error("node shutdown timed out with {active_native_sessions} physical sessions retained")]
17996    ShutdownTimedOut { active_native_sessions: usize },
17997}
17998
17999impl From<crate::network_allowlist_catalog::NetworkAllowlistCatalogError> for NodeServerError {
18000    fn from(error: crate::network_allowlist_catalog::NetworkAllowlistCatalogError) -> Self {
18001        use crate::network_allowlist_catalog::NetworkAllowlistCatalogError as E;
18002        match error {
18003            E::InvalidPath(path) => Self::InvalidNetworkAllowlistCatalogPath(path),
18004            E::Io(error) => Self::NetworkAllowlistCatalogIo(error),
18005            E::InvalidEntry { line, message } => {
18006                Self::InvalidNetworkAllowlistCatalogEntry { line, message }
18007            }
18008            E::InvalidCatalog(message) => Self::InvalidNetworkAllowlistCatalog(message),
18009            E::Capacity { max } => Self::NetworkAllowlistCatalogCapacity { max },
18010        }
18011    }
18012}
18013
18014#[cfg(test)]
18015mod agent_stream_blocked_tests {
18016    use super::*;
18017    use gate4agent_types::{
18018        AdapterId, AdapterVerification, HostRequestOutcome as ProviderHostRequestOutcome,
18019        ProviderSource,
18020    };
18021
18022    fn provider_source(family: AdapterFamily, adapter: &str) -> ProviderSource {
18023        ProviderSource {
18024            family,
18025            binding: AdapterBinding::new(
18026                AdapterId::new(adapter).unwrap(),
18027                "fixture/v1",
18028                AdapterVerification::SyntheticFixture,
18029            )
18030            .unwrap(),
18031        }
18032    }
18033
18034    fn provider_control_event_at(
18035        family: AdapterFamily,
18036        adapter: &str,
18037        source_sequence: u64,
18038        provider_event: ProviderEvent,
18039    ) -> ControlEvent {
18040        ControlEvent {
18041            sequence: 41,
18042            command_id: None,
18043            instance_id: AgentInstanceId(7),
18044            generation: SessionGeneration(3),
18045            event: ControlEventKind::ProviderEvent {
18046                sequence: 8,
18047                source: provider_source(family, adapter),
18048                source_sequence,
18049                event: provider_event,
18050            },
18051        }
18052    }
18053
18054    fn provider_control_event(family: AdapterFamily, provider_event: ProviderEvent) -> ControlEvent {
18055        provider_control_event_at(family, "fixture", 9, provider_event)
18056    }
18057
18058    fn denied_host_request(
18059        by: ProviderHostDecisionAuthority,
18060        reason: Option<String>,
18061    ) -> ControlEvent {
18062        provider_control_event_at(
18063            AdapterFamily::Acp,
18064            "claude-code",
18065            2,
18066            ProviderEvent::HostRequestObserved {
18067                method: "terminal/create".to_owned(),
18068                params_json: String::new(),
18069                decision: ProviderHostRequestDecision::Denied { by },
18070                outcome: ProviderHostRequestOutcome::Executed,
18071                reason,
18072            },
18073        )
18074    }
18075
18076    fn blocked(
18077        event: &ControlEvent,
18078    ) -> (
18079        Option<String>,
18080        String,
18081        BlockAuthorityV1,
18082        Option<String>,
18083        String,
18084        Option<String>,
18085    ) {
18086        let chunk = agent_stream_chunk(event).expect("expected a Blocked chunk");
18087        chunk.validate().expect("minted chunk must validate");
18088        let AgentStreamChunkKindV1::Blocked {
18089            correlation_id,
18090            tool_class,
18091            authority,
18092            reason_kind,
18093            reason,
18094            help,
18095        } = chunk.kind
18096        else {
18097            panic!("expected AgentStreamChunkKindV1::Blocked");
18098        };
18099        (correlation_id, tool_class, authority, reason_kind, reason, help)
18100    }
18101
18102    /// Pins the fleet and every adapter each member declares: the fleet is
18103    /// exactly what `active_registry` says, and no member declares a hook
18104    /// adapter (lifecycle hooks are retired).
18105    #[test]
18106    fn the_enabled_provider_matrix_is_exact() {
18107        let registry = active_registry().unwrap();
18108        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
18109        assert_eq!(
18110            providers
18111                .iter()
18112                .map(|contract| contract.provider.as_str())
18113                .collect::<Vec<_>>(),
18114            // `shell` is the plain PTY provider (operator terminals and smoke tests).
18115            vec!["shell", "claude", "codex", "grok", "kimi"],
18116        );
18117        assert!(adapters.iter().all(|contract| {
18118            !matches!(contract.family, AdapterFamily::Hook | AdapterFamily::ManagedHook)
18119        }));
18120    }
18121
18122    /// A gate-denied host request, WITH its reason text carried all the way
18123    /// from the ACP host, mints a `Blocked` chunk naming the gate.
18124    #[test]
18125    fn gate_denied_host_request_mints_a_blocked_chunk() {
18126        let gate_text =
18127            "blocked by dangerous-command gate: rule=filesystem-wipe, argument=rm -rf /";
18128        let event = denied_host_request(
18129            ProviderHostDecisionAuthority::Gate,
18130            Some(gate_text.to_owned()),
18131        );
18132        let chunk = agent_stream_chunk(&event).expect("expected a Blocked chunk");
18133        assert_eq!(chunk.source_sequence, 2);
18134        let (correlation_id, tool_class, authority, reason_kind, reason, help) = blocked(&event);
18135        assert_eq!(authority, BlockAuthorityV1::HostGate);
18136        assert_eq!(reason, gate_text);
18137        assert_eq!(correlation_id, None);
18138        assert_eq!(reason_kind, None);
18139        assert_eq!(help, None);
18140        assert_eq!(tool_class, "Write");
18141    }
18142
18143    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.";
18144
18145    /// The owner's classifier sample, verbatim, arriving as a
18146    /// `tool_call_update`-derived `ProviderEvent::ToolCompleted` with
18147    /// `non_execution_kind: "permission-rule"` -- mints a `Blocked` chunk with
18148    /// `authority: ProviderPermissionRule`, the `Reason:` sentence as `reason`
18149    /// and the guidance tail as `help`.
18150    #[test]
18151    fn classifier_sample_permission_rule_mints_a_blocked_chunk() {
18152        let event = provider_control_event(
18153            AdapterFamily::Acp,
18154            ProviderEvent::ToolCompleted {
18155                id: "toolu_9".to_owned(),
18156                output: CLASSIFIER_SAMPLE.to_owned(),
18157                is_error: true,
18158                duration_ms: None,
18159                agent_id: None,
18160                non_execution_kind: Some("permission-rule".to_owned()),
18161            },
18162        );
18163        let (correlation_id, _, authority, reason_kind, reason, help) = blocked(&event);
18164        assert_eq!(authority, BlockAuthorityV1::ProviderPermissionRule);
18165        assert_eq!(reason, "Blocked by classifier");
18166        assert_eq!(reason_kind, Some("permission-rule".to_owned()));
18167        assert!(help.as_deref().is_some_and(|help| help.starts_with("If you have other tasks")));
18168        assert!(correlation_id.is_some(), "a tool-correlated block must carry a correlation id");
18169    }
18170
18171    /// A `session/prompt` RPC error with `data.codexErrorInfo:
18172    /// "usageLimitExceeded"` (measured live 2026-09-05) mints a `Blocked` chunk
18173    /// with `authority: ProviderQuota`, the message verbatim as `reason` and the
18174    /// vendor code as `reason_kind`.
18175    #[test]
18176    fn codex_quota_rpc_error_mints_a_blocked_chunk() {
18177        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.";
18178        let event = provider_control_event(
18179            AdapterFamily::Acp,
18180            ProviderEvent::SessionEnded {
18181                result: "JSON-RPC error -32603: Internal error".to_owned(),
18182                cost_usd: None,
18183                is_error: true,
18184                stop_reason: Some(ProviderStopReason::ProviderError {
18185                    code: -32603,
18186                    message: message.to_owned(),
18187                    vendor_code: Some("usageLimitExceeded".to_owned()),
18188                }),
18189            },
18190        );
18191        let (_, tool_class, authority, reason_kind, reason, _) = blocked(&event);
18192        assert_eq!(authority, BlockAuthorityV1::ProviderQuota);
18193        assert_eq!(reason, message);
18194        assert_eq!(reason_kind, Some("usageLimitExceeded".to_owned()));
18195        assert_eq!(tool_class, "Session");
18196    }
18197
18198    /// ACP's `stopReason: "refusal"` mints a `Blocked` chunk with
18199    /// `authority: ProviderRefusal`.
18200    #[test]
18201    fn session_end_stop_reason_refusal_mints_a_blocked_chunk() {
18202        let event = provider_control_event(
18203            AdapterFamily::Acp,
18204            ProviderEvent::SessionEnded {
18205                result: "refusal".to_owned(),
18206                cost_usd: None,
18207                is_error: true,
18208                stop_reason: Some(ProviderStopReason::Refusal),
18209            },
18210        );
18211        let (_, _, authority, _, _, _) = blocked(&event);
18212        assert_eq!(authority, BlockAuthorityV1::ProviderRefusal);
18213    }
18214
18215    /// A failed tool completion with no typed `non_execution_kind` -- or one
18216    /// that only says the turn stopped -- is a plain execution failure, never a
18217    /// block; nor is an agent's own narrative claiming one.
18218    #[test]
18219    fn failures_and_narrative_without_a_typed_block_mint_no_blocked_chunk() {
18220        for non_execution_kind in [None, Some("interrupted".to_owned()), Some("cancelled".to_owned())] {
18221            let event = provider_control_event(
18222                AdapterFamily::Acp,
18223                ProviderEvent::ToolCompleted {
18224                    id: "toolu_1".to_owned(),
18225                    output: "command exited with status 1".to_owned(),
18226                    is_error: true,
18227                    duration_ms: Some(12),
18228                    agent_id: None,
18229                    non_execution_kind,
18230                },
18231            );
18232            assert!(agent_stream_chunk(&event).is_none());
18233        }
18234        let narrative = provider_control_event(
18235            AdapterFamily::Acp,
18236            ProviderEvent::ToolCompleted {
18237                id: "toolu_2".to_owned(),
18238                output: "the harness blocked the write, approval needed".to_owned(),
18239                is_error: false,
18240                duration_ms: Some(3),
18241                agent_id: None,
18242                non_execution_kind: None,
18243            },
18244        );
18245        assert!(agent_stream_chunk(&narrative).is_none());
18246    }
18247
18248    /// `Granted`/`Deferred` decisions never mint a `Blocked` chunk, whatever the
18249    /// execution outcome: a granted request that failed while running was
18250    /// still authorized.
18251    #[test]
18252    fn granted_and_deferred_host_requests_never_mint_a_blocked_chunk() {
18253        for (decision, outcome) in [
18254            (
18255                ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
18256                ProviderHostRequestOutcome::Executed,
18257            ),
18258            (ProviderHostRequestDecision::Deferred, ProviderHostRequestOutcome::Executed),
18259            (
18260                ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
18261                ProviderHostRequestOutcome::Failed {
18262                    error: "terminal/create spawn failed: os error 3".to_owned(),
18263                },
18264            ),
18265        ] {
18266            let event = provider_control_event_at(
18267                AdapterFamily::Acp,
18268                "claude-code",
18269                3,
18270                ProviderEvent::HostRequestObserved {
18271                    method: "fs/read_text_file".to_owned(),
18272                    params_json: String::new(),
18273                    decision: decision.clone(),
18274                    outcome,
18275                    reason: None,
18276                },
18277            );
18278            assert!(agent_stream_chunk(&event).is_none(), "{decision:?}");
18279        }
18280    }
18281
18282    /// A deferred `session/request_permission` resolved by an operator or an
18283    /// expired deadline carries no reason text -- the chunk falls back to a
18284    /// categorical sentence naming the authority rather than inventing detail.
18285    #[test]
18286    fn denied_host_request_with_no_reason_falls_back_to_a_categorical_sentence() {
18287        for (by, expected_authority, expected_reason) in [
18288            (
18289                ProviderHostDecisionAuthority::Operator,
18290                BlockAuthorityV1::Operator,
18291                "declined by the operator",
18292            ),
18293            (
18294                ProviderHostDecisionAuthority::DeadlinePolicy,
18295                BlockAuthorityV1::HostDeadline,
18296                "denied by host policy after the operator deadline expired",
18297            ),
18298        ] {
18299            let (_, _, authority, _, reason, _) = blocked(&denied_host_request(by, None));
18300            assert_eq!(authority, expected_authority);
18301            assert_eq!(reason, expected_reason);
18302        }
18303    }
18304
18305    /// An over-long reason is cut to the wire bound BEFORE the chunk is built,
18306    /// at a safe UTF-8 boundary -- `validate` rejects an over-long value
18307    /// outright rather than truncating.
18308    #[test]
18309    fn overlong_blocked_reason_is_truncated_to_the_wire_bound() {
18310        let oversized = "x".repeat(MAX_ACP_BLOCKED_REASON_BYTES + 500);
18311        let (_, _, _, _, reason, _) = blocked(&denied_host_request(
18312            ProviderHostDecisionAuthority::Policy,
18313            Some(oversized),
18314        ));
18315        assert!(reason.len() <= MAX_ACP_BLOCKED_REASON_BYTES);
18316    }
18317}
18318
18319#[cfg(test)]
18320mod standalone_capability_platform_tests {
18321    use super::*;
18322
18323    #[test]
18324    fn standalone_workspace_capability_requires_windows_identity_guards() {
18325        let capabilities = baseline_capabilities().unwrap();
18326        let advertised = capabilities.iter().any(|capability| {
18327            capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
18328        });
18329        assert_eq!(advertised, cfg!(windows));
18330
18331        let request = NodeRequest::CreateStandaloneWorkspace {
18332            workspace_id: WorkspaceId::new("standalone-platform").unwrap(),
18333            root: OpaqueHostPath::utf8("standalone-platform-root".to_owned()).unwrap(),
18334            initial_branch: None,
18335        };
18336        assert_eq!(
18337            request_uses_unnegotiated_capability(&request, &capabilities),
18338            !cfg!(windows),
18339        );
18340    }
18341}
18342
18343
18344#[cfg(test)]
18345mod station_knobs_resolve_tests {
18346    use super::*;
18347    use gate4agent_catalog::EnvMutation;
18348    use gate4agent_runtime_native::{
18349        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
18350        NativeLaunchProfileId,
18351    };
18352    use std::ffi::OsString;
18353    use std::sync::Arc;
18354
18355    struct EmptyEnvironmentResolver;
18356
18357    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
18358        fn resolve_child_environment(
18359            &self,
18360        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
18361            Ok(vec![EnvMutation {
18362                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
18363                value: None,
18364            }])
18365        }
18366    }
18367
18368    fn agent(value: &str) -> AgentId {
18369        AgentId::new(value).unwrap()
18370    }
18371
18372    fn station_knobs_fixture() -> (NodeShared, crate::protocol::SpawnProfileDefaults) {
18373        let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
18374        let profile_revision =
18375            crate::protocol::SpawnEnvironmentProfileRevision::new("local-claude-r1").unwrap();
18376        let spawn_defaults = crate::protocol::SpawnProfileDefaults {
18377            profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
18378            revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
18379            provider: agent("claude"),
18380            mode: SessionMode::Pty,
18381            terminal_size: gate4agent_types::TerminalSize {
18382                rows: 24,
18383                columns: 80,
18384            },
18385            prompt: None,
18386            bundle_id: None,
18387            context_id: None,
18388            environment_profile_id: Some(profile_id.clone()),
18389        };
18390        let spawn_profiles = SpawnProfileRegistry::new([spawn_defaults.clone()]).unwrap();
18391        let catalog = active_registry().unwrap();
18392        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18393        let control = runtime.native_launch_profile_control();
18394        let node_profile = NodeEnvironmentProfile::new(
18395            profile_id.clone(),
18396            profile_revision,
18397            agent("claude"),
18398            [NativeLaunchProfile::new(
18399                NativeLaunchProfileId::new("local-claude-pty").unwrap(),
18400                agent("claude"),
18401                TransportKind::Pty,
18402                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
18403                Arc::new(EmptyEnvironmentResolver),
18404            )
18405            .unwrap()],
18406        )
18407        .unwrap();
18408        let (binding, native_profiles, _materialization) = node_profile.into_parts();
18409        for native_profile in native_profiles {
18410            runtime.upsert_native_launch_profile(native_profile).unwrap();
18411        }
18412        let workspace = WorkspaceConfig::new(
18413            WorkspaceId::new("primary").unwrap(),
18414            std::env::current_dir().unwrap(),
18415        )
18416        .unwrap();
18417        let shared = NodeShared::new_with_incarnation(
18418            handle,
18419            "fixture-token".to_owned(),
18420            NodeId::new("node-station-knobs-test").unwrap(),
18421            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
18422            vec![workspace],
18423            vec![agent("claude")],
18424            ProviderRuntimeStatuses::default(),
18425            None,
18426            Vec::new(),
18427            Vec::new(),
18428            spawn_profiles,
18429            Some(control),
18430            None,
18431            None,
18432            None,
18433            Vec::new(),
18434            Vec::new(),
18435            Vec::new(),
18436            Vec::new(),
18437            None,
18438            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
18439        );
18440        shared
18441            .environment_profiles
18442            .write()
18443            .unwrap_or_else(|poisoned| poisoned.into_inner())
18444            .insert(binding.id.clone(), binding);
18445        (shared, spawn_defaults)
18446    }
18447
18448    /// Empty-default catalog refuses unknown network_allowlist ids.
18449    /// Feature-off dig2-station-probe keeps browser_profile stub echo.
18450    #[test]
18451    fn resolve_environment_profile_refuses_unknown_network_allowlist() {
18452        let (shared, defaults) = station_knobs_fixture();
18453        let unknown = crate::protocol::SpawnNetworkAllowlistId::new("not-registered").unwrap();
18454        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18455        let spec = crate::protocol::SpawnSpec {
18456            target: crate::protocol::SpawnTarget {
18457                node_id: shared.node_id.clone(),
18458                workspace_id: WorkspaceId::new("primary").unwrap(),
18459                worktree_id: None,
18460            },
18461            profile_id: defaults.profile_id.clone(),
18462            expected_profile_revision: defaults.revision.clone(),
18463            overrides: crate::protocol::SpawnOverrides {
18464                network_allowlist: Some(unknown),
18465                browser_profile_id: Some(browser),
18466                ..crate::protocol::SpawnOverrides::default()
18467            },
18468            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18469            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18470                "station-knobs-unknown-allowlist",
18471            )
18472            .unwrap(),
18473            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18474        };
18475        let resolved = spec.resolve(&defaults).unwrap();
18476        let failure = shared
18477            .resolve_environment_profile(&resolved)
18478            .expect_err("unknown allowlist must refuse");
18479        assert_eq!(failure.code, NodeFailureCode::UnknownNetworkAllowlist);
18480    }
18481
18482    /// Registered network_allowlist (+ browser_profile_id when feature off)
18483    /// echo onto the env-profile receipt. Empty catalog still allows omit
18484    /// (None). Empty/whitespace refuse is type-level. Feature-off
18485    /// dig2-station-probe keeps stub browser echo; feature-on refuse is
18486    /// covered by `…_refuses_browser_profile_when_station_probe_fails`.
18487    #[test]
18488    fn resolve_environment_profile_echoes_registered_station_network_and_browser_knobs() {
18489        let (shared, defaults) = station_knobs_fixture();
18490        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18491        #[cfg(not(feature = "dig2-station-probe"))]
18492        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18493        shared
18494            .network_allowlist_catalog
18495            .write()
18496            .unwrap_or_else(|poisoned| poisoned.into_inner())
18497            .insert_id(allowlist.clone())
18498            .unwrap();
18499        let spec = crate::protocol::SpawnSpec {
18500            target: crate::protocol::SpawnTarget {
18501                node_id: shared.node_id.clone(),
18502                workspace_id: WorkspaceId::new("primary").unwrap(),
18503                worktree_id: None,
18504            },
18505            profile_id: defaults.profile_id.clone(),
18506            expected_profile_revision: defaults.revision.clone(),
18507            overrides: crate::protocol::SpawnOverrides {
18508                network_allowlist: Some(allowlist.clone()),
18509                #[cfg(not(feature = "dig2-station-probe"))]
18510                browser_profile_id: Some(browser.clone()),
18511                ..crate::protocol::SpawnOverrides::default()
18512            },
18513            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18514            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-echo")
18515                .unwrap(),
18516            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18517        };
18518        let resolved = spec.resolve(&defaults).unwrap();
18519        assert_eq!(resolved.network_allowlist.as_ref(), Some(&allowlist));
18520        #[cfg(not(feature = "dig2-station-probe"))]
18521        assert_eq!(resolved.browser_profile_id.as_ref(), Some(&browser));
18522        let receipt = shared
18523            .resolve_environment_profile(&resolved)
18524            .expect("resolve ok")
18525            .expect("env profile present");
18526        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18527        #[cfg(not(feature = "dig2-station-probe"))]
18528        assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
18529        #[cfg(feature = "dig2-station-probe")]
18530        assert_eq!(receipt.browser_profile_id, None);
18531        assert_eq!(receipt.profile_id.as_str(), "local-claude");
18532
18533        let omitted = crate::protocol::SpawnSpec {
18534            overrides: crate::protocol::SpawnOverrides::default(),
18535            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-omit")
18536                .unwrap(),
18537            ..spec
18538        };
18539        let resolved_omit = omitted.resolve(&defaults).unwrap();
18540        let bare = shared
18541            .resolve_environment_profile(&resolved_omit)
18542            .expect("resolve ok")
18543            .expect("env profile present");
18544        assert_eq!(bare.network_allowlist, None);
18545        assert_eq!(bare.browser_profile_id, None);
18546    }
18547
18548    /// Feature `dig2-station-probe`: browser_profile_id set refuses when the
18549    /// cheap station probe cannot succeed. On non-Windows (this CI host) that
18550    /// is `BrowserStationProbeUnavailable`. Never cookies on C2. Lease map is
18551    /// unit-tested separately (probe refuses before lease on Linux).
18552    #[cfg(feature = "dig2-station-probe")]
18553    #[test]
18554    fn resolve_environment_profile_refuses_browser_profile_when_station_probe_fails() {
18555        let (shared, defaults) = station_knobs_fixture();
18556        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18557        let spec = crate::protocol::SpawnSpec {
18558            target: crate::protocol::SpawnTarget {
18559                node_id: shared.node_id.clone(),
18560                workspace_id: WorkspaceId::new("primary").unwrap(),
18561                worktree_id: None,
18562            },
18563            profile_id: defaults.profile_id.clone(),
18564            expected_profile_revision: defaults.revision.clone(),
18565            overrides: crate::protocol::SpawnOverrides {
18566                browser_profile_id: Some(browser),
18567                ..crate::protocol::SpawnOverrides::default()
18568            },
18569            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18570            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18571                "station-knobs-probe-refuse",
18572            )
18573            .unwrap(),
18574            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18575        };
18576        let resolved = spec.resolve(&defaults).unwrap();
18577        let failure = shared
18578            .resolve_environment_profile(&resolved)
18579            .expect_err("browser_profile with failing probe must refuse");
18580        #[cfg(windows)]
18581        {
18582            assert!(
18583                matches!(
18584                    failure.code,
18585                    NodeFailureCode::BrowserStationUnreachable
18586                        | NodeFailureCode::BrowserStationProbeUnavailable
18587                ),
18588                "unexpected code {:?}",
18589                failure.code
18590            );
18591        }
18592        #[cfg(not(windows))]
18593        {
18594            assert_eq!(
18595                failure.code,
18596                NodeFailureCode::BrowserStationProbeUnavailable
18597            );
18598        }
18599    }
18600
18601    /// Feature on: omitting browser_profile_id still resolves (no probe).
18602    #[cfg(feature = "dig2-station-probe")]
18603    #[test]
18604    fn resolve_environment_profile_skips_station_probe_when_browser_profile_omitted() {
18605        let (shared, defaults) = station_knobs_fixture();
18606        let spec = crate::protocol::SpawnSpec {
18607            target: crate::protocol::SpawnTarget {
18608                node_id: shared.node_id.clone(),
18609                workspace_id: WorkspaceId::new("primary").unwrap(),
18610                worktree_id: None,
18611            },
18612            profile_id: defaults.profile_id.clone(),
18613            expected_profile_revision: defaults.revision.clone(),
18614            overrides: crate::protocol::SpawnOverrides::default(),
18615            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18616            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18617                "station-knobs-probe-omit",
18618            )
18619            .unwrap(),
18620            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18621        };
18622        let resolved = spec.resolve(&defaults).unwrap();
18623        let receipt = shared
18624            .resolve_environment_profile(&resolved)
18625            .expect("omit browser_profile must not probe")
18626            .expect("env profile present");
18627        assert_eq!(receipt.browser_profile_id, None);
18628    }
18629
18630    /// Feature dig2-station-probe: exclusive BrowserStationLease on NodeShared
18631    /// refuses a second holder for the same opaque profile id; release on
18632    /// remove_binding allows reacquire. Never ImportSession/cookies on C2.
18633    #[cfg(feature = "dig2-station-probe")]
18634    #[test]
18635    fn browser_station_lease_exclusive_and_clears_on_remove_binding() {
18636        let (shared, _defaults) = station_knobs_fixture();
18637        let profile = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18638        let workspace_id = WorkspaceId::new("primary").unwrap();
18639        let address_a = SessionAddress {
18640            workspace_id: workspace_id.clone(),
18641            session: SessionKey {
18642                instance_id: AgentInstanceId(101),
18643                generation: SessionGeneration(1),
18644            },
18645        };
18646        let address_b = SessionAddress {
18647            workspace_id: workspace_id.clone(),
18648            session: SessionKey {
18649                instance_id: AgentInstanceId(102),
18650                generation: SessionGeneration(1),
18651            },
18652        };
18653        let guard_a = crate::dig2_station_lease::acquire_lease(
18654            &shared.browser_station_leases,
18655            profile.clone(),
18656            address_a.session.instance_id,
18657            1,
18658        )
18659        .expect("first lease");
18660        let busy = crate::dig2_station_lease::acquire_lease(
18661            &shared.browser_station_leases,
18662            profile.clone(),
18663            address_b.session.instance_id,
18664            2,
18665        )
18666        .expect_err("second holder must refuse");
18667        assert!(matches!(
18668            busy,
18669            crate::dig2_station_lease::BrowserStationLeaseError::ProfileBusy { .. }
18670        ));
18671        // Simulate a bound session so remove_binding clears the lease.
18672        shared.bind_session_with_policy(
18673            &address_a,
18674            ProviderRuntimePolicy::raw_pty(),
18675            None,
18676        );
18677        guard_a.retain();
18678        assert!(shared.remove_binding(&address_a).is_some());
18679        crate::dig2_station_lease::acquire_lease(
18680            &shared.browser_station_leases,
18681            profile,
18682            address_b.session.instance_id,
18683            3,
18684        )
18685        .expect("reacquire after clear")
18686        .retain();
18687        assert!(
18688            crate::dig2_station_lease::release_lease_for_holder(
18689                &shared.browser_station_leases,
18690                address_b.session.instance_id,
18691            )
18692        );
18693    }
18694
18695    /// Feature-off: no browser_station_leases field; stub echo documented in
18696    /// resolve_environment_profile_echoes_registered_station_network_and_browser_knobs.
18697    #[cfg(not(feature = "dig2-station-probe"))]
18698    #[test]
18699    fn browser_station_lease_feature_off_keeps_stub_without_lease_table() {
18700        let (shared, defaults) = station_knobs_fixture();
18701        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18702        let spec = crate::protocol::SpawnSpec {
18703            target: crate::protocol::SpawnTarget {
18704                node_id: shared.node_id.clone(),
18705                workspace_id: WorkspaceId::new("primary").unwrap(),
18706                worktree_id: None,
18707            },
18708            profile_id: defaults.profile_id.clone(),
18709            expected_profile_revision: defaults.revision.clone(),
18710            overrides: crate::protocol::SpawnOverrides {
18711                browser_profile_id: Some(browser.clone()),
18712                ..crate::protocol::SpawnOverrides::default()
18713            },
18714            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18715            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("lease-stub-off")
18716                .unwrap(),
18717            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18718        };
18719        let resolved = spec.resolve(&defaults).unwrap();
18720        let receipt = shared
18721            .resolve_environment_profile(&resolved)
18722            .expect("stub resolve")
18723            .expect("env profile");
18724        assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
18725    }
18726
18727
18728    /// Startup file loader registers ids into the empty-default catalog.
18729    /// Feature-off dig2 probe stays stub. Never secrets on C2.
18730    #[test]
18731    fn network_allowlist_catalog_loads_from_temp_file() {
18732        let unique = std::time::SystemTime::now()
18733            .duration_since(std::time::UNIX_EPOCH)
18734            .unwrap()
18735            .as_nanos();
18736        let path = std::env::temp_dir().join(format!(
18737            "gate4agent-node-allowlist-config-{}-{unique}.txt",
18738            std::process::id(),
18739        ));
18740        std::fs::write(
18741            &path,
18742            "# opaque station allowlist ids\negress-default\nlab-egress\n",
18743        )
18744        .unwrap();
18745        let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
18746            .expect("temp catalog file must load");
18747        let _ = std::fs::remove_file(&path);
18748        assert_eq!(loaded.len(), 2);
18749        let (shared, defaults) = station_knobs_fixture();
18750        {
18751            let mut catalog = shared
18752                .network_allowlist_catalog
18753                .write()
18754                .unwrap_or_else(|poisoned| poisoned.into_inner());
18755            *catalog = loaded;
18756        }
18757        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18758        let spec = crate::protocol::SpawnSpec {
18759            target: crate::protocol::SpawnTarget {
18760                node_id: shared.node_id.clone(),
18761                workspace_id: WorkspaceId::new("primary").unwrap(),
18762                worktree_id: None,
18763            },
18764            profile_id: defaults.profile_id.clone(),
18765            expected_profile_revision: defaults.revision.clone(),
18766            overrides: crate::protocol::SpawnOverrides {
18767                network_allowlist: Some(allowlist.clone()),
18768                ..crate::protocol::SpawnOverrides::default()
18769            },
18770            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18771            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18772                "station-knobs-catalog-file",
18773            )
18774            .unwrap(),
18775            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18776        };
18777        let resolved = spec.resolve(&defaults).unwrap();
18778        let receipt = shared
18779            .resolve_environment_profile(&resolved)
18780            .expect("registered via file load")
18781            .expect("env profile present");
18782        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18783    }
18784
18785    /// Snapshot launch inventory echoes registered catalog ids (opaque).
18786    /// Dig2 station probe is orthogonal (feature `dig2-station-probe`). Never secrets on C2.
18787    #[test]
18788    fn snapshot_launch_inventory_lists_network_allowlist_catalog_ids() {
18789        let (shared, _defaults) = station_knobs_fixture();
18790        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18791        shared
18792            .network_allowlist_catalog
18793            .write()
18794            .unwrap_or_else(|poisoned| poisoned.into_inner())
18795            .insert_id(allowlist.clone())
18796            .unwrap();
18797        let snapshot = shared.snapshot();
18798        let inventory = snapshot
18799            .launch_inventory
18800            .expect("launch inventory present");
18801        let ids = inventory
18802            .network_allowlists
18803            .expect("non-empty catalog surfaces ids");
18804        assert_eq!(ids, vec![allowlist]);
18805    }
18806
18807    /// Track B: registered id with provider_native codex_network_access refuses
18808    /// clearly for Claude (fixture provider). Unknown id still UnknownNetworkAllowlist.
18809    /// Never secrets on C2. Codex Moderate maps to `-c` overlay; RO/FullAuto refuse.
18810    #[test]
18811    fn resolve_environment_profile_refuses_unsupported_provider_native_mapping() {
18812        let (shared, defaults) = station_knobs_fixture();
18813        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-only").unwrap();
18814        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18815            id: allowlist.clone(),
18816            permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
18817                protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18818                peer: "127.0.0.1:443".to_owned(),
18819            }],
18820            provider_native: Some(
18821                crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
18822                    codex_network_access: Some(true),
18823                },
18824            ),
18825        };
18826        shared
18827            .network_allowlist_catalog
18828            .write()
18829            .unwrap_or_else(|poisoned| poisoned.into_inner())
18830            .insert(entry)
18831            .unwrap();
18832        let spec = crate::protocol::SpawnSpec {
18833            target: crate::protocol::SpawnTarget {
18834                node_id: shared.node_id.clone(),
18835                workspace_id: WorkspaceId::new("primary").unwrap(),
18836                worktree_id: None,
18837            },
18838            profile_id: defaults.profile_id.clone(),
18839            expected_profile_revision: defaults.revision.clone(),
18840            overrides: crate::protocol::SpawnOverrides {
18841                network_allowlist: Some(allowlist),
18842                ..crate::protocol::SpawnOverrides::default()
18843            },
18844            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18845            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18846                "station-knobs-unsupported-mapping",
18847            )
18848            .unwrap(),
18849            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18850        };
18851        let resolved = spec.resolve(&defaults).unwrap();
18852        assert_eq!(resolved.provider.as_str(), "claude");
18853        let failure = shared
18854            .resolve_environment_profile(&resolved)
18855            .expect_err("claude + codex_network_access must refuse");
18856        assert_eq!(
18857            failure.code,
18858            NodeFailureCode::UnsupportedNetworkAllowlistMapping
18859        );
18860    }
18861
18862    /// Codex Moderate + codex_network_access yields `-c` overlay argv.
18863    /// ReadOnly / FullAuto refuse UnsupportedNetworkAllowlistMapping.
18864    /// Never secrets on C2.
18865    #[test]
18866    fn resolve_codex_network_access_overlay_moderate_only() {
18867        let (shared, defaults) = station_knobs_fixture();
18868        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-net").unwrap();
18869        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18870            id: allowlist.clone(),
18871            permits: Vec::new(),
18872            provider_native: Some(
18873                crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
18874                    codex_network_access: Some(true),
18875                },
18876            ),
18877        };
18878        shared
18879            .network_allowlist_catalog
18880            .write()
18881            .unwrap_or_else(|poisoned| poisoned.into_inner())
18882            .insert(entry)
18883            .unwrap();
18884
18885        let overlay = shared
18886            .resolve_codex_network_access_overlay_args(
18887                &agent("codex"),
18888                SessionMode::Pty,
18889                ApprovalLevel::Moderate,
18890                Some(&allowlist),
18891            )
18892            .expect("Moderate Codex maps network_access");
18893        assert_eq!(
18894            overlay,
18895            vec![
18896                std::ffi::OsString::from("-c"),
18897                std::ffi::OsString::from("sandbox_workspace_write.network_access=true"),
18898            ]
18899        );
18900
18901        let acp = shared
18902            .resolve_codex_network_access_overlay_args(
18903                &agent("codex"),
18904                SessionMode::Acp,
18905                ApprovalLevel::Moderate,
18906                Some(&allowlist),
18907            )
18908            .unwrap();
18909        assert_eq!(acp, overlay);
18910
18911        let inline = shared
18912            .resolve_codex_network_access_overlay_args(
18913                &agent("codex"),
18914                SessionMode::Inline,
18915                ApprovalLevel::Moderate,
18916                Some(&allowlist),
18917            )
18918            .unwrap_err();
18919        assert_eq!(
18920            inline.code,
18921            NodeFailureCode::UnsupportedNetworkAllowlistMapping
18922        );
18923
18924        for level in [ApprovalLevel::ReadOnly, ApprovalLevel::FullAuto] {
18925            let spec = crate::protocol::SpawnSpec {
18926                target: crate::protocol::SpawnTarget {
18927                    node_id: shared.node_id.clone(),
18928                    workspace_id: WorkspaceId::new("primary").unwrap(),
18929                    worktree_id: None,
18930                },
18931                profile_id: defaults.profile_id.clone(),
18932                expected_profile_revision: defaults.revision.clone(),
18933                overrides: crate::protocol::SpawnOverrides {
18934                    provider: crate::protocol::SpawnOverride::Set {
18935                        value: agent("codex"),
18936                    },
18937                    approval_level: Some(level),
18938                    network_allowlist: Some(allowlist.clone()),
18939                    ..crate::protocol::SpawnOverrides::default()
18940                },
18941                deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18942                idempotency_key: crate::protocol::SpawnIdempotencyKey::new(format!(
18943                    "codex-net-refuse-{}",
18944                    match level {
18945                        ApprovalLevel::ReadOnly => "ro",
18946                        ApprovalLevel::FullAuto => "fa",
18947                        _ => "other",
18948                    }
18949                ))
18950                .unwrap(),
18951                required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18952            };
18953            let resolved = spec.resolve(&defaults).unwrap();
18954            let failure = shared
18955                .resolve_environment_profile(&resolved)
18956                .expect_err("RO/FullAuto must refuse");
18957            assert_eq!(
18958                failure.code,
18959                NodeFailureCode::UnsupportedNetworkAllowlistMapping
18960            );
18961        }
18962    }
18963
18964    /// Claude PTY + catalog permits → ProviderHome `--settings` sidecar with
18965    /// sandbox.network.allowedDomains. ACP / missing ProviderHome refuse.
18966    /// Never secrets on C2; does not invent provider_native Claude keys.
18967    #[test]
18968    fn resolve_claude_settings_network_overlay_pty_writes_sidecar() {
18969        let (shared, _defaults) = station_knobs_fixture();
18970        let allowlist =
18971            crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-domains").unwrap();
18972        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18973            id: allowlist.clone(),
18974            permits: vec![
18975                crate::network_allowlist_catalog::NetworkPermitSketch {
18976                    protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18977                    peer: "github.com:443".to_owned(),
18978                },
18979                crate::network_allowlist_catalog::NetworkPermitSketch {
18980                    protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18981                    peer: "*.npmjs.org".to_owned(),
18982                },
18983            ],
18984            provider_native: None,
18985        };
18986        shared
18987            .network_allowlist_catalog
18988            .write()
18989            .unwrap_or_else(|poisoned| poisoned.into_inner())
18990            .insert(entry)
18991            .unwrap();
18992
18993        let unique = std::time::SystemTime::now()
18994            .duration_since(std::time::UNIX_EPOCH)
18995            .unwrap()
18996            .as_nanos();
18997        let provider_home = std::env::temp_dir().join(format!(
18998            "gate4agent-claude-provider-home-{}-{unique}",
18999            std::process::id(),
19000        ));
19001        std::fs::create_dir_all(&provider_home).unwrap();
19002
19003        let overlay = shared
19004            .resolve_network_allowlist_overlay_args(
19005                &agent("claude"),
19006                SessionMode::Pty,
19007                ApprovalLevel::Moderate,
19008                Some(&allowlist),
19009                Some(provider_home.as_path()),
19010            )
19011            .expect("Claude PTY settings overlay on unix");
19012        let settings_path = provider_home.join(
19013            crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
19014        );
19015        assert_eq!(
19016            overlay,
19017            vec![
19018                std::ffi::OsString::from("--settings"),
19019                std::ffi::OsString::from(settings_path.as_os_str()),
19020            ]
19021        );
19022        let body = std::fs::read_to_string(&settings_path).unwrap();
19023        let value: serde_json::Value = serde_json::from_str(&body).unwrap();
19024        assert_eq!(value["sandbox"]["enabled"], true);
19025        assert_eq!(
19026            value["sandbox"]["network"]["allowedDomains"],
19027            serde_json::json!(["github.com", "*.npmjs.org"])
19028        );
19029        assert!(settings_path.starts_with(&provider_home));
19030
19031        let acp = shared
19032            .resolve_network_allowlist_overlay_args(
19033                &agent("claude"),
19034                SessionMode::Acp,
19035                ApprovalLevel::Moderate,
19036                Some(&allowlist),
19037                Some(provider_home.as_path()),
19038            )
19039            .expect_err("Claude ACP must refuse settings overlay");
19040        assert_eq!(
19041            acp.code,
19042            NodeFailureCode::UnsupportedNetworkAllowlistMapping
19043        );
19044
19045        let no_home = shared
19046            .resolve_network_allowlist_overlay_args(
19047                &agent("claude"),
19048                SessionMode::Pty,
19049                ApprovalLevel::Moderate,
19050                Some(&allowlist),
19051                None,
19052            )
19053            .expect_err("Claude overlay requires ProviderHome");
19054        assert_eq!(
19055            no_home.code,
19056            NodeFailureCode::UnsupportedNetworkAllowlistMapping
19057        );
19058
19059        let bare = crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-bare").unwrap();
19060        shared
19061            .network_allowlist_catalog
19062            .write()
19063            .unwrap_or_else(|poisoned| poisoned.into_inner())
19064            .insert_id(bare.clone())
19065            .unwrap();
19066        let empty = shared
19067            .resolve_network_allowlist_overlay_args(
19068                &agent("claude"),
19069                SessionMode::Pty,
19070                ApprovalLevel::Moderate,
19071                Some(&bare),
19072                Some(provider_home.as_path()),
19073            )
19074            .unwrap();
19075        assert!(empty.is_empty());
19076
19077        let _ = std::fs::remove_dir_all(&provider_home);
19078    }
19079
19080    /// Kimi / Grok with permits stay without invented argv overlays.
19081    #[test]
19082    fn resolve_kimi_grok_network_overlay_stays_empty_without_native() {
19083        let (shared, _defaults) = station_knobs_fixture();
19084        let allowlist =
19085            crate::protocol::SpawnNetworkAllowlistId::new("egress-kimi-permits").unwrap();
19086        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19087            id: allowlist.clone(),
19088            permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
19089                protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19090                peer: "example.com:443".to_owned(),
19091            }],
19092            provider_native: None,
19093        };
19094        shared
19095            .network_allowlist_catalog
19096            .write()
19097            .unwrap_or_else(|poisoned| poisoned.into_inner())
19098            .insert(entry)
19099            .unwrap();
19100        for provider in ["kimi", "grok"] {
19101            let overlay = shared
19102                .resolve_network_allowlist_overlay_args(
19103                    &agent(provider),
19104                    SessionMode::Pty,
19105                    ApprovalLevel::Moderate,
19106                    Some(&allowlist),
19107                    None,
19108                )
19109                .unwrap();
19110            assert!(
19111                overlay.is_empty(),
19112                "{provider} must not invent network argv from permits"
19113            );
19114        }
19115    }
19116
19117    /// Track B: JSON v2 catalog file loads permits; inventory still ids only.
19118    #[test]
19119    fn network_allowlist_catalog_loads_json_v2_and_lists_ids_only() {
19120        let unique = std::time::SystemTime::now()
19121            .duration_since(std::time::UNIX_EPOCH)
19122            .unwrap()
19123            .as_nanos();
19124        let path = std::env::temp_dir().join(format!(
19125            "gate4agent-node-allowlist-json-{}-{unique}.json",
19126            std::process::id(),
19127        ));
19128        std::fs::write(
19129            &path,
19130            r#"{
19131              "schema_version": 2,
19132              "entries": [
19133                {
19134                  "id": "egress-default",
19135                  "permits": [{ "protocol": "tcp", "peer": "127.0.0.1:443" }]
19136                },
19137                { "id": "lab-egress" }
19138              ]
19139            }"#,
19140        )
19141        .unwrap();
19142        let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
19143            .expect("json v2 catalog must load");
19144        let _ = std::fs::remove_file(&path);
19145        assert_eq!(loaded.len(), 2);
19146        let entry = loaded
19147            .get(&crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap())
19148            .unwrap();
19149        assert_eq!(entry.permits.len(), 1);
19150        let (shared, defaults) = station_knobs_fixture();
19151        {
19152            let mut catalog = shared
19153                .network_allowlist_catalog
19154                .write()
19155                .unwrap_or_else(|poisoned| poisoned.into_inner());
19156            *catalog = loaded;
19157        }
19158        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19159        let spec = crate::protocol::SpawnSpec {
19160            target: crate::protocol::SpawnTarget {
19161                node_id: shared.node_id.clone(),
19162                workspace_id: WorkspaceId::new("primary").unwrap(),
19163                worktree_id: None,
19164            },
19165            profile_id: defaults.profile_id.clone(),
19166            expected_profile_revision: defaults.revision.clone(),
19167            overrides: crate::protocol::SpawnOverrides {
19168                network_allowlist: Some(allowlist.clone()),
19169                ..crate::protocol::SpawnOverrides::default()
19170            },
19171            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19172            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19173                "station-knobs-catalog-json",
19174            )
19175            .unwrap(),
19176            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19177        };
19178        let resolved = spec.resolve(&defaults).unwrap();
19179        let receipt = shared
19180            .resolve_environment_profile(&resolved)
19181            .expect("permits-only entry ok for claude")
19182            .expect("env profile present");
19183        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
19184        let inventory = shared
19185            .snapshot()
19186            .launch_inventory
19187            .expect("launch inventory present");
19188        let ids = inventory.network_allowlists.expect("ids surface");
19189        assert_eq!(
19190            ids,
19191            vec![
19192                crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap(),
19193                crate::protocol::SpawnNetworkAllowlistId::new("lab-egress").unwrap(),
19194            ]
19195        );
19196    }
19197
19198
19199}
19200
19201
19202#[cfg(test)]
19203mod workspace_environment_collision_tests {
19204    use super::*;
19205    use crate::session_environment::{
19206        NodeSessionMaterializationProfile, NodeSessionPathBinding, NodeSessionPathClass,
19207    };
19208    use gate4agent_catalog::EnvMutation;
19209    use gate4agent_runtime_native::{
19210        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
19211        NativeLaunchProfileId,
19212    };
19213    use std::ffi::OsString;
19214    use std::sync::Arc;
19215
19216    struct EmptyEnvironmentResolver;
19217
19218    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19219        fn resolve_child_environment(
19220            &self,
19221        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19222            Ok(vec![EnvMutation {
19223                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19224                value: None,
19225            }])
19226        }
19227    }
19228
19229    fn agent(value: &str) -> AgentId {
19230        AgentId::new(value).unwrap()
19231    }
19232
19233    /// Two Claude ProviderHome profiles (`CLAUDE_CONFIG_DIR` A vs B) on one node.
19234    fn claude_config_dir_collision_fixture() -> (
19235        NodeShared,
19236        ResolvedEnvironmentProfileReceipt,
19237        ResolvedEnvironmentProfileReceipt,
19238    ) {
19239        let catalog = active_registry().unwrap();
19240        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19241        let control = runtime.native_launch_profile_control();
19242        let workspace = WorkspaceConfig::new(
19243            WorkspaceId::new("primary").unwrap(),
19244            std::env::current_dir().unwrap(),
19245        )
19246        .unwrap();
19247        let shared = NodeShared::new_with_incarnation(
19248            handle,
19249            "fixture-token".to_owned(),
19250            NodeId::new("node-env-collision-test").unwrap(),
19251            NodeIncarnationId::from_bytes([3; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19252            vec![workspace],
19253            vec![agent("claude")],
19254            ProviderRuntimeStatuses::default(),
19255            None,
19256            Vec::new(),
19257            Vec::new(),
19258            SpawnProfileRegistry::default(),
19259            Some(control),
19260            None,
19261            None,
19262            None,
19263            Vec::new(),
19264            Vec::new(),
19265            Vec::new(),
19266            Vec::new(),
19267            None,
19268            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19269        );
19270
19271        let mut receipts = Vec::new();
19272        for (suffix, native_suffix) in [("a", "a-pty"), ("b", "b-pty")] {
19273            let profile_id =
19274                SpawnEnvironmentProfileId::new(format!("claude-config-dir-{suffix}")).unwrap();
19275            let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(format!(
19276                "claude-config-dir-{suffix}-r1"
19277            ))
19278            .unwrap();
19279            let materialization = NodeSessionMaterializationProfile::new(
19280                Vec::new(),
19281                vec![NodeSessionPathBinding::new(
19282                    "CLAUDE_CONFIG_DIR",
19283                    NodeSessionPathClass::ProviderHome,
19284                )
19285                .unwrap()],
19286                Vec::new(),
19287            )
19288            .unwrap();
19289            let node_profile = NodeEnvironmentProfile::new_with_materialization(
19290                profile_id.clone(),
19291                profile_revision.clone(),
19292                agent("claude"),
19293                [NativeLaunchProfile::new(
19294                    NativeLaunchProfileId::new(format!("claude-config-dir-{native_suffix}"))
19295                        .unwrap(),
19296                    agent("claude"),
19297                    TransportKind::Pty,
19298                    vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19299                    Arc::new(EmptyEnvironmentResolver),
19300                )
19301                .unwrap()],
19302                Some(materialization),
19303            )
19304            .unwrap();
19305            let (binding, native_profiles, materialization) = node_profile.into_parts();
19306            for native_profile in native_profiles {
19307                runtime.upsert_native_launch_profile(native_profile).unwrap();
19308            }
19309            shared
19310                .environment_profiles
19311                .write()
19312                .unwrap_or_else(|poisoned| poisoned.into_inner())
19313                .insert(binding.id.clone(), binding);
19314            shared
19315                .environment_materialization_profiles
19316                .write()
19317                .unwrap_or_else(|poisoned| poisoned.into_inner())
19318                .insert(profile_id.clone(), materialization.unwrap());
19319            receipts.push(ResolvedEnvironmentProfileReceipt {
19320                profile_id,
19321                profile_revision,
19322                network_allowlist: None,
19323                browser_profile_id: None,
19324            });
19325        }
19326        let profile_b = receipts.pop().unwrap();
19327        let profile_a = receipts.pop().unwrap();
19328        (shared, profile_a, profile_b)
19329    }
19330
19331    #[test]
19332    fn refuse_distinct_claude_config_dir_profiles_on_same_workspace_cwd() {
19333        let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
19334        let primary = WorkspaceId::new("primary").unwrap();
19335        let worktree = WorkspaceId::new("worktree-b").unwrap();
19336        let address = SessionAddress {
19337            workspace_id: primary.clone(),
19338            session: SessionKey {
19339                instance_id: AgentInstanceId(41),
19340                generation: SessionGeneration(1),
19341            },
19342        };
19343        shared.bind_session_with_policy(
19344            &address,
19345            ProviderRuntimePolicy::raw_pty(),
19346            Some(profile_a.clone()),
19347        );
19348
19349        let refused = shared
19350            .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_b))
19351            .unwrap_err();
19352        assert_eq!(refused.code, NodeFailureCode::WorkspaceBusy);
19353
19354        // Some vs None on the same cwd is also a collision.
19355        let refused_none = shared
19356            .ensure_workspace_environment_profile_compatible(&primary, None)
19357            .unwrap_err();
19358        assert_eq!(refused_none.code, NodeFailureCode::WorkspaceBusy);
19359
19360        // Same profile on the same workspace remains allowed.
19361        shared
19362            .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_a))
19363            .unwrap();
19364
19365        // Distinct workspace id (managed-worktree parallel path) allows profile B.
19366        shared
19367            .ensure_workspace_environment_profile_compatible(&worktree, Some(&profile_b))
19368            .unwrap();
19369    }
19370
19371    #[tokio::test]
19372    async fn spawn_session_refuses_conflicting_environment_profile_before_mutation() {
19373        let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
19374        let primary = WorkspaceId::new("primary").unwrap();
19375        let address = SessionAddress {
19376            workspace_id: primary.clone(),
19377            session: SessionKey {
19378                instance_id: AgentInstanceId(41),
19379                generation: SessionGeneration(1),
19380            },
19381        };
19382        shared.bind_session_with_policy(
19383            &address,
19384            ProviderRuntimePolicy::raw_pty(),
19385            Some(profile_a),
19386        );
19387
19388        let failure = shared
19389            .spawn_session_with_deadline(
19390                primary,
19391                agent("claude"),
19392                SessionMode::Pty,
19393                gate4agent_types::TerminalSize {
19394                    rows: 24,
19395                    columns: 80,
19396                },
19397                None,
19398                Some(profile_b),
19399                None,
19400                None,
19401                None,
19402                None,
19403                SpawnRecordPolicy::ProviderIdentityOnly,
19404                Some(Instant::now() + Duration::from_secs(30)),
19405                &[],
19406                None,
19407                ApprovalLevel::default(),
19408                None,
19409                None,
19410            )
19411            .await
19412            .unwrap_err();
19413        assert_eq!(failure.code, NodeFailureCode::WorkspaceBusy);
19414        // Guard runs before instance id mint / bind.
19415        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
19416        assert!(shared.handle.snapshot().sessions.is_empty());
19417    }
19418}
19419
19420#[cfg(test)]
19421mod spawn_characterization_tests {
19422    use super::*;
19423    use crate::session_environment::{
19424        NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
19425        NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
19426        NodeSessionPathClass,
19427    };
19428    use gate4agent_catalog::EnvMutation;
19429    use gate4agent_runtime_native::{
19430        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
19431        NativeLaunchProfile, NativeLaunchProfileId,
19432    };
19433    use std::ffi::OsString;
19434    use std::sync::Arc;
19435
19436    struct EmptyEnvironmentResolver;
19437
19438    struct FixtureSecretResolver {
19439        deny: Arc<AtomicBool>,
19440    }
19441
19442    impl NodeSecretResolver for FixtureSecretResolver {
19443        fn resolve(
19444            &self,
19445            _reference: &NodeSecretReference,
19446        ) -> Result<NodeSecretValue, NodeSecretResolveError> {
19447            if self.deny.load(Ordering::Acquire) {
19448                Err(NodeSecretResolveError::Denied)
19449            } else {
19450                NodeSecretValue::text("fixture-secret")
19451                    .map_err(|_| NodeSecretResolveError::Unavailable)
19452            }
19453        }
19454    }
19455
19456    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19457        fn resolve_child_environment(
19458            &self,
19459        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19460            Ok(vec![EnvMutation {
19461                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19462                value: None,
19463            }])
19464        }
19465    }
19466
19467    fn agent(value: &str) -> AgentId {
19468        AgentId::new(value).unwrap()
19469    }
19470
19471    fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
19472        let unique = std::time::SystemTime::now()
19473            .duration_since(std::time::UNIX_EPOCH)
19474            .unwrap()
19475            .as_nanos();
19476        std::env::temp_dir().join(format!(
19477            "gate4agent-node-{label}-{}-{unique}",
19478            std::process::id(),
19479        ))
19480    }
19481
19482    fn materialization_test_shared(
19483        deny_secret: bool,
19484    ) -> (
19485        NodeShared,
19486        NativeRuntime,
19487        ResolvedEnvironmentProfileReceipt,
19488        PathBuf,
19489        Arc<AtomicBool>,
19490    ) {
19491        let workspace = WorkspaceConfig::new(
19492            WorkspaceId::new("primary").unwrap(),
19493            std::env::current_dir().unwrap(),
19494        )
19495        .unwrap();
19496        materialization_test_shared_with_workspace(deny_secret, workspace)
19497    }
19498
19499    fn materialization_test_shared_with_workspace(
19500        deny_secret: bool,
19501        workspace: WorkspaceConfig,
19502    ) -> (
19503        NodeShared,
19504        NativeRuntime,
19505        ResolvedEnvironmentProfileReceipt,
19506        PathBuf,
19507        Arc<AtomicBool>,
19508    ) {
19509        let root = temporary_workspace_root(if deny_secret {
19510            "materialization-denied"
19511        } else {
19512            "materialization-ready"
19513        });
19514        std::fs::create_dir_all(&root).unwrap();
19515        let materialization_root = root.join("session-environments");
19516        let deny = Arc::new(AtomicBool::new(deny_secret));
19517        let resolver = Arc::new(FixtureSecretResolver {
19518            deny: Arc::clone(&deny),
19519        });
19520        let materializer = SessionEnvironmentMaterializer::new(
19521            materialization_root,
19522            resolver,
19523        )
19524        .unwrap();
19525        let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
19526        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19527            "materialized-claude-r1",
19528        )
19529        .unwrap();
19530        let materialization = NodeSessionMaterializationProfile::new(
19531            vec![NodeSessionEnvironmentMutation::SetSecret {
19532                key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
19533                reference: NodeSecretReference::new("fixture-token").unwrap(),
19534            }],
19535            vec![NodeSessionPathBinding::new(
19536                "GATE4AGENT_PROVIDER_HOME",
19537                NodeSessionPathClass::ProviderHome,
19538            )
19539            .unwrap()],
19540            vec![NodeSessionFile::secret(
19541                NodeSessionPathClass::Config,
19542                "auth/token",
19543                NodeSecretReference::new("fixture-token").unwrap(),
19544            )
19545            .unwrap()],
19546        )
19547        .unwrap();
19548        let catalog = active_registry().unwrap();
19549        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19550        let control = runtime.native_launch_profile_control();
19551        let node_profile = NodeEnvironmentProfile::new_with_materialization(
19552            profile_id.clone(),
19553            profile_revision.clone(),
19554            agent("claude"),
19555            [NativeLaunchProfile::new(
19556                NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
19557                agent("claude"),
19558                TransportKind::Pty,
19559                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19560                Arc::new(EmptyEnvironmentResolver),
19561            )
19562            .unwrap()],
19563            Some(materialization),
19564        )
19565        .unwrap();
19566        let (binding, native_profiles, materialization) = node_profile.into_parts();
19567        for native_profile in native_profiles {
19568            runtime.upsert_native_launch_profile(native_profile).unwrap();
19569        }
19570        let shared = NodeShared::new_with_incarnation(
19571            handle,
19572            "fixture-token".to_owned(),
19573            NodeId::new("node-materialization-test").unwrap(),
19574            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19575            vec![workspace],
19576            vec![agent("claude")],
19577            ProviderRuntimeStatuses::default(),
19578            None,
19579            Vec::new(),
19580            Vec::new(),
19581            SpawnProfileRegistry::default(),
19582            Some(control),
19583            None,
19584            Some(materializer),
19585            None,
19586            Vec::new(),
19587            Vec::new(),
19588            Vec::new(),
19589            Vec::new(),
19590            None,
19591            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19592        );
19593        shared
19594            .environment_profiles
19595            .write()
19596            .unwrap_or_else(|poisoned| poisoned.into_inner())
19597            .insert(binding.id.clone(), binding);
19598        shared
19599            .environment_materialization_profiles
19600            .write()
19601            .unwrap_or_else(|poisoned| poisoned.into_inner())
19602            .insert(profile_id.clone(), materialization.unwrap());
19603        (
19604            shared,
19605            runtime,
19606            ResolvedEnvironmentProfileReceipt {
19607                profile_id,
19608                profile_revision,
19609                network_allowlist: None,
19610                browser_profile_id: None,
19611            },
19612            root,
19613            deny,
19614        )
19615    }
19616
19617
19618    // ---- Characterization of the spawn preparation block (pre-SpawnPreparer) ----
19619
19620    async fn characterization_spawn(
19621        shared: &NodeShared,
19622        mode: SessionMode,
19623        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
19624        required: &[ProviderRuntimeCapability],
19625        harness_mcp: Option<&PreparedHarnessMcpSpawn>,
19626    ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
19627        shared
19628            .spawn_session_with_deadline(
19629                WorkspaceId::new("primary").unwrap(),
19630                agent("claude"),
19631                mode,
19632                gate4agent_types::TerminalSize { rows: 24, columns: 80 },
19633                None,
19634                environment_profile,
19635                None,
19636                None,
19637                None,
19638                Some(ProviderRuntimePolicy::raw_pty()),
19639                SpawnRecordPolicy::ProviderIdentityOnly,
19640                Some(Instant::now() + Duration::from_secs(30)),
19641                required,
19642                harness_mcp,
19643                ApprovalLevel::default(),
19644                None,
19645                None,
19646            )
19647            .await
19648    }
19649
19650    fn assert_no_session_state(shared: &NodeShared) {
19651        assert!(shared.handle.snapshot().sessions.is_empty());
19652        assert!(shared
19653            .session_bindings
19654            .lock()
19655            .unwrap_or_else(|poisoned| poisoned.into_inner())
19656            .is_empty());
19657        assert!(shared
19658            .materializations
19659            .lock()
19660            .unwrap_or_else(|poisoned| poisoned.into_inner())
19661            .is_empty());
19662    }
19663
19664    #[tokio::test]
19665    async fn spawn_unadmitted_required_capability_refuses_before_id_mint() {
19666        let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
19667        let error = characterization_spawn(
19668            &shared,
19669            SessionMode::Pty,
19670            None,
19671            &[ProviderRuntimeCapability::SemanticReadiness],
19672            None,
19673        )
19674        .await
19675        .unwrap_err();
19676        assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
19677        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
19678        assert_no_session_state(&shared);
19679        drop(shared);
19680        drop(runtime);
19681        std::fs::remove_dir_all(root).unwrap();
19682    }
19683
19684    #[tokio::test]
19685    async fn spawn_denied_environment_materialization_fails_after_id_mint_with_no_state() {
19686        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
19687        let error = characterization_spawn(&shared, SessionMode::Pty, Some(receipt), &[], None)
19688            .await
19689            .unwrap_err();
19690        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
19691        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 2);
19692        assert_no_session_state(&shared);
19693        drop(shared);
19694        drop(runtime);
19695        std::fs::remove_dir_all(root).unwrap();
19696    }
19697
19698    #[test]
19699    fn remove_binding_returns_binding_and_is_idempotent_for_environment_session() {
19700        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
19701        let address = SessionAddress {
19702            workspace_id: WorkspaceId::new("primary").unwrap(),
19703            session: SessionKey {
19704                instance_id: AgentInstanceId(77),
19705                generation: SessionGeneration(1),
19706            },
19707        };
19708        shared.bind_session_with_policy(&address, ProviderRuntimePolicy::raw_pty(), Some(receipt));
19709        let removed = shared.remove_binding(&address).expect("binding removed");
19710        assert!(removed.environment_profile.is_some());
19711        assert!(shared.remove_binding(&address).is_none());
19712        assert_no_session_state(&shared);
19713        drop(shared);
19714        drop(runtime);
19715        std::fs::remove_dir_all(root).unwrap();
19716    }
19717
19718    async fn characterization_prepared_harness(provider: &str) -> PreparedHarnessMcpSpawn {
19719        use crate::harness_mcp_proxy::{HarnessMcpProxyRegistry, ReviewedHarnessMcpProgram};
19720        use crate::protocol::{HarnessMcpActivationDigest, HarnessMcpLaunchV1, HarnessMcpReservationId};
19721        let helper = ReviewedHarnessMcpProgram::review(std::env::current_exe().unwrap())
19722            .expect("review current test executable as fixture helper");
19723        let registry = HarnessMcpProxyRegistry::new(helper);
19724        let spec = crate::protocol::SpawnSpec {
19725            target: crate::protocol::SpawnTarget {
19726                node_id: NodeId::new("node-characterization").unwrap(),
19727                workspace_id: WorkspaceId::new("primary").unwrap(),
19728                worktree_id: None,
19729            },
19730            profile_id: crate::protocol::SpawnProfileId::new("claude").unwrap(),
19731            expected_profile_revision: crate::protocol::SpawnProfileRevision::new("r1").unwrap(),
19732            overrides: crate::protocol::SpawnOverrides::default(),
19733            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19734            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("characterization-harness").unwrap(),
19735            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19736        };
19737        let reservation = HarnessMcpReservationId::new(format!("hmcpres_{}", "c".repeat(24))).unwrap();
19738        let digest = HarnessMcpActivationDigest::new(format!("sha256:{}", "c".repeat(64))).unwrap();
19739        let launch = HarnessMcpLaunchV1 {
19740            server_name: "fixture-harness".to_owned(),
19741            args: vec!["--session-proxy".to_owned()],
19742            endpoint_env: "FIXTURE_SESSION_ENDPOINT".to_owned(),
19743            token_env: "FIXTURE_SESSION_TOKEN".to_owned(),
19744            program_env: "FIXTURE_MCP_PROGRAM".to_owned(),
19745            trace: None,
19746            scrub_env: Vec::new(),
19747        };
19748        let expires = unix_time_ms().saturating_add(30_000);
19749        registry.arm(reservation.clone(), digest.clone(), spec.clone(), launch, expires).await.unwrap();
19750        registry
19751            .prepare_spawn(&reservation, &digest, &spec, expires, agent(provider))
19752            .unwrap()
19753    }
19754
19755    #[tokio::test]
19756    async fn spawn_harness_overlay_refuses_inline_and_provider_mismatch_with_no_state() {
19757        let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
19758        let prepared = characterization_prepared_harness("claude").await;
19759        let error = characterization_spawn(&shared, SessionMode::Inline, None, &[], Some(&prepared))
19760            .await
19761            .unwrap_err();
19762        // Inline is stopped by runtime admission before the harness check runs.
19763        assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
19764        let mismatched = characterization_prepared_harness("codex").await;
19765        let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&mismatched))
19766            .await
19767            .unwrap_err();
19768        assert_eq!(error.code, NodeFailureCode::BindingMismatch);
19769        assert_no_session_state(&shared);
19770        drop(shared);
19771        drop(runtime);
19772        std::fs::remove_dir_all(root).unwrap();
19773    }
19774
19775    #[tokio::test]
19776    async fn spawn_harness_overlay_without_launch_control_is_unavailable_with_no_state() {
19777        let (mut shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
19778        shared.native_launch_profile_control = None;
19779        let prepared = characterization_prepared_harness("claude").await;
19780        let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&prepared))
19781            .await
19782            .unwrap_err();
19783        assert_eq!(error.code, NodeFailureCode::HarnessMcpUnavailable);
19784        assert_no_session_state(&shared);
19785        drop(shared);
19786        drop(runtime);
19787        std::fs::remove_dir_all(root).unwrap();
19788    }
19789}
19790
19791#[cfg(all(test, windows))]
19792mod tests {
19793    use super::*;
19794    use gate4agent_types::ProviderActivity;
19795    use crate::session_environment::{
19796        NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
19797        NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
19798        NodeSessionPathClass,
19799    };
19800    use gate4agent_catalog::EnvMutation;
19801    use gate4agent_runtime_native::{
19802        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
19803        NativeLaunchProfile, NativeLaunchProfileId,
19804    };
19805    use std::ffi::OsString;
19806    use std::sync::Arc;
19807
19808    struct EmptyEnvironmentResolver;
19809
19810    struct FixtureSecretResolver {
19811        deny: Arc<AtomicBool>,
19812    }
19813
19814    impl NodeSecretResolver for FixtureSecretResolver {
19815        fn resolve(
19816            &self,
19817            _reference: &NodeSecretReference,
19818        ) -> Result<NodeSecretValue, NodeSecretResolveError> {
19819            if self.deny.load(Ordering::Acquire) {
19820                Err(NodeSecretResolveError::Denied)
19821            } else {
19822                NodeSecretValue::text("fixture-secret")
19823                    .map_err(|_| NodeSecretResolveError::Unavailable)
19824            }
19825        }
19826    }
19827
19828    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19829        fn resolve_child_environment(
19830            &self,
19831        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19832            Ok(vec![EnvMutation {
19833                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19834                value: None,
19835            }])
19836        }
19837    }
19838
19839    fn agent(value: &str) -> AgentId {
19840        AgentId::new(value).unwrap()
19841    }
19842
19843    fn terminal_test_shared_with_mode(mode: WorktreeServiceMode) -> NodeShared {
19844        let catalog = active_registry().unwrap();
19845        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19846        let workspace = WorkspaceConfig::new(
19847            WorkspaceId::new("primary").unwrap(),
19848            std::env::current_dir().unwrap(),
19849        )
19850        .unwrap()
19851        .with_worktree_service_mode(mode);
19852        NodeShared::new(
19853            handle,
19854            "fixture-token".to_owned(),
19855            NodeId::new("node-terminal-test").unwrap(),
19856            vec![workspace],
19857            vec![agent("claude")],
19858        )
19859    }
19860
19861    fn terminal_test_shared() -> NodeShared {
19862        terminal_test_shared_with_mode(WorktreeServiceMode::Manual)
19863    }
19864
19865    #[cfg(feature = "fixture")]
19866    #[test]
19867    fn managed_worktree_v2_failure_probe_keeps_only_the_latest_code() {
19868        let probe = SpawnManagedWorktreeV2FailureProbe::default();
19869        assert_eq!(probe.latest_code(), None);
19870
19871        probe.record(Some(NodeFailureCode::ManagedWorktreeOwnershipConflict));
19872        assert_eq!(
19873            probe.latest_code(),
19874            Some(NodeFailureCode::ManagedWorktreeOwnershipConflict),
19875        );
19876
19877        probe.record(Some(NodeFailureCode::SpawnDeadlineExceeded));
19878        assert_eq!(
19879            probe.latest_code(),
19880            Some(NodeFailureCode::SpawnDeadlineExceeded),
19881        );
19882
19883        probe.record(None);
19884        assert_eq!(probe.latest_code(), None);
19885    }
19886
19887    #[test]
19888    fn launch_snapshot_is_authoritative_bounded_and_path_free() {
19889        let source = temporary_workspace_root("launch-inventory-source");
19890        let allocation = temporary_workspace_root("launch-inventory-allocation");
19891        std::fs::create_dir_all(&source).unwrap();
19892        std::fs::create_dir_all(&allocation).unwrap();
19893        let workspace_id = WorkspaceId::new("primary").unwrap();
19894        let mut workspace = WorkspaceConfig::new(workspace_id.clone(), &source)
19895            .unwrap()
19896            .with_worktree_service_mode(WorktreeServiceMode::Managed);
19897        for index in 0..MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE {
19898            workspace = workspace
19899                .with_managed_worktree_profile(
19900                    ManagedWorktreeProfile::new(
19901                        WorktreeProfileId::new(format!("profile-{index}")).unwrap(),
19902                        crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
19903                        &allocation,
19904                        "gate4agent",
19905                        "HEAD",
19906                        ManagedWorktreeRetention::RemoveWhenReleased,
19907                    )
19908                    .unwrap(),
19909                )
19910                .unwrap();
19911        }
19912        let overflow = workspace.clone().with_managed_worktree_profile(
19913            ManagedWorktreeProfile::new(
19914                WorktreeProfileId::new("overflow").unwrap(),
19915                crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
19916                &allocation,
19917                "gate4agent",
19918                "HEAD",
19919                ManagedWorktreeRetention::Retain,
19920            )
19921            .unwrap(),
19922        );
19923        assert!(matches!(
19924            overflow,
19925            Err(NodeServerError::ManagedWorktreeProfileCapacity {
19926                max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
19927                ..
19928            }),
19929        ));
19930
19931        let catalog = active_registry().unwrap();
19932        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19933        let shared = NodeShared::new(
19934            handle,
19935            "fixture-token".to_owned(),
19936            NodeId::new("node-launch-inventory").unwrap(),
19937            vec![workspace],
19938            vec![agent("claude")],
19939        );
19940        let snapshot = shared.snapshot();
19941        let launch = snapshot.launch_inventory.as_ref().unwrap();
19942        assert_eq!(launch.spawn_profiles.as_ref().unwrap().len(), 1);
19943        assert!(launch.bundles.as_ref().unwrap().is_empty());
19944        let profiles = snapshot.workspaces[0]
19945            .managed_worktree_profiles
19946            .as_ref()
19947            .unwrap();
19948        assert_eq!(
19949            snapshot.workspaces[0].worktree_service_mode,
19950            Some(WorktreeServiceMode::Managed),
19951        );
19952        assert_eq!(
19953            profiles.profiles.len(),
19954            MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
19955        );
19956        let public_inventory = serde_json::to_string(&(launch, profiles)).unwrap();
19957        for forbidden in ["allocation_root", "branch_prefix", "base", "prompt", "environment", "home"] {
19958            assert!(!public_inventory.contains(forbidden), "launch inventory leaked {forbidden}");
19959        }
19960        let legacy = snapshot_for_wire(
19961            &shared,
19962            true,
19963            true,
19964            false,
19965            false,
19966            true,
19967            false,
19968            true,
19969            false,
19970            true,
19971        );
19972        assert!(legacy.launch_inventory.is_none());
19973        assert!(legacy.workspaces[0].managed_worktree_profiles.is_none());
19974
19975        drop(shared);
19976        std::fs::remove_dir_all(source).unwrap();
19977        std::fs::remove_dir_all(allocation).unwrap();
19978    }
19979
19980    #[test]
19981    fn agent_progress_projection_omits_sensitive_provider_payloads_and_is_capability_gated() {
19982        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
19983            capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
19984        }));
19985        let source = gate4agent_types::ProviderSource {
19986            family: AdapterFamily::PtySemantic,
19987            binding: AdapterBinding::new(
19988                gate4agent_types::AdapterId::new("claude").unwrap(),
19989                "fixture/v1",
19990                gate4agent_types::AdapterVerification::SyntheticFixture,
19991            )
19992            .unwrap(),
19993        };
19994        let provider = ProviderSnapshot {
19995            sequence: 17,
19996            session: Some(ProviderSessionIdentity {
19997                key: ProviderSessionKey::SessionId,
19998                id: "PRIVATE_PROVIDER_SESSION".to_owned(),
19999                transcript_path: Some(r"C:\PRIVATE_PROVIDER_PATH".to_owned()),
20000            }),
20001            model: Some("PRIVATE_MODEL".to_owned()),
20002            tools: vec!["PRIVATE_TOOL_INVENTORY".to_owned()],
20003            completed_turns: 3,
20004            usage: gate4agent_types::TokenUsage {
20005                input_tokens: 10,
20006                output_tokens: 20,
20007                cache_read_tokens: 30,
20008                cache_write_tokens: 40,
20009                reasoning_tokens: 50,
20010                context_window: Some(200_000),
20011            },
20012            lead_activity: ProviderActivity::WaitingForInput,
20013            activity: ProviderActivity::WaitingForInput,
20014            current_prompt: Some("PRIVATE_CURRENT_PROMPT".to_owned()),
20015            active_tools: (0..9)
20016                .map(|index| gate4agent_types::ActiveProviderTool {
20017                    id: format!("private-tool-id-{index}"),
20018                    name: if index == 0 {
20019                        format!("  Read\u{0000}{}", "x".repeat(80))
20020                    } else {
20021                        format!("Tool-{index}")
20022                    },
20023                    input_json: "PRIVATE_TOOL_INPUT_JSON".to_owned(),
20024                })
20025                .collect(),
20026            interactions: vec![gate4agent_types::ProviderInteraction {
20027                id: gate4agent_types::ProviderInteractionId(17),
20028                source: source.clone(),
20029                provider_request_id: Some("PRIVATE_PROVIDER_REQUEST".to_owned()),
20030                interaction_kind: ProviderInteractionKind::Approval,
20031                tool_name: " Approve\u{0007}Tool ".to_owned(),
20032                prompt: "PRIVATE_INTERACTION_PROMPT".to_owned(),
20033                agent_id: Some("PRIVATE_INTERACTION_AGENT".to_owned()),
20034                resume_lead_activity: Some(ProviderActivity::Working),
20035                status: ProviderInteractionStatus::Pending,
20036            }],
20037            subagents: vec![gate4agent_types::ProviderSubagent {
20038                source: source.clone(),
20039                provider_agent_id: "PRIVATE_SUBAGENT_ID".to_owned(),
20040                agent_type: Some("PRIVATE_SUBAGENT_TYPE".to_owned()),
20041                description: Some("PRIVATE_SUBAGENT_DESCRIPTION".to_owned()),
20042            }],
20043            sources: vec![gate4agent_types::ProviderSourceCursor {
20044                source,
20045                sequence: 17,
20046                gap_count: 2,
20047                stale: true,
20048            }],
20049            last_event: Some(ProviderEvent::Error {
20050                message: "PRIVATE_PROVIDER_ERROR".to_owned(),
20051            }),
20052            gap_count: 2,
20053            stale: true,
20054        };
20055        let address = terminal_address(3);
20056        let entry = agent_progress_from_provider_snapshot(address.clone(), &provider).unwrap();
20057        assert_eq!(entry.address, address);
20058        assert_eq!(entry.progress.provider_sequence, 17);
20059        assert_eq!(entry.progress.active_tool_count, 9);
20060        assert_eq!(
20061            entry.progress.active_tool_labels.len(),
20062            MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS,
20063        );
20064        assert_eq!(entry.progress.subagent_count, 1);
20065        assert_eq!(entry.progress.last_event_kind, Some(AgentProgressEventKindV1::Error));
20066        assert!(entry.progress.truncated);
20067        let encoded = serde_json::to_string(&entry).unwrap();
20068        for forbidden in [
20069            "PRIVATE_PROVIDER_SESSION",
20070            "PRIVATE_PROVIDER_PATH",
20071            "PRIVATE_MODEL",
20072            "PRIVATE_TOOL_INVENTORY",
20073            "PRIVATE_CURRENT_PROMPT",
20074            "PRIVATE_TOOL_INPUT_JSON",
20075            "PRIVATE_PROVIDER_REQUEST",
20076            "PRIVATE_INTERACTION_PROMPT",
20077            "PRIVATE_INTERACTION_AGENT",
20078            "PRIVATE_SUBAGENT_ID",
20079            "PRIVATE_SUBAGENT_TYPE",
20080            "PRIVATE_SUBAGENT_DESCRIPTION",
20081            "PRIVATE_PROVIDER_ERROR",
20082            "input_json",
20083            "current_prompt",
20084            "session_id",
20085        ] {
20086            assert!(!encoded.contains(forbidden), "agent progress leaked {forbidden}");
20087        }
20088
20089        let snapshot = NodeSnapshot {
20090            node_id: NodeId::new("node-progress-test").unwrap(),
20091            enabled_providers: vec![agent("claude")],
20092            provider_runtime_statuses: ProviderRuntimeStatuses::default(),
20093            workspaces: Vec::new(),
20094            session_records: Vec::new(),
20095            managed_worktrees: Vec::new(),
20096            launch_inventory: None,
20097            agent_progress: vec![entry],
20098        };
20099        let mut legacy_reply = ResponseEnvelope {
20100            request_id: 1,
20101            result: Ok(NodeResponse::Snapshot {
20102                event_sequence: 0,
20103                controller: None,
20104                snapshot: snapshot.clone(),
20105            }),
20106        };
20107        project_response_without_agent_progress(&mut legacy_reply);
20108        let Ok(NodeResponse::Snapshot { snapshot: legacy, .. }) = legacy_reply.result else {
20109            panic!("agent progress projection changed response kind");
20110        };
20111        assert!(legacy.agent_progress.is_empty());
20112        assert_eq!(snapshot.agent_progress.len(), 1);
20113    }
20114
20115    fn terminal_address(generation: u64) -> SessionAddress {
20116        SessionAddress {
20117            workspace_id: WorkspaceId::new("primary").unwrap(),
20118            session: SessionKey {
20119                instance_id: AgentInstanceId(41),
20120                generation: SessionGeneration(generation),
20121            },
20122        }
20123    }
20124
20125    fn spawn_spec_fixture() -> SpawnSpec {
20126        SpawnSpec {
20127            target: crate::protocol::SpawnTarget {
20128                node_id: NodeId::new("node-terminal-test").unwrap(),
20129                workspace_id: WorkspaceId::new("primary").unwrap(),
20130                worktree_id: None,
20131            },
20132            profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
20133            expected_profile_revision: crate::protocol::SpawnProfileRevision::new("builtin-v1")
20134                .unwrap(),
20135            overrides: crate::protocol::SpawnOverrides::default(),
20136            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
20137            idempotency_key: SpawnIdempotencyKey::new("spawn-spec-fixture").unwrap(),
20138            required_capabilities: SpawnRequiredCapabilities::default(),
20139        }
20140    }
20141
20142    fn environment_profile_test_shared(
20143    ) -> (NodeShared, NativeRuntime, ResolvedEnvironmentProfileReceipt) {
20144        let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
20145        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20146            "local-claude-r1",
20147        )
20148        .unwrap();
20149        let spawn_profiles = SpawnProfileRegistry::new([
20150            crate::protocol::SpawnProfileDefaults {
20151                profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
20152                revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
20153                provider: agent("claude"),
20154                mode: SessionMode::Pty,
20155                terminal_size: gate4agent_types::TerminalSize {
20156                    rows: 24,
20157                    columns: 80,
20158                },
20159                prompt: None,
20160                bundle_id: None,
20161                context_id: None,
20162                environment_profile_id: Some(profile_id.clone()),
20163            },
20164        ])
20165        .unwrap();
20166        let catalog = active_registry().unwrap();
20167        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20168        let control = runtime.native_launch_profile_control();
20169        let node_profile = NodeEnvironmentProfile::new(
20170            profile_id.clone(),
20171            profile_revision.clone(),
20172            agent("claude"),
20173            [NativeLaunchProfile::new(
20174                NativeLaunchProfileId::new("local-claude-pty").unwrap(),
20175                agent("claude"),
20176                TransportKind::Pty,
20177                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20178                Arc::new(EmptyEnvironmentResolver),
20179            )
20180            .unwrap()],
20181        )
20182        .unwrap();
20183        let (binding, native_profiles, _materialization) = node_profile.into_parts();
20184        for native_profile in native_profiles {
20185            runtime.upsert_native_launch_profile(native_profile).unwrap();
20186        }
20187        let workspace = WorkspaceConfig::new(
20188            WorkspaceId::new("primary").unwrap(),
20189            std::env::current_dir().unwrap(),
20190        )
20191        .unwrap();
20192        let shared = NodeShared::new_with_incarnation(
20193            handle,
20194            "fixture-token".to_owned(),
20195            NodeId::new("node-terminal-test").unwrap(),
20196            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20197            vec![workspace],
20198            vec![agent("claude")],
20199            ProviderRuntimeStatuses::default(),
20200            None,
20201            Vec::new(),
20202            Vec::new(),
20203            spawn_profiles,
20204            Some(control),
20205            None,
20206            None,
20207            None,
20208            Vec::new(),
20209            Vec::new(),
20210            Vec::new(),
20211            Vec::new(),
20212            None,
20213            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20214        );
20215        shared
20216            .environment_profiles
20217            .write()
20218            .unwrap_or_else(|poisoned| poisoned.into_inner())
20219            .insert(binding.id.clone(), binding);
20220        (
20221            shared,
20222            runtime,
20223            ResolvedEnvironmentProfileReceipt {
20224                profile_id,
20225                profile_revision,
20226                network_allowlist: None,
20227                browser_profile_id: None,
20228            },
20229        )
20230    }
20231
20232    fn materialization_test_shared(
20233        deny_secret: bool,
20234    ) -> (
20235        NodeShared,
20236        NativeRuntime,
20237        ResolvedEnvironmentProfileReceipt,
20238        PathBuf,
20239        Arc<AtomicBool>,
20240    ) {
20241        let workspace = WorkspaceConfig::new(
20242            WorkspaceId::new("primary").unwrap(),
20243            std::env::current_dir().unwrap(),
20244        )
20245        .unwrap();
20246        materialization_test_shared_with_workspace(deny_secret, workspace)
20247    }
20248
20249    fn materialization_test_shared_with_workspace(
20250        deny_secret: bool,
20251        workspace: WorkspaceConfig,
20252    ) -> (
20253        NodeShared,
20254        NativeRuntime,
20255        ResolvedEnvironmentProfileReceipt,
20256        PathBuf,
20257        Arc<AtomicBool>,
20258    ) {
20259        let root = temporary_workspace_root(if deny_secret {
20260            "materialization-denied"
20261        } else {
20262            "materialization-ready"
20263        });
20264        std::fs::create_dir_all(&root).unwrap();
20265        let materialization_root = root.join("session-environments");
20266        let deny = Arc::new(AtomicBool::new(deny_secret));
20267        let resolver = Arc::new(FixtureSecretResolver {
20268            deny: Arc::clone(&deny),
20269        });
20270        let materializer = SessionEnvironmentMaterializer::new(
20271            materialization_root,
20272            resolver,
20273        )
20274        .unwrap();
20275        let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
20276        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20277            "materialized-claude-r1",
20278        )
20279        .unwrap();
20280        let materialization = NodeSessionMaterializationProfile::new(
20281            vec![NodeSessionEnvironmentMutation::SetSecret {
20282                key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
20283                reference: NodeSecretReference::new("fixture-token").unwrap(),
20284            }],
20285            vec![NodeSessionPathBinding::new(
20286                "GATE4AGENT_PROVIDER_HOME",
20287                NodeSessionPathClass::ProviderHome,
20288            )
20289            .unwrap()],
20290            vec![NodeSessionFile::secret(
20291                NodeSessionPathClass::Config,
20292                "auth/token",
20293                NodeSecretReference::new("fixture-token").unwrap(),
20294            )
20295            .unwrap()],
20296        )
20297        .unwrap();
20298        let catalog = active_registry().unwrap();
20299        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20300        let control = runtime.native_launch_profile_control();
20301        let node_profile = NodeEnvironmentProfile::new_with_materialization(
20302            profile_id.clone(),
20303            profile_revision.clone(),
20304            agent("claude"),
20305            [NativeLaunchProfile::new(
20306                NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
20307                agent("claude"),
20308                TransportKind::Pty,
20309                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20310                Arc::new(EmptyEnvironmentResolver),
20311            )
20312            .unwrap()],
20313            Some(materialization),
20314        )
20315        .unwrap();
20316        let (binding, native_profiles, materialization) = node_profile.into_parts();
20317        for native_profile in native_profiles {
20318            runtime.upsert_native_launch_profile(native_profile).unwrap();
20319        }
20320        let shared = NodeShared::new_with_incarnation(
20321            handle,
20322            "fixture-token".to_owned(),
20323            NodeId::new("node-materialization-test").unwrap(),
20324            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20325            vec![workspace],
20326            vec![agent("claude")],
20327            ProviderRuntimeStatuses::default(),
20328            None,
20329            Vec::new(),
20330            Vec::new(),
20331            SpawnProfileRegistry::default(),
20332            Some(control),
20333            None,
20334            Some(materializer),
20335            None,
20336            Vec::new(),
20337            Vec::new(),
20338            Vec::new(),
20339            Vec::new(),
20340            None,
20341            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20342        );
20343        shared
20344            .environment_profiles
20345            .write()
20346            .unwrap_or_else(|poisoned| poisoned.into_inner())
20347            .insert(binding.id.clone(), binding);
20348        shared
20349            .environment_materialization_profiles
20350            .write()
20351            .unwrap_or_else(|poisoned| poisoned.into_inner())
20352            .insert(profile_id.clone(), materialization.unwrap());
20353        (
20354            shared,
20355            runtime,
20356            ResolvedEnvironmentProfileReceipt {
20357                profile_id,
20358                profile_revision,
20359                network_allowlist: None,
20360                browser_profile_id: None,
20361            },
20362            root,
20363            deny,
20364        )
20365    }
20366
20367    #[cfg(feature = "fixture")]
20368    #[derive(Clone, Copy)]
20369    enum CodexHomeProfile {
20370        Missing,
20371        WrongClass,
20372        Exact,
20373    }
20374
20375    #[cfg(feature = "fixture")]
20376    fn codex_bundle_test_shared(
20377        home_profile: CodexHomeProfile,
20378    ) -> (
20379        NodeShared,
20380        NativeRuntime,
20381        ResolvedEnvironmentProfileReceipt,
20382        NodeBundle,
20383        PathBuf,
20384    ) {
20385        const ROOT_MANIFEST: &[u8] = br#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"review-tools"}"#;
20386        const CLAUDE_MANIFEST: &[u8] =
20387            br#"{"name":"review-tools","version":"1.0.0","description":"Review helpers"}"#;
20388        const SKILL: &[u8] = b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n";
20389        const DIGEST: &str =
20390            "sha256:941f20868a6ef49b4329bf1bb1368515763f5b64d8279b05cd9700966083d707";
20391
20392        let (shared, mut runtime, _claude_receipt, root, _deny) =
20393            materialization_test_shared(false);
20394        let profile_id = SpawnEnvironmentProfileId::new(match home_profile {
20395            CodexHomeProfile::Missing => "codex-home-missing",
20396            CodexHomeProfile::WrongClass => "codex-home-wrong",
20397            CodexHomeProfile::Exact => "codex-home-exact",
20398        })
20399        .unwrap();
20400        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20401            "codex-home-r1",
20402        )
20403        .unwrap();
20404        let materialization = match home_profile {
20405            CodexHomeProfile::Missing => None,
20406            CodexHomeProfile::WrongClass | CodexHomeProfile::Exact => {
20407                Some(NodeSessionMaterializationProfile::new(
20408                    Vec::new(),
20409                    vec![NodeSessionPathBinding::new(
20410                        "CODEX_HOME",
20411                        if matches!(home_profile, CodexHomeProfile::Exact) {
20412                            NodeSessionPathClass::ProviderHome
20413                        } else {
20414                            NodeSessionPathClass::Config
20415                        },
20416                    )
20417                    .unwrap()],
20418                    Vec::new(),
20419                )
20420                .unwrap())
20421            }
20422        };
20423        let node_profile = NodeEnvironmentProfile::new_with_materialization(
20424            profile_id.clone(),
20425            profile_revision.clone(),
20426            agent("codex"),
20427            [NativeLaunchProfile::new(
20428                NativeLaunchProfileId::new(format!("{profile_id}-pty")).unwrap(),
20429                agent("codex"),
20430                TransportKind::Pty,
20431                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20432                Arc::new(EmptyEnvironmentResolver),
20433            )
20434            .unwrap()],
20435            materialization,
20436        )
20437        .unwrap();
20438        let (binding, native_profiles, materialization) = node_profile.into_parts();
20439        for native_profile in native_profiles {
20440            runtime.upsert_native_launch_profile(native_profile).unwrap();
20441        }
20442        shared
20443            .environment_profiles
20444            .write()
20445            .unwrap_or_else(|poisoned| poisoned.into_inner())
20446            .insert(binding.id.clone(), binding);
20447        if let Some(materialization) = materialization {
20448            shared
20449                .environment_materialization_profiles
20450                .write()
20451                .unwrap_or_else(|poisoned| poisoned.into_inner())
20452                .insert(profile_id.clone(), materialization);
20453        }
20454
20455        let bundle_source = root.join(format!("bundle-source-{profile_id}"));
20456        std::fs::create_dir_all(bundle_source.join(".claude-plugin")).unwrap();
20457        std::fs::create_dir_all(bundle_source.join("skills/review-code")).unwrap();
20458        std::fs::write(bundle_source.join("plugin.json"), ROOT_MANIFEST).unwrap();
20459        std::fs::write(
20460            bundle_source.join(".claude-plugin/plugin.json"),
20461            CLAUDE_MANIFEST,
20462        )
20463        .unwrap();
20464        std::fs::write(bundle_source.join("skills/review-code/SKILL.md"), SKILL).unwrap();
20465        crate::bundle_catalog::protect_bundle_source_tree_fixture(&bundle_source).unwrap();
20466        let bundle = NodeBundle::new(
20467            crate::protocol::SpawnBundleId::new("review-tools").unwrap(),
20468            crate::protocol::SpawnBundleRevision::new("review-tools-r1").unwrap(),
20469            crate::protocol::SpawnBundleDigest::new(DIGEST).unwrap(),
20470            &bundle_source,
20471        )
20472        .unwrap();
20473        *shared
20474            .bundle_catalog
20475            .write()
20476            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
20477            BundleCatalog::new([bundle.clone()]).unwrap();
20478        (
20479            shared,
20480            runtime,
20481            ResolvedEnvironmentProfileReceipt {
20482                profile_id,
20483                profile_revision,
20484                network_allowlist: None,
20485                browser_profile_id: None,
20486            },
20487            bundle,
20488            root,
20489        )
20490    }
20491
20492    #[cfg(feature = "fixture")]
20493    #[test]
20494    fn codex_bundle_preflight_rejects_missing_or_wrong_home_before_materialization() {
20495        for home_profile in [CodexHomeProfile::Missing, CodexHomeProfile::WrongClass] {
20496            let (shared, runtime, receipt, bundle, root) =
20497                codex_bundle_test_shared(home_profile);
20498            let error = shared
20499                .bundle_layout(
20500                    &agent("codex"),
20501                    SessionMode::Pty,
20502                    Some(&receipt),
20503                    &bundle,
20504                )
20505                .unwrap_err();
20506            assert_eq!(error.code, NodeFailureCode::BundleBindingMismatch);
20507            assert!(shared
20508                .materializations
20509                .lock()
20510                .unwrap_or_else(|poisoned| poisoned.into_inner())
20511                .is_empty());
20512            let materialization_root = root.join("session-environments");
20513            assert!(!std::fs::read_dir(&materialization_root)
20514                .unwrap()
20515                .filter_map(Result::ok)
20516                .any(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())));
20517            drop(shared);
20518            drop(runtime);
20519            let _ = std::fs::remove_dir_all(root);
20520        }
20521    }
20522
20523    #[cfg(feature = "fixture")]
20524    #[test]
20525    fn codex_bundle_exact_home_profile_materializes_only_the_skill_tree() {
20526        let (shared, runtime, receipt, bundle, root) =
20527            codex_bundle_test_shared(CodexHomeProfile::Exact);
20528        let address = SessionAddress {
20529            workspace_id: WorkspaceId::new("primary").unwrap(),
20530            session: SessionKey {
20531                instance_id: AgentInstanceId(310),
20532                generation: SessionGeneration(1),
20533            },
20534        };
20535        let (overlay, guard) = shared
20536            .prepare_session_materialization(
20537                &address,
20538                &agent("codex"),
20539                SessionMode::Pty,
20540                Some(&receipt),
20541                Some(&bundle.receipt()),
20542                None,
20543                None,
20544            )
20545            .unwrap()
20546            .unwrap();
20547        assert!(matches!(overlay, Some(PreparedNativeLaunchOverlay::Instance(_))));
20548        let ownership = shared
20549            .materializations
20550            .lock()
20551            .unwrap_or_else(|poisoned| poisoned.into_inner())
20552            .get(guard.id().unwrap())
20553            .unwrap()
20554            .clone();
20555        let (profile, _, _) = shared
20556            .materialization_profile(
20557                &agent("codex"),
20558                SessionMode::Pty,
20559                Some(&receipt),
20560                Some(&bundle.receipt()),
20561                None,
20562            )
20563            .unwrap()
20564            .unwrap();
20565        let environment = shared
20566            .session_environment_materializer
20567            .as_ref()
20568            .unwrap()
20569            .resolve_environment(&ownership, &profile)
20570            .unwrap();
20571        assert!(environment.iter().any(|mutation| {
20572            mutation.key == OsString::from("CODEX_HOME")
20573                && mutation.value.as_deref() == Some(ownership.provider_home().as_os_str())
20574        }));
20575        assert_eq!(
20576            std::fs::read(ownership.provider_home().join("skills/review-code/SKILL.md"))
20577                .unwrap(),
20578            b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n",
20579        );
20580        assert!(std::fs::read_dir(ownership.bundle_root())
20581            .unwrap()
20582            .next()
20583            .is_none());
20584        drop(guard);
20585        drop(shared);
20586        drop(runtime);
20587        let _ = std::fs::remove_dir_all(root);
20588    }
20589
20590    #[cfg(feature = "fixture")]
20591    #[test]
20592    fn codex_cold_record_resume_revalidates_the_isolated_skill_tree() {
20593        let (shared, runtime, receipt, bundle, root) =
20594            codex_bundle_test_shared(CodexHomeProfile::Exact);
20595        let bundle_receipt = bundle.receipt();
20596        let address = SessionAddress {
20597            workspace_id: WorkspaceId::new("primary").unwrap(),
20598            session: SessionKey {
20599                instance_id: AgentInstanceId(311),
20600                generation: SessionGeneration(1),
20601            },
20602        };
20603        let (_overlay, guard) = shared
20604            .prepare_session_materialization(
20605                &address,
20606                &agent("codex"),
20607                SessionMode::Pty,
20608                Some(&receipt),
20609                Some(&bundle_receipt),
20610                None,
20611                None,
20612            )
20613            .unwrap()
20614            .unwrap();
20615        let materialization_id = guard.id().unwrap().clone();
20616        let skill_path = shared
20617            .materializations
20618            .lock()
20619            .unwrap_or_else(|poisoned| poisoned.into_inner())
20620            .get(&materialization_id)
20621            .unwrap()
20622            .provider_home()
20623            .join("skills/review-code/SKILL.md");
20624        let record_id = shared
20625            .bind_spawn_session_with_materialization(
20626                &address,
20627                agent("codex"),
20628                SessionMode::Pty,
20629                ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap(),
20630                SpawnRecordPolicy::ProviderIdentityOnly,
20631                Some(receipt.clone()),
20632                Some(bundle_receipt.clone()),
20633                None,
20634                Some(materialization_id.clone()),
20635            )
20636            .unwrap()
20637            .unwrap();
20638        guard.retain();
20639        std::fs::write(skill_path, b"changed-after-record-bind").unwrap();
20640
20641        let error = match shared.resolve_record_materialization(
20642                &record_id,
20643                &agent("codex"),
20644                SessionMode::Pty,
20645                Some(&receipt),
20646                Some(&bundle_receipt),
20647                None,
20648            ) {
20649                Ok(_) => panic!("changed Codex skill tree unexpectedly revalidated"),
20650                Err(error) => error,
20651            };
20652        assert_eq!(error.code, NodeFailureCode::BundleMaterializationFailed);
20653        assert_eq!(
20654            shared
20655                .materializations
20656                .lock()
20657                .unwrap_or_else(|poisoned| poisoned.into_inner())
20658                .get(&materialization_id)
20659                .unwrap()
20660                .state(),
20661            MaterializationState::RecoveryRequired,
20662        );
20663        drop(shared);
20664        drop(runtime);
20665        let _ = std::fs::remove_dir_all(root);
20666    }
20667
20668    fn bind_record_owned_materialization_fixture(
20669        shared: &NodeShared,
20670        receipt: &ResolvedEnvironmentProfileReceipt,
20671        instance_id: u64,
20672    ) -> (SessionAddress, SessionRecordId, MaterializationId, PathBuf) {
20673        let address = SessionAddress {
20674            workspace_id: WorkspaceId::new("primary").unwrap(),
20675            session: SessionKey {
20676                instance_id: AgentInstanceId(instance_id),
20677                generation: SessionGeneration(1),
20678            },
20679        };
20680        let (overlay, materialization_guard) = shared
20681            .prepare_session_materialization(
20682                &address,
20683                &agent("claude"),
20684                SessionMode::Pty,
20685                Some(receipt),
20686                None,
20687                None,
20688                None,
20689            )
20690            .unwrap()
20691            .unwrap();
20692        let materialization_id = materialization_guard.id().unwrap().clone();
20693        let materialization_root = shared
20694            .materializations
20695            .lock()
20696            .unwrap_or_else(|poisoned| poisoned.into_inner())
20697            .get(&materialization_id)
20698            .unwrap()
20699            .root()
20700            .to_path_buf();
20701        let selection = shared
20702            .select_environment_profile(
20703                address.session.instance_id,
20704                &agent("claude"),
20705                SessionMode::Pty,
20706                Some(receipt),
20707            )
20708            .unwrap()
20709            .unwrap();
20710        assert!(shared
20711            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
20712            .unwrap()
20713            .is_none());
20714        let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
20715        let record_id = shared
20716            .bind_spawn_session_with_materialization(
20717                &address,
20718                agent("claude"),
20719                SessionMode::Pty,
20720                policy,
20721                SpawnRecordPolicy::ProviderIdentityOnly,
20722                Some(receipt.clone()),
20723                None,
20724                None,
20725                Some(materialization_id.clone()),
20726            )
20727            .unwrap()
20728            .unwrap();
20729        selection.retain();
20730        materialization_guard.retain();
20731        (address, record_id, materialization_id, materialization_root)
20732    }
20733
20734    #[test]
20735    fn child_environment_profile_capability_is_state_aware_and_legacy_safe() {
20736        let (shared, _runtime, environment_profile) = environment_profile_test_shared();
20737        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20738            capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
20739        }));
20740        let support = node_compatibility_support_for_manifest(&[], &[]).unwrap();
20741        assert_eq!(support.state_schema.versions.maximum(), NODE_STATE_SCHEMA_V10);
20742        let mut spec = spawn_spec_fixture();
20743        spec.expected_profile_revision =
20744            crate::protocol::SpawnProfileRevision::new("test-r1").unwrap();
20745        assert!(matches!(
20746            &spec.overrides.environment_profile_id,
20747            crate::protocol::SpawnOverride::Inherit,
20748        ));
20749        assert!(!NodeRequest::SpawnSpec { spec: spec.clone() }
20750            .requires_child_environment_profile_capability());
20751        assert!(request_requires_child_environment_profile(
20752            &shared,
20753            &NodeRequest::SpawnSpec { spec: spec.clone() },
20754        ));
20755
20756        let address = terminal_address(1);
20757        shared.bind_session_with_policy(
20758            &address,
20759            ProviderRuntimePolicy::raw_pty(),
20760            Some(environment_profile.clone()),
20761        );
20762        assert!(request_requires_child_environment_profile(
20763            &shared,
20764            &NodeRequest::Input {
20765                session: address.clone(),
20766                text: "x".to_owned(),
20767            },
20768        ));
20769
20770        let mut record = record("claude", "profile-record");
20771        record.active_session = Some(address.clone());
20772        record.environment_profile = Some(environment_profile.clone());
20773        let record_id = record.record_id.clone();
20774        shared.insert_record(record.clone()).unwrap();
20775        assert!(request_requires_child_environment_profile(
20776            &shared,
20777            &NodeRequest::ResumeSessionRecord {
20778                record_id,
20779                terminal_size: gate4agent_types::TerminalSize {
20780                    rows: 24,
20781                    columns: 80,
20782                },
20783                initial_prompt: None,
20784            },
20785        ));
20786
20787        let snapshot = snapshot_for_wire(
20788            &shared, false, true, true, true, false, false, false, false, true,
20789        );
20790        assert_eq!(snapshot.session_records.len(), 1);
20791        assert!(snapshot.session_records[0].environment_profile.is_none());
20792        let event = project_event_without_child_environment_profile(NodeEventEnvelope {
20793            sequence: 1,
20794            event: NodeEvent::SessionRecordUpserted { record },
20795        });
20796        let NodeEvent::SessionRecordUpserted { record } = event.event else {
20797            panic!("record projection changed the event kind");
20798        };
20799        assert!(record.environment_profile.is_none());
20800
20801        let resolved = shared.resolve_spawn_spec(&spec).unwrap();
20802        let mut reply = ResponseEnvelope {
20803            request_id: 1,
20804            result: Ok(NodeResponse::SpawnSpecAccepted {
20805                receipt: resolved.receipt_with_environment(
20806                    shared.incarnation_id,
20807                    address,
20808                    Some(environment_profile),
20809                ),
20810            }),
20811        };
20812        project_response_without_child_environment_profile(&mut reply);
20813        assert_eq!(
20814            reply.result.unwrap_err().code,
20815            NodeFailureCode::UnsupportedCapability,
20816        );
20817    }
20818
20819    #[test]
20820    fn spawn_profile_environment_authority_is_exact_and_legacy_safe() {
20821        let (shared, _runtime, environment_profile) = environment_profile_test_shared();
20822        let snapshot = shared.snapshot();
20823        let profiles = snapshot
20824            .launch_inventory
20825            .as_ref()
20826            .and_then(|inventory| inventory.spawn_profiles.as_ref())
20827            .unwrap();
20828        assert_eq!(profiles.len(), 1);
20829        assert_eq!(profiles[0].environment_profile, Some(environment_profile));
20830        assert!(snapshot.requires_child_environment_profile_capability());
20831
20832        let mut legacy = snapshot.clone();
20833        clear_snapshot_child_environment_profiles(&mut legacy);
20834        assert!(legacy
20835            .launch_inventory
20836            .as_ref()
20837            .unwrap()
20838            .spawn_profiles
20839            .as_ref()
20840            .unwrap()[0]
20841            .environment_profile
20842            .is_none());
20843        assert!(!legacy.requires_child_environment_profile_capability());
20844
20845        let profile = shared.spawn_profiles.iter().next().unwrap().clone();
20846        let bindings = shared
20847            .environment_profiles
20848            .read()
20849            .unwrap_or_else(|poisoned| poisoned.into_inner())
20850            .clone();
20851        let mut provider_mismatch = bindings.clone();
20852        provider_mismatch
20853            .values_mut()
20854            .next()
20855            .unwrap()
20856            .provider = agent("codex");
20857        assert!(resolved_spawn_profile_summary(&profile, &provider_mismatch).is_none());
20858        let mut mode_mismatch = profile;
20859        mode_mismatch.mode = SessionMode::Inline;
20860        assert!(resolved_spawn_profile_summary(&mode_mismatch, &bindings).is_none());
20861
20862        shared
20863            .environment_profiles
20864            .write()
20865            .unwrap_or_else(|poisoned| poisoned.into_inner())
20866            .clear();
20867        assert!(shared
20868            .snapshot()
20869            .launch_inventory
20870            .unwrap()
20871            .spawn_profiles
20872            .unwrap()
20873            .is_empty());
20874    }
20875
20876    #[test]
20877    fn session_bundle_capability_is_state_aware_and_legacy_safe() {
20878        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20879            capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
20880        }));
20881        let shared = terminal_test_shared();
20882        let bundle = ResolvedBundleReceipt {
20883            id: crate::protocol::SpawnBundleId::new("review-bundle").unwrap(),
20884            revision: crate::protocol::SpawnBundleRevision::new("r1").unwrap(),
20885            digest: crate::protocol::SpawnBundleDigest::new(format!(
20886                "sha256:{}",
20887                "0".repeat(64),
20888            ))
20889            .unwrap(),
20890        };
20891        let mut bundled = record("claude", "bundle-record");
20892        bundled.bundle = Some(bundle.clone());
20893        let record_id = bundled.record_id.clone();
20894        shared.insert_record(bundled.clone()).unwrap();
20895        assert!(request_requires_session_bundle(
20896            &shared,
20897            &NodeRequest::ForgetSessionRecord {
20898                record_id: record_id.clone(),
20899            },
20900        ));
20901
20902        let snapshot = snapshot_for_wire(
20903            &shared, false, true, true, true, true, false, false, false, true,
20904        );
20905        assert!(snapshot.session_records[0].bundle.is_none());
20906        let launch = snapshot.launch_inventory.as_ref().unwrap();
20907        assert!(launch.spawn_profiles.is_some());
20908        assert!(launch.bundles.is_none());
20909        let event = project_event_without_session_bundle(NodeEventEnvelope {
20910            sequence: 1,
20911            event: NodeEvent::SessionRecordUpserted { record: bundled },
20912        });
20913        let NodeEvent::SessionRecordUpserted { record } = event.event else {
20914            panic!("record projection changed the event kind");
20915        };
20916        assert!(record.bundle.is_none());
20917
20918        let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
20919        let mut reply = ResponseEnvelope {
20920            request_id: 1,
20921            result: Ok(NodeResponse::SpawnSpecAccepted {
20922                receipt: resolved.receipt_with_materialization(
20923                    shared.incarnation_id,
20924                    terminal_address(17),
20925                    None,
20926                    Some(bundle),
20927                    None,
20928                ),
20929            }),
20930        };
20931        project_response_without_session_bundle(&mut reply);
20932        assert_eq!(
20933            reply.result.unwrap_err().code,
20934            NodeFailureCode::UnsupportedCapability,
20935        );
20936    }
20937
20938    #[test]
20939    fn history_context_pack_capability_redacts_loaded_messages_and_legacy_metadata() {
20940        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20941            capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
20942        }));
20943        let context_id = SpawnContextId::new("context-review").unwrap();
20944        let context = ResolvedContextPackReceipt {
20945            id: context_id.clone(),
20946            digest: crate::protocol::SpawnContextDigest::new(format!(
20947                "sha256:{}",
20948                "c".repeat(64),
20949            ))
20950            .unwrap(),
20951            lineage: ContextPackLineageReceipt {
20952                source_node_id: NodeId::new("node-source").unwrap(),
20953                source_session: terminal_address(1),
20954                source_provider: agent("kimi"),
20955            },
20956            source_message_count: 2,
20957            retained_message_count: 2,
20958            byte_len: 256,
20959            truncated: false,
20960        };
20961        assert!(context.is_valid());
20962        let mut bound_record = record("codex", "context-record");
20963        bound_record.context_id = Some(context_id);
20964        bound_record.context = Some(context.clone());
20965        let session = gate4agent_types::SessionSnapshot {
20966            instance_id: AgentInstanceId(91),
20967            agent_id: agent("kimi"),
20968            transport: TransportKind::Pty,
20969            generation: SessionGeneration(3),
20970            status: SessionStatus::Running,
20971            pending_operation: None,
20972            pending_input: None,
20973            process_id: Some(9001),
20974            terminal_size: None,
20975            terminal_frame: None,
20976            terminal_stale: None,
20977            session_options: None,
20978            capabilities: gate4agent_types::CapabilitySnapshot::default(),
20979            history: gate4agent_types::HistorySnapshot {
20980                pending: None,
20981                candidates: vec![HistoryCandidateSummary {
20982                    id: "candidate-opaque".to_owned(),
20983                    session_id_hint: "session-hint".to_owned(),
20984                    modified_at_unix_ms: Some(1),
20985                }],
20986                loaded_candidate_id: Some("candidate-opaque".to_owned()),
20987                loaded: Some(HistorySessionRecord {
20988                    session_id: "private-vendor-session".to_owned(),
20989                    title: Some("private title".to_owned()),
20990                    cwd: Some(r"C:\private\history-root".to_owned()),
20991                    model: Some("private-model".to_owned()),
20992                    message_count: 2,
20993                    completed_turn_count: None,
20994                    total_tokens: 19,
20995                    messages: vec![
20996                        gate4agent_types::HistoryMessageRecord {
20997                            role: gate4agent_types::HistoryMessageRole::User,
20998                            text: "F7_PRIVATE_HISTORY_MESSAGE".to_owned(),
20999                        },
21000                        gate4agent_types::HistoryMessageRecord {
21001                            role: gate4agent_types::HistoryMessageRole::Assistant,
21002                            text: "private answer".to_owned(),
21003                        },
21004                    ],
21005                }),
21006                last_error: None,
21007            },
21008            resume: gate4agent_types::ResumeSnapshot::default(),
21009            foreground: gate4agent_types::ForegroundSnapshot::default(),
21010            provider: gate4agent_types::ProviderSnapshot::default(),
21011            screen_state: gate4agent_types::PtyScreenState::default(),
21012        };
21013        let original = NodeSnapshot {
21014            node_id: NodeId::new("node-terminal-test").unwrap(),
21015            enabled_providers: vec![agent("kimi"), agent("codex")],
21016            provider_runtime_statuses: ProviderRuntimeStatuses::default(),
21017            workspaces: vec![WorkspaceSnapshot {
21018                workspace_id: WorkspaceId::new("primary").unwrap(),
21019                canonical_root: opaque_windows_path(
21020                    std::env::current_dir().unwrap().to_string_lossy().into_owned(),
21021                ),
21022                sessions: vec![session],
21023                worktree_service_mode: None,
21024                managed_worktree_profiles: None,
21025            }],
21026            session_records: vec![bound_record],
21027            managed_worktrees: Vec::new(),
21028            launch_inventory: None,
21029            agent_progress: Vec::new(),
21030        };
21031
21032        let mut negotiated = original.clone();
21033        project_snapshot_history_for_wire(&mut negotiated, true);
21034        assert_eq!(negotiated.workspaces[0].sessions[0].history.candidates.len(), 1);
21035        assert!(negotiated.workspaces[0].sessions[0].history.loaded.is_none());
21036        assert_eq!(negotiated.session_records[0].context.as_ref(), Some(&context));
21037        assert!(!serde_json::to_string(&negotiated)
21038            .unwrap()
21039            .contains("F7_PRIVATE_HISTORY_MESSAGE"));
21040
21041        let mut legacy = original;
21042        project_snapshot_history_for_wire(&mut legacy, false);
21043        clear_snapshot_context_packs(&mut legacy);
21044        assert_eq!(
21045            legacy.workspaces[0].sessions[0].history,
21046            gate4agent_types::HistorySnapshot::default(),
21047        );
21048        assert!(legacy.session_records[0].context_id.is_none());
21049        assert!(legacy.session_records[0].context.is_none());
21050        let encoded = serde_json::to_string(&legacy).unwrap();
21051        assert!(!encoded.contains("F7_PRIVATE_HISTORY_MESSAGE"));
21052        assert!(!encoded.contains("context-review"));
21053
21054        let mut reply = ResponseEnvelope {
21055            request_id: 7,
21056            result: Ok(NodeResponse::ContextPackExported { context }),
21057        };
21058        project_response_without_context_pack(&mut reply);
21059        assert_eq!(
21060            reply.result.unwrap_err().code,
21061            NodeFailureCode::UnsupportedCapability,
21062        );
21063    }
21064
21065    #[test]
21066    fn forgetting_unbound_context_pack_evicts_replay_but_retained_record_is_busy() {
21067        let shared = terminal_test_shared();
21068        let history = HistorySessionRecord {
21069            session_id: "fixture-history".to_owned(),
21070            title: None,
21071            cwd: None,
21072            model: None,
21073            message_count: 1,
21074            completed_turn_count: None,
21075            total_tokens: 0,
21076            messages: vec![gate4agent_types::HistoryMessageRecord {
21077                role: gate4agent_types::HistoryMessageRole::User,
21078                text: "bounded handoff".to_owned(),
21079            }],
21080        };
21081        let pack = NodeContextPack::export(
21082            ContextPackLineageReceipt {
21083                source_node_id: shared.node_id.clone(),
21084                source_session: terminal_address(1),
21085                source_provider: agent("claude"),
21086            },
21087            &history,
21088        )
21089        .unwrap();
21090        let context = shared
21091            .context_catalog
21092            .write()
21093            .unwrap_or_else(|poisoned| poisoned.into_inner())
21094            .insert(pack)
21095            .unwrap();
21096        let mut retained = record("codex", "context-retained-record");
21097        retained.context_id = Some(context.id.clone());
21098        retained.context = Some(context.clone());
21099        let record_id = retained.record_id.clone();
21100        shared.insert_record(retained).unwrap();
21101        assert_eq!(
21102            shared.forget_context_pack(&context.id).unwrap_err().code,
21103            NodeFailureCode::ContextPackBusy,
21104        );
21105        assert!(shared
21106            .context_catalog
21107            .read()
21108            .unwrap_or_else(|poisoned| poisoned.into_inner())
21109            .get(&context.id)
21110            .is_some());
21111        shared
21112            .session_records
21113            .lock()
21114            .unwrap_or_else(|poisoned| poisoned.into_inner())
21115            .records
21116            .remove(&record_id);
21117
21118        let mut spec = spawn_spec_fixture();
21119        spec.overrides.context_id = crate::protocol::SpawnOverride::Set {
21120            value: context.id.clone(),
21121        };
21122        let resolved = shared.resolve_spawn_spec(&spec).unwrap();
21123        let receipt = resolved.receipt_with_materialization(
21124            shared.incarnation_id,
21125            terminal_address(2),
21126            None,
21127            None,
21128            Some(context.clone()),
21129        );
21130        assert!(receipt.context_binding_is_valid());
21131        shared.remember_spawn_spec(spec.clone(), Ok(receipt), Instant::now());
21132
21133        shared.forget_context_pack(&context.id).unwrap();
21134        assert!(shared
21135            .context_catalog
21136            .read()
21137            .unwrap_or_else(|poisoned| poisoned.into_inner())
21138            .get(&context.id)
21139            .is_none());
21140        assert!(shared
21141            .replay_spawn_spec(&spec, Instant::now())
21142            .unwrap()
21143            .is_none());
21144    }
21145
21146    #[test]
21147    fn environment_profile_selection_is_owned_by_the_exact_session_binding() {
21148        let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
21149        let address = terminal_address(1);
21150        let selection = shared
21151            .select_environment_profile(
21152                address.session.instance_id,
21153                &agent("claude"),
21154                SessionMode::Pty,
21155                Some(&environment_profile),
21156            )
21157            .unwrap()
21158            .unwrap();
21159        shared.bind_session_with_policy(
21160            &address,
21161            ProviderRuntimePolicy::raw_pty(),
21162            Some(environment_profile.clone()),
21163        );
21164        selection.retain();
21165        let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
21166        assert!(matches!(
21167            runtime.remove_native_launch_profile(&native_id),
21168            Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
21169        ));
21170        assert!(shared.remove_binding(&address).is_some());
21171        assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
21172
21173        let mismatched = ResolvedEnvironmentProfileReceipt {
21174            profile_id: environment_profile.profile_id,
21175            profile_revision: crate::protocol::SpawnEnvironmentProfileRevision::new(
21176                "local-claude-r2",
21177            )
21178            .unwrap(),
21179            network_allowlist: None,
21180            browser_profile_id: None,
21181        };
21182        let error = match shared.select_environment_profile(
21183                AgentInstanceId(42),
21184                &agent("claude"),
21185                SessionMode::Pty,
21186                Some(&mismatched),
21187            ) {
21188            Ok(_) => panic!("mismatched environment profile revision was selected"),
21189            Err(error) => error,
21190        };
21191        assert_eq!(
21192            error.code,
21193            NodeFailureCode::EnvironmentProfileBindingMismatch,
21194        );
21195        assert!(!shared
21196            .session_bindings
21197            .lock()
21198            .unwrap_or_else(|poisoned| poisoned.into_inner())
21199            .contains_key(&AgentInstanceId(42)));
21200    }
21201
21202    #[test]
21203    fn combined_environment_and_bundle_overlay_clears_with_its_binding() {
21204        let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
21205        let address = terminal_address(1);
21206        let selection = shared
21207            .select_environment_profile(
21208                address.session.instance_id,
21209                &agent("claude"),
21210                SessionMode::Pty,
21211                Some(&environment_profile),
21212            )
21213            .unwrap()
21214            .unwrap();
21215        let overlay = NativeInstanceLaunchOverlay::new(
21216            agent("claude"),
21217            TransportKind::Pty,
21218            vec![EnvMutation {
21219                key: OsString::from("GATE4AGENT_SESSION_TOKEN"),
21220                value: Some(OsString::from("fixture-secret")),
21221            }],
21222            vec![
21223                OsString::from("--plugin-dir"),
21224                std::env::current_dir().unwrap().into_os_string(),
21225            ],
21226        )
21227        .unwrap();
21228        let instance_overlay = shared
21229            .install_prepared_launch_overlay(
21230                address.session.instance_id,
21231                PreparedNativeLaunchOverlay::Instance(overlay),
21232            )
21233            .unwrap()
21234            .unwrap();
21235        let bundle = ResolvedBundleReceipt {
21236            id: crate::protocol::SpawnBundleId::new("combined-bundle").unwrap(),
21237            revision: crate::protocol::SpawnBundleRevision::new("combined-r1").unwrap(),
21238            digest: crate::protocol::SpawnBundleDigest::new(format!(
21239                "sha256:{}",
21240                "0".repeat(64),
21241            ))
21242            .unwrap(),
21243        };
21244        shared.bind_session_with_materialization(
21245            &address,
21246            ProviderRuntimePolicy::raw_pty(),
21247            Some(environment_profile),
21248            Some(bundle),
21249            None,
21250            None,
21251        );
21252        selection.retain();
21253        instance_overlay.retain();
21254
21255        let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
21256        assert!(matches!(
21257            runtime.remove_native_launch_profile(&native_id),
21258            Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
21259        ));
21260        assert!(shared.remove_binding(&address).is_some());
21261        assert!(!shared
21262            .native_launch_profile_control
21263            .as_ref()
21264            .unwrap()
21265            .clear_native_instance_launch_overlay(address.session.instance_id));
21266        assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
21267    }
21268
21269    #[test]
21270    fn session_environment_materialization_failure_is_pre_child_and_durable() {
21271        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
21272        let address = SessionAddress {
21273            workspace_id: WorkspaceId::new("primary").unwrap(),
21274            session: SessionKey {
21275                instance_id: AgentInstanceId(201),
21276                generation: SessionGeneration(1),
21277            },
21278        };
21279        let error = match shared.prepare_session_materialization(
21280            &address,
21281            &agent("claude"),
21282            SessionMode::Pty,
21283            Some(&receipt),
21284            None,
21285            None,
21286            None,
21287        ) {
21288            Ok(_) => panic!("denied local reference unexpectedly materialized"),
21289            Err(error) => error,
21290        };
21291        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21292        assert!(shared.handle.snapshot().sessions.is_empty());
21293        assert!(shared
21294            .session_bindings
21295            .lock()
21296            .unwrap_or_else(|poisoned| poisoned.into_inner())
21297            .is_empty());
21298        assert!(shared
21299            .materializations
21300            .lock()
21301            .unwrap_or_else(|poisoned| poisoned.into_inner())
21302            .is_empty());
21303        drop(shared);
21304        drop(runtime);
21305        std::fs::remove_dir_all(root).unwrap();
21306    }
21307
21308    #[test]
21309    fn session_environment_binding_retains_on_stop_and_cleans_after_remove_reap() {
21310        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21311        let address = SessionAddress {
21312            workspace_id: WorkspaceId::new("primary").unwrap(),
21313            session: SessionKey {
21314                instance_id: AgentInstanceId(202),
21315                generation: SessionGeneration(1),
21316            },
21317        };
21318        let (overlay, materialization_guard) = shared
21319            .prepare_session_materialization(
21320                &address,
21321                &agent("claude"),
21322                SessionMode::Pty,
21323                Some(&receipt),
21324                None,
21325                None,
21326                None,
21327            )
21328            .unwrap()
21329            .unwrap();
21330        let materialization_id = materialization_guard.id().unwrap().clone();
21331        let materialization_root = shared
21332            .materializations
21333            .lock()
21334            .unwrap_or_else(|poisoned| poisoned.into_inner())
21335            .get(&materialization_id)
21336            .unwrap()
21337            .root()
21338            .to_path_buf();
21339        let selection = shared
21340            .select_environment_profile(
21341                address.session.instance_id,
21342                &agent("claude"),
21343                SessionMode::Pty,
21344                Some(&receipt),
21345            )
21346            .unwrap()
21347            .unwrap();
21348        assert!(shared
21349            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21350            .unwrap()
21351            .is_none());
21352        shared.bind_session_with_materialization(
21353            &address,
21354            ProviderRuntimePolicy::raw_pty(),
21355            Some(receipt),
21356            None,
21357            None,
21358            Some(materialization_id),
21359        );
21360        selection.retain();
21361        materialization_guard.retain();
21362
21363        assert!(materialization_root.is_dir());
21364        assert!(shared
21365            .session_bindings
21366            .lock()
21367            .unwrap_or_else(|poisoned| poisoned.into_inner())
21368            .contains_key(&address.session.instance_id));
21369
21370        let removed = shared.remove_binding(&address).unwrap();
21371        shared
21372            .cleanup_session_owned_materialization(&address, &removed)
21373            .unwrap();
21374        assert!(!materialization_root.exists());
21375        assert!(shared
21376            .materializations
21377            .lock()
21378            .unwrap_or_else(|poisoned| poisoned.into_inner())
21379            .is_empty());
21380        drop(shared);
21381        drop(runtime);
21382        std::fs::remove_dir_all(root).unwrap();
21383    }
21384
21385    #[tokio::test]
21386    async fn record_owned_session_environment_reuses_on_resume_and_forget_cleans() {
21387        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21388        let address = SessionAddress {
21389            workspace_id: WorkspaceId::new("primary").unwrap(),
21390            session: SessionKey {
21391                instance_id: AgentInstanceId(203),
21392                generation: SessionGeneration(1),
21393            },
21394        };
21395        let (overlay, materialization_guard) = shared
21396            .prepare_session_materialization(
21397                &address,
21398                &agent("claude"),
21399                SessionMode::Pty,
21400                Some(&receipt),
21401                None,
21402                None,
21403                None,
21404            )
21405            .unwrap()
21406            .unwrap();
21407        let materialization_id = materialization_guard.id().unwrap().clone();
21408        let materialization_root = shared
21409            .materializations
21410            .lock()
21411            .unwrap_or_else(|poisoned| poisoned.into_inner())
21412            .get(&materialization_id)
21413            .unwrap()
21414            .root()
21415            .to_path_buf();
21416        let selection = shared
21417            .select_environment_profile(
21418                address.session.instance_id,
21419                &agent("claude"),
21420                SessionMode::Pty,
21421                Some(&receipt),
21422            )
21423            .unwrap()
21424            .unwrap();
21425        assert!(shared
21426            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21427            .unwrap()
21428            .is_none());
21429        let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
21430        let record_id = shared
21431            .bind_spawn_session_with_materialization(
21432                &address,
21433                agent("claude"),
21434                SessionMode::Pty,
21435                policy,
21436                SpawnRecordPolicy::ProviderIdentityOnly,
21437                Some(receipt.clone()),
21438                None,
21439                None,
21440                Some(materialization_id.clone()),
21441            )
21442            .unwrap()
21443            .unwrap();
21444        selection.retain();
21445        materialization_guard.retain();
21446        assert_eq!(
21447            shared
21448                .materializations
21449                .lock()
21450                .unwrap_or_else(|poisoned| poisoned.into_inner())
21451                .get(&materialization_id)
21452                .unwrap()
21453                .owner(),
21454            &MaterializationOwner::Record {
21455                record_id: record_id.clone(),
21456            },
21457        );
21458
21459        let removed = shared.remove_binding(&address).unwrap();
21460        shared
21461            .cleanup_session_owned_materialization(&address, &removed)
21462            .unwrap();
21463        assert!(materialization_root.is_dir());
21464        assert!(shared
21465            .resolve_record_materialization(
21466                &record_id,
21467                &agent("claude"),
21468                SessionMode::Pty,
21469                Some(&receipt),
21470                None,
21471                None,
21472            )
21473            .unwrap()
21474            .is_some());
21475        {
21476            let mut records = shared
21477                .session_records
21478                .lock()
21479                .unwrap_or_else(|poisoned| poisoned.into_inner());
21480            let record = records.records.get_mut(&record_id).unwrap();
21481            record.active_session = None;
21482            record.state = ManagedSessionState::Unavailable;
21483        }
21484        shared.forget_session_record(&record_id).await.unwrap();
21485        assert!(!materialization_root.exists());
21486        assert!(shared
21487            .materializations
21488            .lock()
21489            .unwrap_or_else(|poisoned| poisoned.into_inner())
21490            .is_empty());
21491        drop(shared);
21492        drop(runtime);
21493        std::fs::remove_dir_all(root).unwrap();
21494    }
21495
21496    #[test]
21497    fn record_materialization_revalidation_failure_persists_recovery_required() {
21498        let (mut shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21499        let state_path = root.join("node-state-v6.json");
21500        shared.state_path = Some(state_path.clone());
21501        let (_address, _record_id, materialization_id, materialization_root) =
21502            bind_record_owned_materialization_fixture(&shared, &receipt, 205);
21503        std::fs::write(
21504            materialization_root.join(".gate4agent-materialization-owner"),
21505            b"tampered",
21506        )
21507        .unwrap();
21508
21509        let error = match shared.resolve_record_materialization(
21510            &_record_id,
21511            &agent("claude"),
21512            SessionMode::Pty,
21513            Some(&receipt),
21514            None,
21515            None,
21516        ) {
21517            Ok(_) => panic!("tampered owner marker unexpectedly revalidated"),
21518            Err(error) => error,
21519        };
21520        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21521        assert_eq!(
21522            shared
21523                .materializations
21524                .lock()
21525                .unwrap_or_else(|poisoned| poisoned.into_inner())
21526                .get(&materialization_id)
21527                .unwrap()
21528                .state(),
21529            MaterializationState::RecoveryRequired,
21530        );
21531        let loaded = session_registry::load(Some(&state_path), &shared.node_id).unwrap();
21532        assert_eq!(loaded.materializations.len(), 1);
21533        assert_eq!(
21534            loaded.materializations[0].state(),
21535            MaterializationState::RecoveryRequired,
21536        );
21537        drop(shared);
21538        drop(runtime);
21539        std::fs::remove_dir_all(root).unwrap();
21540    }
21541
21542    #[test]
21543    fn transient_record_materialization_resolution_failure_remains_ready() {
21544        let (shared, runtime, receipt, root, deny) = materialization_test_shared(false);
21545        let (_address, record_id, materialization_id, _materialization_root) =
21546            bind_record_owned_materialization_fixture(&shared, &receipt, 206);
21547        deny.store(true, Ordering::Release);
21548
21549        let error = match shared.resolve_record_materialization(
21550            &record_id,
21551            &agent("claude"),
21552            SessionMode::Pty,
21553            Some(&receipt),
21554            None,
21555            None,
21556        ) {
21557            Ok(_) => panic!("denied transient resolver unexpectedly succeeded"),
21558            Err(error) => error,
21559        };
21560        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21561        assert_eq!(
21562            shared
21563                .materializations
21564                .lock()
21565                .unwrap_or_else(|poisoned| poisoned.into_inner())
21566                .get(&materialization_id)
21567                .unwrap()
21568                .state(),
21569            MaterializationState::Ready,
21570        );
21571        drop(shared);
21572        drop(runtime);
21573        std::fs::remove_dir_all(root).unwrap();
21574    }
21575
21576    #[test]
21577    fn startup_missing_record_materialization_is_not_resumable() {
21578        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21579        let record_id = SessionRecordId::new("sr-missing-materialization").unwrap();
21580        shared
21581            .insert_record(ManagedSessionRecord {
21582                record_id: record_id.clone(),
21583                display_name: "missing materialization".to_owned(),
21584                provider: agent("claude"),
21585                mode: SessionMode::Pty,
21586                state: ManagedSessionState::Dormant,
21587                workspace_id: WorkspaceId::new("primary").unwrap(),
21588                canonical_root: opaque_windows_path(
21589                    std::env::current_dir().unwrap().to_string_lossy().into_owned(),
21590                ),
21591                provider_session: Some(gate4agent_types::ProviderSessionIdentity {
21592                    key: gate4agent_types::ProviderSessionKey::SessionId,
21593                    id: "provider-session-missing-materialization".to_owned(),
21594                    transcript_path: None,
21595                }),
21596                active_session: None,
21597                environment_profile: Some(receipt),
21598                bundle: None,
21599                context_id: None,
21600                context: None,
21601                exported_context: None,
21602                task_binding: None,
21603                created_at_unix_ms: 1,
21604                updated_at_unix_ms: 1,
21605                last_error: None,
21606            })
21607            .unwrap();
21608
21609        shared.reconcile_materializations();
21610        let record = shared.record(&record_id).unwrap();
21611        assert_eq!(record.state, ManagedSessionState::Unavailable);
21612        assert!(record.provider_session.is_none());
21613        assert_eq!(
21614            record.last_error.as_deref(),
21615            Some("environment-profile-unavailable"),
21616        );
21617        drop(shared);
21618        drop(runtime);
21619        std::fs::remove_dir_all(root).unwrap();
21620    }
21621
21622    #[tokio::test]
21623    async fn managed_worktree_cleanup_waits_for_session_environment_cleanup() {
21624        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21625        let lease = install_managed_lease(&shared);
21626        let address = SessionAddress {
21627            workspace_id: WorkspaceId::new("primary").unwrap(),
21628            session: SessionKey {
21629                instance_id: AgentInstanceId(204),
21630                generation: SessionGeneration(1),
21631            },
21632        };
21633        let (overlay, materialization_guard) = shared
21634            .prepare_session_materialization(
21635                &address,
21636                &agent("claude"),
21637                SessionMode::Pty,
21638                Some(&receipt),
21639                None,
21640                None,
21641                Some(lease.lease_id.clone()),
21642            )
21643            .unwrap()
21644            .unwrap();
21645        let materialization_id = materialization_guard.id().unwrap().clone();
21646        let materialization_root = shared
21647            .materializations
21648            .lock()
21649            .unwrap_or_else(|poisoned| poisoned.into_inner())
21650            .get(&materialization_id)
21651            .unwrap()
21652            .root()
21653            .to_path_buf();
21654        let selection = shared
21655            .select_environment_profile(
21656                address.session.instance_id,
21657                &agent("claude"),
21658                SessionMode::Pty,
21659                Some(&receipt),
21660            )
21661            .unwrap()
21662            .unwrap();
21663        assert!(shared
21664            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21665            .unwrap()
21666            .is_none());
21667        shared.bind_session_with_materialization(
21668            &address,
21669            ProviderRuntimePolicy::raw_pty(),
21670            Some(receipt),
21671            None,
21672            None,
21673            Some(materialization_id),
21674        );
21675        selection.retain();
21676        materialization_guard.retain();
21677
21678        let blocked = shared
21679            .cleanup_managed_worktree(&lease.lease_id, false)
21680            .await
21681            .unwrap_err();
21682        assert_eq!(blocked.code, NodeFailureCode::ManagedWorktreeRecoveryRequired);
21683        assert!(materialization_root.is_dir());
21684        let removed = shared.remove_binding(&address).unwrap();
21685        shared
21686            .cleanup_session_owned_materialization(&address, &removed)
21687            .unwrap();
21688        assert!(!materialization_root.exists());
21689        drop(shared);
21690        drop(runtime);
21691        std::fs::remove_dir_all(root).unwrap();
21692    }
21693
21694    #[tokio::test]
21695    async fn raw_managed_record_is_inventory_only_and_does_not_block_session_cleanup() {
21696        let git_root = temporary_workspace_root("raw-managed-inventory");
21697        let source = git_root.join("source");
21698        let allocation = git_root.join("allocation");
21699        std::fs::create_dir_all(&source).unwrap();
21700        std::fs::create_dir_all(&allocation).unwrap();
21701        run_test_git(&source, &["init"]);
21702        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
21703        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
21704        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
21705        run_test_git(&source, &["add", "seed.txt"]);
21706        run_test_git(&source, &["commit", "-m", "seed"]);
21707        let profile = ManagedWorktreeProfile::new(
21708            WorktreeProfileId::new("default").unwrap(),
21709            crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
21710            &allocation,
21711            "gate4agent",
21712            "HEAD",
21713            ManagedWorktreeRetention::RemoveWhenReleased,
21714        )
21715        .unwrap();
21716        let workspace = WorkspaceConfig::new(
21717            WorkspaceId::new("primary").unwrap(),
21718            &source,
21719        )
21720        .unwrap()
21721        .with_worktree_service_mode(WorktreeServiceMode::Managed)
21722        .with_managed_worktree_profile(profile.clone())
21723        .unwrap();
21724        let (shared, mut runtime, receipt, materialization_fixture_root, _deny) =
21725            materialization_test_shared_with_workspace(false, workspace);
21726        let target = PathBuf::from(profile.allocation_root()).join("mw-raw-inventory");
21727        let source_text = source.to_string_lossy().into_owned();
21728        let target_text = target.to_string_lossy().into_owned();
21729        let created = create_git_worktree(
21730            &source_text,
21731            &target_text,
21732            "gate4agent/mw-raw-inventory",
21733            Some("HEAD"),
21734        )
21735        .await
21736        .unwrap();
21737        let lease = ManagedWorktreeLeaseRecord {
21738            lease_id: ManagedWorktreeLeaseId::new("mw-raw-inventory").unwrap(),
21739            source_workspace_id: WorkspaceId::new("primary").unwrap(),
21740            workspace_id: WorkspaceId::new("managed-raw-inventory").unwrap(),
21741            profile_id: profile.profile_id().clone(),
21742            profile_revision: profile.revision().clone(),
21743            target_root: created.path.clone(),
21744            branch: "gate4agent/mw-raw-inventory".to_owned(),
21745            base_commit: created.head.clone(),
21746            expected_head: Some(created.head.clone()),
21747            retention: ManagedWorktreeRetention::RemoveWhenReleased,
21748            state: ManagedWorktreeLeaseState::Ready,
21749            session_holders: Vec::new(),
21750            record_holders: Vec::new(),
21751            cleanup_failure: None,
21752            created_at_unix_ms: 1,
21753            updated_at_unix_ms: 1,
21754        };
21755        shared
21756            .workspaces
21757            .write()
21758            .unwrap_or_else(|poisoned| poisoned.into_inner())
21759            .insert(lease.workspace_id.clone(), created.path.clone());
21760        *shared
21761            .managed_worktrees
21762            .lock()
21763            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21764            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
21765        let address = SessionAddress {
21766            workspace_id: lease.workspace_id.clone(),
21767            session: SessionKey {
21768                instance_id: AgentInstanceId(205),
21769                generation: SessionGeneration(1),
21770            },
21771        };
21772        let (overlay, materialization_guard) = shared
21773            .prepare_session_materialization(
21774                &address,
21775                &agent("claude"),
21776                SessionMode::Pty,
21777                Some(&receipt),
21778                None,
21779                None,
21780                Some(lease.lease_id.clone()),
21781            )
21782            .unwrap()
21783            .unwrap();
21784        let materialization_id = materialization_guard.id().unwrap().clone();
21785        let materialization_root = shared
21786            .materializations
21787            .lock()
21788            .unwrap_or_else(|poisoned| poisoned.into_inner())
21789            .get(&materialization_id)
21790            .unwrap()
21791            .root()
21792            .to_path_buf();
21793        let selection = shared
21794            .select_environment_profile(
21795                address.session.instance_id,
21796                &agent("claude"),
21797                SessionMode::Pty,
21798                Some(&receipt),
21799            )
21800            .unwrap()
21801            .unwrap();
21802        assert!(shared
21803            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21804            .unwrap()
21805            .is_none());
21806        let record_id = shared
21807            .bind_spawn_session_with_materialization(
21808                &address,
21809                agent("claude"),
21810                SessionMode::Pty,
21811                ProviderRuntimePolicy::raw_pty(),
21812                SpawnRecordPolicy::Always,
21813                Some(receipt.clone()),
21814                None,
21815                None,
21816                Some(materialization_id.clone()),
21817            )
21818            .unwrap()
21819            .unwrap();
21820        selection.retain();
21821        materialization_guard.retain();
21822        let record = shared.record(&record_id).unwrap();
21823        assert_eq!(record.state, ManagedSessionState::Live);
21824        assert!(record.provider_session.is_none());
21825        assert!(record.environment_profile.is_none());
21826        assert!(record.bundle.is_none());
21827        assert!(record.context_id.is_none());
21828        assert!(record.context.is_none());
21829        assert_eq!(record.active_session.as_ref(), Some(&address));
21830        assert_eq!(
21831            shared
21832                .materializations
21833                .lock()
21834                .unwrap_or_else(|poisoned| poisoned.into_inner())
21835                .get(&materialization_id)
21836                .unwrap()
21837                .owner(),
21838            &MaterializationOwner::Session {
21839                incarnation_id: shared.incarnation_id,
21840                instance_id: address.session.instance_id,
21841                generation: address.session.generation,
21842            },
21843        );
21844        {
21845            let mut bindings = shared
21846                .session_bindings
21847                .lock()
21848                .unwrap_or_else(|poisoned| poisoned.into_inner());
21849            bindings
21850                .get_mut(&address.session.instance_id)
21851                .unwrap()
21852                .managed_worktree_lease_id = Some(lease.lease_id.clone());
21853        }
21854        let lease_snapshot = shared
21855            .managed_worktrees
21856            .lock()
21857            .unwrap_or_else(|poisoned| poisoned.into_inner())
21858            .bind_session(
21859                &lease.lease_id,
21860                ManagedWorktreeSessionHolder {
21861                    incarnation_id: shared.incarnation_id,
21862                    instance_id: address.session.instance_id,
21863                    generation: address.session.generation,
21864                },
21865                None,
21866                unix_time_ms(),
21867            )
21868            .unwrap();
21869        assert_eq!(lease_snapshot.active_session_count, 1);
21870        assert_eq!(lease_snapshot.managed_record_count, 0);
21871        shared.persist_state().unwrap();
21872        assert!(shared
21873            .managed_worktrees
21874            .lock()
21875            .unwrap_or_else(|poisoned| poisoned.into_inner())
21876            .get(&lease.lease_id)
21877            .unwrap()
21878            .record_holders
21879            .is_empty());
21880        shared
21881            .handle
21882            .dispatch(shared.prepare_command(ControlCommand::Register {
21883                instance_id: address.session.instance_id,
21884                agent_id: agent("claude"),
21885                transport: TransportKind::Pty,
21886            }))
21887            .unwrap();
21888        runtime.tick().await;
21889        shared.reconcile_managed_record(
21890            &record_id,
21891            &address,
21892            &ControlEvent {
21893                sequence: 1,
21894                command_id: None,
21895                instance_id: address.session.instance_id,
21896                generation: address.session.generation,
21897                event: ControlEventKind::Removed,
21898            },
21899            false,
21900            false,
21901        );
21902        let remove = shared.remove_session(&address);
21903        let drive_runtime = async {
21904            runtime.tick().await;
21905        };
21906        let (removed, ()) = tokio::join!(remove, drive_runtime);
21907        removed.unwrap();
21908
21909        assert!(!materialization_root.exists());
21910        assert!(!Path::new(&created.path).exists());
21911        assert_eq!(
21912            shared
21913                .managed_worktrees
21914                .lock()
21915                .unwrap_or_else(|poisoned| poisoned.into_inner())
21916                .get(&lease.lease_id)
21917                .unwrap()
21918                .state,
21919            ManagedWorktreeLeaseState::Removed,
21920        );
21921        let renamed = shared
21922            .rename_session_record(&record_id, "raw managed inventory".to_owned())
21923            .unwrap();
21924        assert_eq!(renamed.record_id, record_id);
21925        assert_eq!(renamed.display_name, "raw managed inventory");
21926        assert_eq!(renamed.state, ManagedSessionState::Unavailable);
21927        assert!(renamed.active_session.is_none());
21928
21929        drop(shared);
21930        drop(runtime);
21931        std::fs::remove_dir_all(materialization_fixture_root).unwrap();
21932        std::fs::remove_dir_all(git_root).unwrap();
21933    }
21934
21935    fn install_managed_lease(shared: &NodeShared) -> ManagedWorktreeLeaseRecord {
21936        let root = std::env::current_dir().unwrap()
21937            .join("managed-test-target")
21938            .to_string_lossy()
21939            .into_owned();
21940        let lease = ManagedWorktreeLeaseRecord {
21941            lease_id: ManagedWorktreeLeaseId::new("mw-test").unwrap(),
21942            source_workspace_id: WorkspaceId::new("primary").unwrap(),
21943            workspace_id: WorkspaceId::new("managed-test").unwrap(),
21944            profile_id: WorktreeProfileId::new("default").unwrap(),
21945            profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
21946            target_root: root.clone(),
21947            branch: "gate4agent/mw-test".to_owned(),
21948            base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
21949            expected_head: Some("0123456789abcdef0123456789abcdef01234567".to_owned()),
21950            retention: ManagedWorktreeRetention::RemoveWhenReleased,
21951            state: ManagedWorktreeLeaseState::Ready,
21952            session_holders: Vec::new(),
21953            record_holders: Vec::new(),
21954            cleanup_failure: None,
21955            created_at_unix_ms: 1,
21956            updated_at_unix_ms: 1,
21957        };
21958        shared.workspaces.write()
21959            .unwrap_or_else(|poisoned| poisoned.into_inner())
21960            .insert(lease.workspace_id.clone(), root);
21961        *shared.managed_worktrees.lock()
21962            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21963            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
21964        lease
21965    }
21966
21967    #[cfg(feature = "fixture")]
21968    #[test]
21969    fn monitoring_fixture_managed_spawn_retains_effective_record_holder_and_converges_live() {
21970        let mut shared = terminal_test_shared();
21971        shared.fixture_semantic_hook_policy = true;
21972        let lease = install_managed_lease(&shared);
21973        let address = SessionAddress {
21974            workspace_id: lease.workspace_id.clone(),
21975            session: SessionKey {
21976                instance_id: AgentInstanceId(206),
21977                generation: SessionGeneration(1),
21978            },
21979        };
21980        let pre_admission_policy = ProviderRuntimePolicy::raw_pty();
21981        let effective_runtime_policy =
21982            shared.effective_spawn_runtime_policy(pre_admission_policy);
21983        assert_ne!(effective_runtime_policy, pre_admission_policy);
21984        assert!(effective_runtime_policy.provider_session_identity);
21985
21986        let record_id = shared
21987            .bind_spawn_session(
21988                &address,
21989                agent("claude"),
21990                SessionMode::Pty,
21991                effective_runtime_policy,
21992                SpawnRecordPolicy::Always,
21993                None,
21994            )
21995            .unwrap()
21996            .unwrap();
21997        assert_eq!(
21998            shared.record(&record_id).unwrap().state,
21999            ManagedSessionState::IdentityPending,
22000        );
22001        let (record_holder, raw_inventory_record_id) = {
22002            let mut bindings = shared
22003                .session_bindings
22004                .lock()
22005                .unwrap_or_else(|poisoned| poisoned.into_inner());
22006            let binding = bindings.get_mut(&address.session.instance_id).unwrap();
22007            binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
22008            managed_spawn_record_ownership(binding, effective_runtime_policy)
22009        };
22010        assert_eq!(record_holder.as_ref(), Some(&record_id));
22011        assert!(raw_inventory_record_id.is_none());
22012        let snapshot = shared
22013            .managed_worktrees
22014            .lock()
22015            .unwrap_or_else(|poisoned| poisoned.into_inner())
22016            .bind_session(
22017                &lease.lease_id,
22018                ManagedWorktreeSessionHolder {
22019                    incarnation_id: shared.incarnation_id,
22020                    instance_id: address.session.instance_id,
22021                    generation: address.session.generation,
22022                },
22023                record_holder,
22024                unix_time_ms(),
22025            )
22026            .unwrap();
22027        assert_eq!(snapshot.managed_record_count, 1);
22028        assert_eq!(
22029            shared
22030                .managed_worktrees
22031                .lock()
22032                .unwrap_or_else(|poisoned| poisoned.into_inner())
22033                .get(&lease.lease_id)
22034                .unwrap()
22035                .record_holders,
22036            vec![record_id.clone()],
22037        );
22038
22039        let identity = ProviderSessionIdentity {
22040            key: ProviderSessionKey::SessionId,
22041            id: "monitoring-fixture-managed-session".to_owned(),
22042            transcript_path: None,
22043        };
22044        shared.publish_control(ControlEvent {
22045            sequence: 1,
22046            command_id: None,
22047            instance_id: address.session.instance_id,
22048            generation: address.session.generation,
22049            event: ControlEventKind::ProviderEvent {
22050                sequence: 1,
22051                source: gate4agent_types::ProviderSource {
22052                    family: AdapterFamily::Hook,
22053                    binding: AdapterBinding::new(
22054                        gate4agent_types::AdapterId::new("claude-code").unwrap(),
22055                        "fixture/v1",
22056                        gate4agent_types::AdapterVerification::SyntheticFixture,
22057                    )
22058                    .unwrap(),
22059                },
22060                source_sequence: 1,
22061                event: ProviderEvent::SessionIdentityObserved {
22062                    identity: identity.clone(),
22063                },
22064            },
22065        });
22066
22067        let live = shared.record(&record_id).unwrap();
22068        assert_eq!(live.state, ManagedSessionState::Live);
22069        assert_eq!(live.provider_session.as_ref(), Some(&identity));
22070        assert_eq!(live.active_session.as_ref(), Some(&address));
22071        assert_eq!(
22072            shared
22073                .managed_worktrees
22074                .lock()
22075                .unwrap_or_else(|poisoned| poisoned.into_inner())
22076                .get(&lease.lease_id)
22077                .unwrap()
22078                .record_holders,
22079            vec![record_id],
22080        );
22081    }
22082
22083    fn managed_v2_test_shared(
22084        allocation_root: &Path,
22085        state_path: Option<PathBuf>,
22086        profile_revision: &str,
22087    ) -> NodeShared {
22088        let profile = ManagedWorktreeProfile::new(
22089            WorktreeProfileId::new("default").unwrap(),
22090            WorktreeProfileRevision::new(profile_revision).unwrap(),
22091            allocation_root,
22092            "gate4agent",
22093            "HEAD",
22094            ManagedWorktreeRetention::RemoveWhenReleased,
22095        )
22096        .unwrap();
22097        let workspace = WorkspaceConfig::new(
22098            WorkspaceId::new("primary").unwrap(),
22099            std::env::current_dir().unwrap(),
22100        )
22101        .unwrap()
22102        .with_worktree_service_mode(WorktreeServiceMode::Managed)
22103        .with_managed_worktree_profile(profile)
22104        .unwrap();
22105        let catalog = active_registry().unwrap();
22106        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22107        let mut shared = NodeShared::new(
22108            handle,
22109            "fixture-token".to_owned(),
22110            NodeId::new("node-terminal-test").unwrap(),
22111            vec![workspace],
22112            vec![agent("claude")],
22113        );
22114        shared.state_path = state_path;
22115        shared
22116    }
22117
22118    fn managed_v2_request(idempotency_key: &str) -> ManagedWorktreeSpawnRequestV2 {
22119        let mut spawn_spec = spawn_spec_fixture();
22120        spawn_spec.idempotency_key = SpawnIdempotencyKey::new(idempotency_key).unwrap();
22121        ManagedWorktreeSpawnRequestV2 {
22122            spawn_spec,
22123            worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
22124            expected_profile_revision: WorktreeProfileRevision::new("v1").unwrap(),
22125        }
22126    }
22127
22128    #[test]
22129    fn managed_spawn_receipt_rebinds_allocated_worktree_and_roundtrips_wire() {
22130        let shared = terminal_test_shared();
22131        let mut lease = install_managed_lease(&shared);
22132        let session = SessionAddress {
22133            workspace_id: lease.workspace_id.clone(),
22134            session: SessionKey {
22135                instance_id: AgentInstanceId(73),
22136                generation: SessionGeneration(4),
22137            },
22138        };
22139        lease.state = ManagedWorktreeLeaseState::InUse;
22140        lease.session_holders.push(ManagedWorktreeSessionHolder {
22141            incarnation_id: shared.incarnation_id,
22142            instance_id: session.session.instance_id,
22143            generation: session.session.generation,
22144        });
22145        let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
22146        assert!(resolved.target.worktree_id.is_none());
22147
22148        let receipt = managed_spawn_receipt(
22149            &resolved,
22150            shared.incarnation_id,
22151            session.clone(),
22152            lease.snapshot(),
22153            None,
22154            None,
22155            None,
22156        );
22157        assert!(resolved.target.worktree_id.is_none());
22158        assert_eq!(receipt.spawn.target.workspace_id, lease.source_workspace_id);
22159        assert_eq!(receipt.spawn.target.worktree_id.as_ref(), Some(&lease.workspace_id));
22160        assert_eq!(receipt.spawn.session, session);
22161        assert_eq!(receipt.lease.workspace_id, lease.workspace_id);
22162        let encoded = serde_json::to_vec(&receipt).unwrap();
22163        let decoded: ManagedWorktreeSpawnReceipt = serde_json::from_slice(&encoded).unwrap();
22164        assert_eq!(decoded, receipt);
22165    }
22166
22167    #[tokio::test]
22168    async fn managed_spawn_v2_rejects_profile_revision_before_allocation() {
22169        let root = temporary_workspace_root("managed-v2-revision");
22170        let allocation = root.join("allocation");
22171        std::fs::create_dir_all(&allocation).unwrap();
22172        let shared = managed_v2_test_shared(
22173            &allocation,
22174            Some(root.join("node-state.json")),
22175            "v1",
22176        );
22177        let mut request = managed_v2_request("managed-v2-revision");
22178        request.expected_profile_revision = WorktreeProfileRevision::new("v2").unwrap();
22179
22180        let failure = shared.spawn_managed_worktree_v2(request).await.unwrap_err();
22181
22182        assert_eq!(
22183            failure.code,
22184            NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
22185        );
22186        assert!(shared
22187            .managed_worktrees
22188            .lock()
22189            .unwrap_or_else(|poisoned| poisoned.into_inner())
22190            .records()
22191            .is_empty());
22192        assert!(shared
22193            .managed_spawn_replays
22194            .lock()
22195            .unwrap_or_else(|poisoned| poisoned.into_inner())
22196            .is_empty());
22197        std::fs::remove_dir_all(root).unwrap();
22198    }
22199
22200    #[test]
22201    fn managed_spawn_v2_concurrent_same_key_has_one_durable_reservation() {
22202        let root = temporary_workspace_root("managed-v2-concurrent");
22203        let allocation = root.join("allocation");
22204        std::fs::create_dir_all(&allocation).unwrap();
22205        let shared = Arc::new(managed_v2_test_shared(
22206            &allocation,
22207            Some(root.join("node-state.json")),
22208            "v1",
22209        ));
22210        let request = managed_v2_request("managed-v2-concurrent");
22211        let barrier = Arc::new(std::sync::Barrier::new(3));
22212        let mut joins = Vec::new();
22213        for _ in 0..2 {
22214            let shared = Arc::clone(&shared);
22215            let request = request.clone();
22216            let barrier = Arc::clone(&barrier);
22217            joins.push(std::thread::spawn(move || {
22218                let digest = managed_spawn_request_digest_v2(&request).unwrap();
22219                barrier.wait();
22220                shared.acquire_durable_managed_spawn_v2(&request, digest)
22221            }));
22222        }
22223        barrier.wait();
22224        let results = joins
22225            .into_iter()
22226            .map(|join| join.join().unwrap())
22227            .collect::<Vec<_>>();
22228        assert_eq!(
22229            results
22230                .iter()
22231                .filter(|result| {
22232                    matches!(result, Ok(DurableManagedSpawnReplayDecision::Reserved))
22233                })
22234                .count(),
22235            1,
22236        );
22237        assert_eq!(
22238            results
22239                .iter()
22240                .filter(|result| {
22241                    matches!(result, Err(error) if error.code == NodeFailureCode::BackendBusy)
22242                })
22243                .count(),
22244            1,
22245        );
22246        assert_eq!(
22247            shared
22248                .managed_spawn_replays
22249                .lock()
22250                .unwrap_or_else(|poisoned| poisoned.into_inner())
22251                .len(),
22252            1,
22253        );
22254        assert!(shared
22255            .managed_worktrees
22256            .lock()
22257            .unwrap_or_else(|poisoned| poisoned.into_inner())
22258            .records()
22259            .is_empty());
22260        drop(shared);
22261        std::fs::remove_dir_all(root).unwrap();
22262    }
22263
22264    #[tokio::test]
22265    async fn managed_spawn_v2_linked_pending_reopen_fails_closed_without_reallocation() {
22266        let root = temporary_workspace_root("managed-v2-linked-pending");
22267        let allocation = root.join("allocation");
22268        std::fs::create_dir_all(&allocation).unwrap();
22269        let state_path = root.join("node-state.json");
22270        let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
22271        let lease = install_managed_lease(&shared);
22272        let request = managed_v2_request("managed-v2-linked-pending");
22273        assert!(matches!(
22274            shared
22275                .acquire_durable_managed_spawn_v2(
22276                    &request,
22277                    managed_spawn_request_digest_v2(&request).unwrap(),
22278                )
22279                .unwrap(),
22280            DurableManagedSpawnReplayDecision::Reserved,
22281        ));
22282        {
22283            let _transaction = shared
22284                .state_transaction
22285                .lock()
22286                .unwrap_or_else(|poisoned| poisoned.into_inner());
22287            shared
22288                .link_durable_managed_spawn_v2_lease_locked(
22289                    &request.spawn_spec.idempotency_key,
22290                    &lease.lease_id,
22291                )
22292                .unwrap();
22293            shared.persist_state_locked().unwrap();
22294        }
22295        drop(shared);
22296
22297        let mut loaded = session_registry::load(
22298            Some(&state_path),
22299            &NodeId::new("node-terminal-test").unwrap(),
22300        )
22301        .unwrap();
22302        let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22303        *reopened
22304            .managed_worktrees
22305            .lock()
22306            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22307            ManagedWorktreeRegistry::from_records(
22308                std::mem::take(&mut loaded.managed_worktrees),
22309                std::mem::take(&mut loaded.managed_worktree_tombstones),
22310            )
22311            .unwrap();
22312        *reopened
22313            .managed_spawn_replays
22314            .lock()
22315            .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22316            .managed_spawn_replays
22317            .into_iter()
22318            .map(|record| (record.idempotency_key.clone(), record))
22319            .collect();
22320        let lease_count = reopened
22321            .managed_worktrees
22322            .lock()
22323            .unwrap_or_else(|poisoned| poisoned.into_inner())
22324            .records()
22325            .len();
22326
22327        let failure = reopened
22328            .spawn_managed_worktree_v2(request)
22329            .await
22330            .unwrap_err();
22331
22332        assert_eq!(
22333            failure.code,
22334            NodeFailureCode::ManagedWorktreeRecoveryRequired,
22335        );
22336        assert_eq!(
22337            reopened
22338                .managed_worktrees
22339                .lock()
22340                .unwrap_or_else(|poisoned| poisoned.into_inner())
22341                .records()
22342                .len(),
22343            lease_count,
22344        );
22345        std::fs::remove_dir_all(root).unwrap();
22346    }
22347
22348    #[tokio::test]
22349    async fn managed_spawn_v2_replays_exact_receipt_after_durable_reopen_and_conflicts() {
22350        let root = temporary_workspace_root("managed-v2-reopen");
22351        let allocation = root.join("allocation");
22352        std::fs::create_dir_all(&allocation).unwrap();
22353        let state_path = root.join("node-state.json");
22354        let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
22355        let lease = install_managed_lease(&shared);
22356        let request = managed_v2_request("managed-v2-reopen");
22357        let session = SessionAddress {
22358            workspace_id: lease.workspace_id.clone(),
22359            session: SessionKey {
22360                instance_id: AgentInstanceId(91),
22361                generation: SessionGeneration(1),
22362            },
22363        };
22364        let snapshot = {
22365            let mut registry = shared
22366                .managed_worktrees
22367                .lock()
22368                .unwrap_or_else(|poisoned| poisoned.into_inner());
22369            let current = registry.get_mut(&lease.lease_id).unwrap();
22370            current.state = ManagedWorktreeLeaseState::InUse;
22371            current.session_holders.push(ManagedWorktreeSessionHolder {
22372                incarnation_id: shared.incarnation_id,
22373                instance_id: session.session.instance_id,
22374                generation: session.session.generation,
22375            });
22376            current.snapshot()
22377        };
22378        let resolved = shared.resolve_spawn_spec(&request.spawn_spec).unwrap();
22379        let receipt = managed_spawn_receipt(
22380            &resolved,
22381            shared.incarnation_id,
22382            session,
22383            snapshot,
22384            None,
22385            None,
22386            None,
22387        );
22388        assert!(matches!(
22389            shared
22390                .acquire_durable_managed_spawn_v2(
22391                    &request,
22392                    managed_spawn_request_digest_v2(&request).unwrap(),
22393                )
22394                .unwrap(),
22395            DurableManagedSpawnReplayDecision::Reserved,
22396        ));
22397        {
22398            let _transaction = shared
22399                .state_transaction
22400                .lock()
22401                .unwrap_or_else(|poisoned| poisoned.into_inner());
22402            shared
22403                .link_durable_managed_spawn_v2_lease_locked(
22404                    &request.spawn_spec.idempotency_key,
22405                    &lease.lease_id,
22406                )
22407                .unwrap();
22408            shared.persist_state_locked().unwrap();
22409        }
22410        shared
22411            .commit_durable_managed_spawn_v2(&request, receipt.clone())
22412            .unwrap();
22413        let legacy_same_key = ManagedWorktreeSpawnRequest {
22414            spawn_spec: request.spawn_spec.clone(),
22415            worktree_profile_id: request.worktree_profile_id.clone(),
22416        };
22417        shared.remember_managed_spawn_attempt(
22418            Some(&request.spawn_spec.idempotency_key),
22419            legacy_same_key.clone(),
22420            Ok(receipt.clone()),
22421            Instant::now(),
22422        );
22423        assert!(shared
22424            .replay_managed_spawn(&legacy_same_key, Instant::now())
22425            .unwrap()
22426            .is_none());
22427        drop(shared);
22428
22429        let mut loaded = session_registry::load(
22430            Some(&state_path),
22431            &NodeId::new("node-terminal-test").unwrap(),
22432        )
22433        .unwrap();
22434        assert_eq!(loaded.managed_spawn_replays.len(), 1);
22435        let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22436        *reopened
22437            .managed_worktrees
22438            .lock()
22439            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22440            ManagedWorktreeRegistry::from_records(
22441                std::mem::take(&mut loaded.managed_worktrees),
22442                std::mem::take(&mut loaded.managed_worktree_tombstones),
22443            )
22444            .unwrap();
22445        *reopened
22446            .managed_spawn_replays
22447            .lock()
22448            .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22449            .managed_spawn_replays
22450            .into_iter()
22451            .map(|record| (record.idempotency_key.clone(), record))
22452            .collect();
22453        let lease_count_before = reopened
22454            .managed_worktrees
22455            .lock()
22456            .unwrap_or_else(|poisoned| poisoned.into_inner())
22457            .records()
22458            .len();
22459
22460        assert_eq!(
22461            reopened
22462                .spawn_managed_worktree_v2(request.clone())
22463                .await
22464                .unwrap(),
22465            receipt,
22466        );
22467        assert_eq!(
22468            reopened
22469                .managed_worktrees
22470                .lock()
22471                .unwrap_or_else(|poisoned| poisoned.into_inner())
22472                .records()
22473                .len(),
22474            lease_count_before,
22475        );
22476        let mut changed = request;
22477        changed.spawn_spec.deadline_ms = crate::protocol::SpawnDeadlineMs::new(29_000).unwrap();
22478        let failure = reopened
22479            .spawn_managed_worktree_v2(changed)
22480            .await
22481            .unwrap_err();
22482        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22483        assert_eq!(
22484            reopened
22485                .managed_worktrees
22486                .lock()
22487                .unwrap_or_else(|poisoned| poisoned.into_inner())
22488                .records()
22489                .len(),
22490            lease_count_before,
22491        );
22492        let mut new_key = managed_v2_request("managed-v2-new-after-profile-change");
22493        new_key.expected_profile_revision = WorktreeProfileRevision::new("v1").unwrap();
22494        let failure = reopened
22495            .spawn_managed_worktree_v2(new_key)
22496            .await
22497            .unwrap_err();
22498        assert_eq!(
22499            failure.code,
22500            NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
22501        );
22502        assert_eq!(
22503            reopened
22504                .managed_worktrees
22505                .lock()
22506                .unwrap_or_else(|poisoned| poisoned.into_inner())
22507                .records()
22508                .len(),
22509            lease_count_before,
22510        );
22511        assert_eq!(
22512            reopened
22513                .managed_spawn_replays
22514                .lock()
22515                .unwrap_or_else(|poisoned| poisoned.into_inner())
22516                .len(),
22517            1,
22518        );
22519        std::fs::remove_dir_all(root).unwrap();
22520    }
22521
22522    #[test]
22523    fn legacy_managed_spawn_idempotency_remains_transient_and_exact() {
22524        let shared = terminal_test_shared();
22525        let request = ManagedWorktreeSpawnRequest {
22526            spawn_spec: spawn_spec_fixture(),
22527            worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
22528        };
22529        let expected = failure(NodeFailureCode::BackendBusy, "legacy fixture failure");
22530        shared.remember_managed_spawn(
22531            request.clone(),
22532            Err(expected.clone()),
22533            Instant::now(),
22534        );
22535
22536        assert_eq!(
22537            shared
22538                .replay_managed_spawn(&request, Instant::now())
22539                .unwrap()
22540                .unwrap(),
22541            Err(expected),
22542        );
22543        assert!(shared
22544            .managed_spawn_replays
22545            .lock()
22546            .unwrap_or_else(|poisoned| poisoned.into_inner())
22547            .is_empty());
22548    }
22549
22550    #[tokio::test]
22551    async fn ordinary_spawn_and_spawn_spec_cannot_bypass_managed_lease_holder_fence() {
22552        let shared = terminal_test_shared();
22553        let lease = install_managed_lease(&shared);
22554        let raw = shared.spawn_session(
22555            lease.workspace_id.clone(),
22556            agent("claude"),
22557            SessionMode::Pty,
22558            gate4agent_types::TerminalSize { rows: 24, columns: 80 },
22559            None,
22560        ).await.unwrap_err();
22561        assert_eq!(raw.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22562        let mut spec = spawn_spec_fixture();
22563        spec.target.workspace_id = lease.workspace_id.clone();
22564        let specified = shared.spawn_from_spec(spec).await.unwrap_err();
22565        assert_eq!(specified.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22566        let mut selected = spawn_spec_fixture();
22567        selected.idempotency_key = SpawnIdempotencyKey::new("managed-selected-bypass").unwrap();
22568        selected.target.worktree_id = Some(lease.workspace_id);
22569        let selected = shared.spawn_from_spec(selected).await.unwrap_err();
22570        assert_eq!(selected.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22571        assert!(shared.handle.snapshot().sessions.is_empty());
22572    }
22573
22574    #[test]
22575    fn legacy_projection_omits_managed_snapshot_events_and_path_bearing_diagnostics() {
22576        let shared = terminal_test_shared();
22577        let lease = install_managed_lease(&shared);
22578        let snapshot = snapshot_for_wire(
22579            &shared, true, true, true, false, true, true, false, false, true,
22580        );
22581        assert!(snapshot.managed_worktrees.is_empty());
22582        assert!(snapshot.workspaces.iter().all(|workspace| {
22583            workspace.managed_worktree_profiles.is_none()
22584        }));
22585        let mut reply = ResponseEnvelope {
22586            request_id: 1,
22587            result: Ok(NodeResponse::Resync {
22588                event_sequence: 1,
22589                oldest_available_sequence: 1,
22590                snapshot: shared.snapshot(),
22591                events: vec![NodeEventEnvelope {
22592                    sequence: 1,
22593                    event: NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() },
22594                }],
22595            }),
22596        };
22597        project_response_without_managed_worktrees(&mut reply);
22598        let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
22599            panic!("projection changed the response kind");
22600        };
22601        assert!(snapshot.managed_worktrees.is_empty());
22602        assert!(events.is_empty());
22603        let mut git = git_snapshot_for_parser();
22604        git.branch = Some(lease.branch.clone());
22605        git.managed_worktree = Some(crate::protocol::ManagedWorktreeGitScope {
22606            lease_id: lease.lease_id.clone(),
22607            source_workspace_id: lease.source_workspace_id.clone(),
22608            branch: lease.branch.clone(),
22609            base_commit: GitObjectId::new(lease.base_commit.clone()).unwrap(),
22610            active_session_count: 1,
22611            managed_record_count: 0,
22612        });
22613        let mut inspection_reply = ResponseEnvelope {
22614            request_id: 2,
22615            result: Ok(NodeResponse::WorkspaceInspected {
22616                inspection: WorkspaceInspection {
22617                    workspace_id: lease.workspace_id.clone(),
22618                    entries: Vec::new(),
22619                    tree_truncated: false,
22620                    git,
22621                    truncation: None,
22622                },
22623            }),
22624        };
22625        project_response_without_managed_worktrees(&mut inspection_reply);
22626        let Ok(NodeResponse::WorkspaceInspected { inspection }) = inspection_reply.result else {
22627            panic!("projection changed the inspection response kind");
22628        };
22629        assert!(inspection.git.managed_worktree.is_none());
22630        let failure = managed_git_worktree_failure(GitWorktreeError {
22631            kind: GitWorktreeErrorKind::Failed,
22632            message: r"git failed at C:\private\managed-target".to_owned(),
22633        });
22634        assert!(!failure.message.contains("private"));
22635        assert!(!failure.message.contains("managed-target"));
22636    }
22637
22638    #[tokio::test]
22639    async fn restart_reconciliation_retains_exact_owned_worktree_and_explicit_cleanup_removes_it() {
22640        let root = temporary_workspace_root("managed-restart");
22641        let source = root.join("source");
22642        let allocation = root.join("allocation");
22643        std::fs::create_dir_all(&source).unwrap();
22644        std::fs::create_dir_all(&allocation).unwrap();
22645        run_test_git(&source, &["init"]);
22646        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
22647        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
22648        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
22649        run_test_git(&source, &["add", "seed.txt"]);
22650        run_test_git(&source, &["commit", "-m", "seed"]);
22651        let profile = ManagedWorktreeProfile::new(
22652            WorktreeProfileId::new("default").unwrap(),
22653            crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
22654            &allocation,
22655            "gate4agent",
22656            "HEAD",
22657            ManagedWorktreeRetention::Retain,
22658        ).unwrap();
22659        let workspace = WorkspaceConfig::new(
22660            WorkspaceId::new("primary").unwrap(),
22661            &source,
22662        ).unwrap()
22663            .with_worktree_service_mode(WorktreeServiceMode::Managed)
22664            .with_managed_worktree_profile(profile.clone()).unwrap();
22665        let target = PathBuf::from(profile.allocation_root()).join("mw-restart");
22666        let source_text = workspace.canonical_root().to_owned();
22667        let target_text = target.to_string_lossy().into_owned();
22668        let created = create_git_worktree(
22669            &source_text,
22670            &target_text,
22671            "gate4agent/mw-restart",
22672            Some("HEAD"),
22673        ).await.unwrap();
22674        let lease = ManagedWorktreeLeaseRecord {
22675            lease_id: ManagedWorktreeLeaseId::new("mw-restart").unwrap(),
22676            source_workspace_id: WorkspaceId::new("primary").unwrap(),
22677            workspace_id: WorkspaceId::new("managed-restart").unwrap(),
22678            profile_id: profile.profile_id().clone(),
22679            profile_revision: profile.revision().clone(),
22680            target_root: created.path.clone(),
22681            branch: "gate4agent/mw-restart".to_owned(),
22682            base_commit: created.head.clone(),
22683            expected_head: None,
22684            retention: ManagedWorktreeRetention::Retain,
22685            state: ManagedWorktreeLeaseState::Allocating,
22686            session_holders: Vec::new(),
22687            record_holders: Vec::new(),
22688            cleanup_failure: None,
22689            created_at_unix_ms: 1,
22690            updated_at_unix_ms: 1,
22691        };
22692        let catalog = active_registry().unwrap();
22693        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22694        let shared = NodeShared::new_with_incarnation(
22695            handle,
22696            "fixture-token".to_owned(),
22697            NodeId::new("node-managed-restart").unwrap(),
22698            NodeIncarnationId::from_bytes([7; crate::protocol::NODE_INCARNATION_ID_BYTES]),
22699            vec![workspace],
22700            vec![agent("claude")],
22701            ProviderRuntimeStatuses::default(),
22702            None,
22703            Vec::new(),
22704            Vec::new(),
22705            SpawnProfileRegistry::default(),
22706            None,
22707            None,
22708            None,
22709            None,
22710            Vec::new(),
22711            vec![lease.clone()],
22712            Vec::new(),
22713            Vec::new(),
22714            None,
22715            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
22716        );
22717        shared.reconcile_managed_worktrees().await;
22718        let retained = shared.managed_worktrees.lock()
22719            .unwrap_or_else(|poisoned| poisoned.into_inner())
22720            .get(&lease.lease_id).unwrap().clone();
22721        assert_eq!(retained.state, ManagedWorktreeLeaseState::Retained);
22722        assert!(shared.workspaces.read()
22723            .unwrap_or_else(|poisoned| poisoned.into_inner())
22724            .contains_key(&lease.workspace_id));
22725        let removed = shared.cleanup_managed_worktree(&lease.lease_id, true).await.unwrap();
22726        assert_eq!(removed.state, ManagedWorktreeLeaseState::Removed);
22727        assert!(!Path::new(&created.path).exists());
22728        std::fs::remove_dir_all(root).unwrap();
22729    }
22730
22731    fn run_test_git(root: &Path, arguments: &[&str]) {
22732        let output = std::process::Command::new("git")
22733            .arg("-C").arg(root).args(arguments).output().unwrap();
22734        assert!(
22735            output.status.success(),
22736            "git {:?} failed: {}",
22737            arguments,
22738            String::from_utf8_lossy(&output.stderr),
22739        );
22740    }
22741
22742    #[tokio::test]
22743    async fn spawn_spec_validation_fails_before_session_mutation() {
22744        let shared = terminal_test_shared();
22745        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22746            capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
22747        }));
22748        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22749            capability.as_str() == NODE_SPAWN_PROFILE_REVISION_CAPABILITY
22750        }));
22751        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22752            capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
22753        }));
22754
22755        let mut target_mismatch = spawn_spec_fixture();
22756        target_mismatch.target.node_id = NodeId::new("other-node").unwrap();
22757        let failure = shared.spawn_from_spec(target_mismatch).await.unwrap_err();
22758        assert_eq!(failure.code, NodeFailureCode::SpawnTargetMismatch);
22759
22760        let mut unknown_profile = spawn_spec_fixture();
22761        unknown_profile.idempotency_key = SpawnIdempotencyKey::new("unknown-profile").unwrap();
22762        unknown_profile.profile_id = crate::protocol::SpawnProfileId::new("missing").unwrap();
22763        let failure = shared.spawn_from_spec(unknown_profile).await.unwrap_err();
22764        assert_eq!(failure.code, NodeFailureCode::UnknownSpawnProfile);
22765
22766        let mut revision_mismatch = spawn_spec_fixture();
22767        revision_mismatch.idempotency_key =
22768            SpawnIdempotencyKey::new("profile-revision-mismatch").unwrap();
22769        revision_mismatch.expected_profile_revision =
22770            crate::protocol::SpawnProfileRevision::new("test-r2").unwrap();
22771        let failure = shared.spawn_from_spec(revision_mismatch).await.unwrap_err();
22772        assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
22773
22774        let mut unsupported_materializer = spawn_spec_fixture();
22775        unsupported_materializer.idempotency_key =
22776            SpawnIdempotencyKey::new("unsupported-materializer").unwrap();
22777        unsupported_materializer.overrides.bundle_id = crate::protocol::SpawnOverride::Set {
22778            value: crate::protocol::SpawnBundleId::new("bundle-v1").unwrap(),
22779        };
22780        let failure = shared
22781            .spawn_from_spec(unsupported_materializer)
22782            .await
22783            .unwrap_err();
22784        assert_eq!(failure.code, NodeFailureCode::UnknownBundle);
22785
22786        let failure = shared
22787            .spawn_session_with_deadline(
22788                WorkspaceId::new("primary").unwrap(),
22789                agent("claude"),
22790                SessionMode::Pty,
22791                gate4agent_types::TerminalSize {
22792                    rows: 24,
22793                    columns: 80,
22794                },
22795                None,
22796                None,
22797                None,
22798                None,
22799                None,
22800                None,
22801                SpawnRecordPolicy::ProviderIdentityOnly,
22802                Some(Instant::now()),
22803                &[],
22804                None,
22805                ApprovalLevel::default(),
22806                None,
22807                None,
22808            )
22809            .await
22810            .unwrap_err();
22811        assert_eq!(failure.code, NodeFailureCode::SpawnDeadlineExceeded);
22812        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
22813        assert!(shared.handle.snapshot().sessions.is_empty());
22814    }
22815
22816    #[test]
22817    fn worktree_selection_requires_its_separate_negotiated_capability() {
22818        let mut spec = spawn_spec_fixture();
22819        spec.target.worktree_id = Some(WorkspaceId::new("selected-worktree").unwrap());
22820        let request = NodeRequest::SpawnSpec { spec };
22821        let spawn_spec_capability =
22822            CapabilityId::new(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY).unwrap();
22823        assert!(request_uses_unnegotiated_capability(
22824            &request,
22825            &[spawn_spec_capability.clone()],
22826        ));
22827        let worktree_capability =
22828            CapabilityId::new(NODE_WORKTREE_SELECTION_CAPABILITY).unwrap();
22829        assert!(request_uses_unnegotiated_capability(
22830            &request,
22831            &[spawn_spec_capability.clone(), worktree_capability.clone()],
22832        ));
22833        let profile_revision_capability =
22834            CapabilityId::new(NODE_SPAWN_PROFILE_REVISION_CAPABILITY).unwrap();
22835        assert!(request_uses_unnegotiated_capability(
22836            &request,
22837            &[
22838                spawn_spec_capability.clone(),
22839                worktree_capability.clone(),
22840                profile_revision_capability.clone(),
22841            ],
22842        ));
22843        // Default `SpawnOverrides` leaves `bundle_id`/`context_id` at
22844        // `Inherit`, not `Clear` — the profile's own defaults might still
22845        // materialize a bundle or a context pack, so both capabilities stay
22846        // conservatively required until negotiated (see
22847        // `requires_session_bundle_materialization_capability` /
22848        // `requires_history_context_pack_capability`).
22849        let bundle_materialization_capability =
22850            CapabilityId::new(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY).unwrap();
22851        assert!(request_uses_unnegotiated_capability(
22852            &request,
22853            &[
22854                spawn_spec_capability.clone(),
22855                worktree_capability.clone(),
22856                profile_revision_capability.clone(),
22857                bundle_materialization_capability.clone(),
22858            ],
22859        ));
22860        let history_context_pack_capability =
22861            CapabilityId::new(NODE_HISTORY_CONTEXT_PACK_CAPABILITY).unwrap();
22862        assert!(!request_uses_unnegotiated_capability(
22863            &request,
22864            &[
22865                spawn_spec_capability,
22866                worktree_capability,
22867                profile_revision_capability,
22868                bundle_materialization_capability,
22869                history_context_pack_capability,
22870            ],
22871        ));
22872    }
22873
22874    #[tokio::test]
22875    async fn spawn_spec_idempotency_replays_receipt_and_conflicts_before_session_mutation() {
22876        let mut shared = terminal_test_shared();
22877        let spec = spawn_spec_fixture();
22878        let receipt = shared
22879            .resolve_spawn_spec(&spec)
22880            .unwrap()
22881            .receipt(shared.incarnation_id, terminal_address(1));
22882        shared.remember_spawn_spec(spec.clone(), Ok(receipt.clone()), Instant::now());
22883
22884        let mut revised = shared.spawn_profiles.get(&spec.profile_id).unwrap().clone();
22885        revised.revision = crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
22886        shared.spawn_profiles = SpawnProfileRegistry::new([revised]).unwrap();
22887
22888        assert_eq!(shared.spawn_from_spec(spec.clone()).await.unwrap(), receipt);
22889
22890        let mut revision_conflicting = spec.clone();
22891        revision_conflicting.expected_profile_revision =
22892            crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
22893        let failure = shared.spawn_from_spec(revision_conflicting).await.unwrap_err();
22894        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22895
22896        let mut stale_cache_miss = spec.clone();
22897        stale_cache_miss.idempotency_key =
22898            SpawnIdempotencyKey::new("stale-profile-cache-miss").unwrap();
22899        let failure = shared.spawn_from_spec(stale_cache_miss).await.unwrap_err();
22900        assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
22901
22902        let mut conflicting = spec;
22903        conflicting.deadline_ms = crate::protocol::SpawnDeadlineMs::new(31_000).unwrap();
22904        let failure = shared.spawn_from_spec(conflicting).await.unwrap_err();
22905        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22906        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
22907        assert!(shared.handle.snapshot().sessions.is_empty());
22908    }
22909
22910    #[tokio::test]
22911    async fn off_worktree_service_rejects_selection_and_mutation_before_git() {
22912        let shared = terminal_test_shared_with_mode(WorktreeServiceMode::Off);
22913        let mut spec = spawn_spec_fixture();
22914        spec.target.worktree_id = Some(WorkspaceId::new("unregistered-worktree").unwrap());
22915        let failure = shared.spawn_from_spec(spec).await.unwrap_err();
22916        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
22917
22918        let failure = shared
22919            .create_worktree(
22920                WorkspaceId::new("primary").unwrap(),
22921                WorkspaceId::new("new-worktree").unwrap(),
22922                "not-an-absolute-path".to_owned(),
22923                "invalid branch".to_owned(),
22924                None,
22925            )
22926            .await
22927            .unwrap_err();
22928        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
22929
22930        let failure = shared
22931            .remove_worktree(
22932                WorkspaceId::new("primary").unwrap(),
22933                "not-an-absolute-path".to_owned(),
22934            )
22935            .await
22936            .unwrap_err();
22937        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
22938        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
22939        assert!(shared.handle.snapshot().sessions.is_empty());
22940    }
22941
22942    #[tokio::test]
22943    async fn manual_worktree_registration_failure_removes_the_created_worktree() {
22944        let root = temporary_workspace_root("manual-worktree-compensation");
22945        let source = root.join("source");
22946        let target = root.join("target");
22947        let invalid_state_parent = root.join("state-parent-is-a-file");
22948        let invalid_state_path = invalid_state_parent.join("state.json");
22949        std::fs::create_dir_all(&source).unwrap();
22950        std::fs::write(&invalid_state_parent, "not a directory").unwrap();
22951        run_test_git(&source, &["init"]);
22952        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
22953        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
22954        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
22955        run_test_git(&source, &["add", "seed.txt"]);
22956        run_test_git(&source, &["commit", "-m", "seed"]);
22957
22958        let catalog = active_registry().unwrap();
22959        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22960        let workspace = WorkspaceConfig::new(
22961            WorkspaceId::new("primary").unwrap(),
22962            &source,
22963        ).unwrap();
22964        let mut shared = NodeShared::new(
22965            handle,
22966            "fixture-token".to_owned(),
22967            NodeId::new("node-worktree-compensation").unwrap(),
22968            vec![workspace],
22969            vec![agent("claude")],
22970        );
22971        shared.state_path = Some(invalid_state_path);
22972
22973        let error = shared.create_worktree(
22974            WorkspaceId::new("primary").unwrap(),
22975            WorkspaceId::new("compensated").unwrap(),
22976            target.to_string_lossy().into_owned(),
22977            "gate4agent/compensated".to_owned(),
22978            Some("HEAD".to_owned()),
22979        ).await.unwrap_err();
22980
22981        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
22982        assert!(!target.exists());
22983        assert!(list_git_worktrees(source.to_str().unwrap()).await.unwrap()
22984            .iter()
22985            .all(|worktree| !worktree_paths_equal(&worktree.path, &target.to_string_lossy())));
22986        std::fs::remove_dir_all(root).unwrap();
22987    }
22988
22989    #[test]
22990    fn selected_worktree_requires_authoritative_non_main_non_bare_entry() {
22991        fn worktree(path: &str, is_main: bool, is_bare: bool) -> NativeGitWorktreeSnapshot {
22992            NativeGitWorktreeSnapshot {
22993                path: path.to_owned(),
22994                head: "0123456789abcdef".to_owned(),
22995                branch: Some("refs/heads/test".to_owned()),
22996                is_bare,
22997                is_main,
22998                locked: false,
22999                lock_reason: None,
23000                prunable: false,
23001                prunable_reason: None,
23002                workspace_id: None,
23003            }
23004        }
23005
23006        let source = r"C:\repo";
23007        let selected = r"C:\repo-worktree";
23008        let listed = vec![
23009            worktree(source, true, false),
23010            worktree(selected, false, false),
23011        ];
23012        assert!(validate_selected_worktree(source, selected, &listed).is_ok());
23013
23014        let main_failure = validate_selected_worktree(source, source, &listed).unwrap_err();
23015        assert_eq!(main_failure.code, NodeFailureCode::WorktreeProtected);
23016
23017        let bare = vec![
23018            worktree(source, true, false),
23019            worktree(selected, false, true),
23020        ];
23021        let bare_failure = validate_selected_worktree(source, selected, &bare).unwrap_err();
23022        assert_eq!(bare_failure.code, NodeFailureCode::WorktreeProtected);
23023
23024        let foreign_failure =
23025            validate_selected_worktree(source, r"C:\foreign", &listed).unwrap_err();
23026        assert_eq!(foreign_failure.code, NodeFailureCode::WorktreeProtected);
23027    }
23028
23029    fn terminal_frame(sequence: u64) -> TerminalFrame {
23030        TerminalFrame {
23031            sequence,
23032            size: gate4agent_types::TerminalSize {
23033                rows: 24,
23034                columns: 80,
23035            },
23036            cursor_row: 2,
23037            cursor_column: 3,
23038            contents: format!("frame-{sequence}"),
23039            formatted: vec![1, 2, 3],
23040            scrollback_formatted: Vec::new(),
23041            alternate_screen: false,
23042            mouse_protocol_enabled: false,
23043            mouse_protocol_encoding: Default::default(),
23044            produced_at_unix_ms: 0,
23045            screen_state: gate4agent_types::PtyScreenState::default(),
23046            bracketed_paste: None,
23047        }
23048    }
23049
23050    #[test]
23051    fn terminal_frame_watermark_coalesces_and_resets_for_exact_session_address() {
23052        let shared = terminal_test_shared();
23053        let mut receiver = shared.terminal_event_tx.subscribe();
23054        let original = terminal_address(1);
23055
23056        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
23057        let first = receiver.try_recv().unwrap();
23058        assert_eq!(first.len(), 1);
23059        assert!(matches!(
23060            &first[0].event,
23061            NodeEvent::TerminalFrame { address, frame }
23062                if address == &original && frame.sequence == 7
23063        ));
23064
23065        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
23066        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(6))]);
23067        assert!(matches!(
23068            receiver.try_recv(),
23069            Err(broadcast::error::TryRecvError::Empty)
23070        ));
23071
23072        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(8))]);
23073        let advanced = receiver.try_recv().unwrap();
23074        assert_eq!(advanced[0].sequence, 2);
23075
23076        let resumed = terminal_address(2);
23077        shared.publish_terminal_frame_candidates(vec![(resumed.clone(), terminal_frame(1))]);
23078        let reset = receiver.try_recv().unwrap();
23079        assert_eq!(reset[0].sequence, 3);
23080        assert!(matches!(
23081            &reset[0].event,
23082            NodeEvent::TerminalFrame { address, frame }
23083                if address == &resumed && frame.sequence == 1
23084        ));
23085        shared.clear_terminal_frame_watermark(&resumed);
23086        assert!(!shared
23087            .terminal_frame_watermarks
23088            .lock()
23089            .unwrap_or_else(|poisoned| poisoned.into_inner())
23090            .contains_key(&resumed.session.instance_id));
23091    }
23092
23093    #[test]
23094    fn terminal_frame_batches_replace_without_entering_node_event_history() {
23095        let shared = terminal_test_shared();
23096        let mut receiver = shared.terminal_event_tx.subscribe();
23097        let address = terminal_address(1);
23098        let count = NODE_EVENT_HISTORY_MAX as u64 + 17;
23099
23100        for sequence in 1..=count {
23101            shared.publish_terminal_frame_candidates(vec![(
23102                address.clone(),
23103                terminal_frame(sequence),
23104            )]);
23105        }
23106
23107        let history = shared
23108            .history
23109            .lock()
23110            .unwrap_or_else(|poisoned| poisoned.into_inner());
23111        assert!(history.events.is_empty());
23112        assert_eq!(history.last_sequence, count);
23113        assert_eq!(history.replay_floor_sequence, 1);
23114        drop(history);
23115        let NodeResponse::Resync {
23116            event_sequence,
23117            oldest_available_sequence,
23118            events,
23119            ..
23120        } = shared.resync(0) else {
23121            unreachable!("resync helper returned another response")
23122        };
23123        assert_eq!(event_sequence, count);
23124        assert_eq!(oldest_available_sequence, 1);
23125        assert!(events.is_empty());
23126        assert!(matches!(
23127            resync_required_event(&shared).event,
23128            NodeEvent::ResyncRequired {
23129                oldest_available_sequence: 1,
23130            }
23131        ));
23132        assert!(matches!(
23133            receiver.try_recv(),
23134            Err(broadcast::error::TryRecvError::Lagged(skipped)) if skipped == count - 1
23135        ));
23136        let latest = receiver.try_recv().unwrap();
23137        assert_eq!(latest.len(), 1);
23138        assert_eq!(latest[0].sequence, count);
23139    }
23140
23141    #[test]
23142    fn real_durable_eviction_advances_authoritative_replay_floor() {
23143        let shared = terminal_test_shared();
23144        for _ in 0..=NODE_EVENT_HISTORY_MAX {
23145            shared.publish(NodeEvent::ControllerChanged { controller: None });
23146        }
23147
23148        let NodeResponse::Resync {
23149            event_sequence,
23150            oldest_available_sequence,
23151            events,
23152            ..
23153        } = shared.resync(0) else {
23154            unreachable!("resync helper returned another response")
23155        };
23156        assert_eq!(event_sequence, NODE_EVENT_HISTORY_MAX as u64 + 1);
23157        assert_eq!(oldest_available_sequence, 2);
23158        assert_eq!(events.first().map(|event| event.sequence), Some(2));
23159        assert_eq!(events.len(), NODE_EVENT_HISTORY_MAX);
23160        assert!(matches!(
23161            resync_required_event(&shared).event,
23162            NodeEvent::ResyncRequired {
23163                oldest_available_sequence: 2,
23164            }
23165        ));
23166    }
23167
23168    #[test]
23169    fn legacy_terminal_frame_connection_has_no_live_subscription() {
23170        let shared = terminal_test_shared();
23171        let without_capability = terminal_event_subscription(&shared, false);
23172        let with_capability = terminal_event_subscription(&shared, true);
23173        assert!(without_capability.is_none());
23174        assert!(with_capability.is_some());
23175        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23176            capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
23177        }));
23178    }
23179
23180    #[test]
23181    fn continuous_terminal_output_leaves_request_service_after_bounded_event_burst() {
23182        let mut pending = Vec::new();
23183        let mut event_sequence = 0_u64;
23184        for frame_sequence in 1..=4 {
23185            for instance in 1..=(NODE_CONNECTION_EVENT_BURST_MAX as u64 * 2) {
23186                event_sequence += 1;
23187                let mut address = terminal_address(1);
23188                address.session.instance_id = AgentInstanceId(instance);
23189                queue_connection_event(
23190                    &mut pending,
23191                    NodeEventEnvelope {
23192                        sequence: event_sequence,
23193                        event: NodeEvent::TerminalFrame {
23194                            address,
23195                            frame: terminal_frame(frame_sequence),
23196                        },
23197                    },
23198                    0,
23199                );
23200            }
23201        }
23202        assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX * 2);
23203        pending.sort_unstable_by_key(|event| event.sequence);
23204
23205        let mut uninterrupted = pending.clone();
23206        let first_burst = connection_event_burst_len(uninterrupted.len());
23207        uninterrupted.drain(..first_burst);
23208        let second_burst = connection_event_burst_len(uninterrupted.len());
23209        uninterrupted.drain(..second_burst);
23210        assert!(uninterrupted.is_empty());
23211
23212        let (frame_tx, mut frame_rx) =
23213            mpsc::channel::<Result<ClientFrame, FrameError>>(1);
23214        frame_tx
23215            .try_send(Ok(ClientFrame::Request(RequestEnvelope {
23216                request_id: 77,
23217                request: NodeRequest::Snapshot,
23218            })))
23219            .unwrap();
23220        let burst_len = connection_event_burst_len(pending.len());
23221        assert_eq!(burst_len, NODE_CONNECTION_EVENT_BURST_MAX);
23222        pending.drain(..burst_len);
23223        assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX);
23224        assert!(matches!(
23225            frame_rx.try_recv(),
23226            Ok(Ok(ClientFrame::Request(RequestEnvelope {
23227                request_id: 77,
23228                request: NodeRequest::Snapshot,
23229            })))
23230        ));
23231    }
23232
23233    #[test]
23234    fn terminal_lag_watermark_discards_retained_and_replayed_batches() {
23235        let mut pending = Vec::new();
23236        for sequence in [7_u64, 8] {
23237            let mut address = terminal_address(1);
23238            address.session.instance_id = AgentInstanceId(sequence);
23239            queue_connection_event(
23240                &mut pending,
23241                NodeEventEnvelope {
23242                    sequence,
23243                    event: NodeEvent::TerminalFrame {
23244                        address,
23245                        frame: terminal_frame(sequence),
23246                    },
23247                },
23248                0,
23249            );
23250        }
23251        let mut discard_through = 0;
23252        apply_connection_resync_watermark(&mut pending, &mut discard_through, 8);
23253        assert!(pending.is_empty());
23254
23255        let replayed = NodeEventEnvelope {
23256            sequence: 8,
23257            event: NodeEvent::TerminalFrame {
23258                address: terminal_address(1),
23259                frame: terminal_frame(8),
23260            },
23261        };
23262        let advanced = NodeEventEnvelope {
23263            sequence: 9,
23264            event: NodeEvent::TerminalFrame {
23265                address: terminal_address(2),
23266                frame: terminal_frame(1),
23267            },
23268        };
23269        queue_connection_event_batch(
23270            &mut pending,
23271            &[replayed, advanced.clone()],
23272            discard_through,
23273        );
23274        assert_eq!(pending, vec![advanced]);
23275    }
23276
23277    fn record(provider: &str, record_id: &str) -> ManagedSessionRecord {
23278        ManagedSessionRecord {
23279            record_id: SessionRecordId::new(record_id).unwrap(),
23280            display_name: format!("{provider} record"),
23281            provider: agent(provider),
23282            mode: SessionMode::Pty,
23283            state: ManagedSessionState::Unavailable,
23284            workspace_id: WorkspaceId::new("primary").unwrap(),
23285            canonical_root: opaque_windows_path(
23286                std::env::current_dir().unwrap().to_string_lossy().into_owned(),
23287            ),
23288            provider_session: None,
23289            active_session: None,
23290            environment_profile: None,
23291            bundle: None,
23292            context_id: None,
23293            context: None,
23294            exported_context: None,
23295            task_binding: None,
23296            created_at_unix_ms: 1,
23297            updated_at_unix_ms: 1,
23298            last_error: None,
23299        }
23300    }
23301
23302    fn retention_test_record(
23303        workspace_id: &str,
23304        record_id: &str,
23305        state: ManagedSessionState,
23306        updated_at_unix_ms: u64,
23307    ) -> ManagedSessionRecord {
23308        ManagedSessionRecord {
23309            record_id: SessionRecordId::new(record_id).unwrap(),
23310            display_name: format!("{record_id} display"),
23311            provider: agent("claude"),
23312            mode: SessionMode::Pty,
23313            state,
23314            workspace_id: WorkspaceId::new(workspace_id).unwrap(),
23315            canonical_root: opaque_windows_path(
23316                std::env::current_dir().unwrap().to_string_lossy().into_owned(),
23317            ),
23318            provider_session: None,
23319            active_session: None,
23320            environment_profile: None,
23321            bundle: None,
23322            context_id: None,
23323            context: None,
23324            exported_context: None,
23325            task_binding: None,
23326            created_at_unix_ms: updated_at_unix_ms,
23327            updated_at_unix_ms,
23328            last_error: None,
23329        }
23330    }
23331
23332    /// Two workspaces of three `Unavailable` records each, staggered by
23333    /// age, plus one never-touchable decoy per non-`Unavailable` state in
23334    /// `workspace-a` (all as old as that workspace's oldest real record --
23335    /// if the sweep's state gate ever leaked, these would be the first
23336    /// thing it wrongly retired). Ages, oldest to newest:
23337    /// `workspace-a`: a1 (500s), a2 (300s), a3 (100s).
23338    /// `workspace-b`: b1 (400s), b2 (200s), b3 (50s).
23339    fn retention_fixture_records(now: u64) -> Vec<ManagedSessionRecord> {
23340        vec![
23341            retention_test_record("workspace-a", "a1", ManagedSessionState::Unavailable, now - 500_000),
23342            retention_test_record("workspace-a", "a2", ManagedSessionState::Unavailable, now - 300_000),
23343            retention_test_record("workspace-a", "a3", ManagedSessionState::Unavailable, now - 100_000),
23344            retention_test_record("workspace-b", "b1", ManagedSessionState::Unavailable, now - 400_000),
23345            retention_test_record("workspace-b", "b2", ManagedSessionState::Unavailable, now - 200_000),
23346            retention_test_record("workspace-b", "b3", ManagedSessionState::Unavailable, now - 50_000),
23347            retention_test_record("workspace-a", "decoy-live", ManagedSessionState::Live, now - 500_000),
23348            retention_test_record(
23349                "workspace-a",
23350                "decoy-pending",
23351                ManagedSessionState::IdentityPending,
23352                now - 500_000,
23353            ),
23354            retention_test_record(
23355                "workspace-a",
23356                "decoy-dormant",
23357                ManagedSessionState::Dormant,
23358                now - 500_000,
23359            ),
23360        ]
23361    }
23362
23363    fn retention_fixture_shared() -> (NodeShared, NativeRuntime, PathBuf, u64) {
23364        let root = temporary_workspace_root("session-record-retention");
23365        std::fs::create_dir_all(&root).unwrap();
23366        let catalog = active_registry().unwrap();
23367        let (handle, runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23368        let workspace_a =
23369            WorkspaceConfig::new(WorkspaceId::new("workspace-a").unwrap(), &root).unwrap();
23370        let workspace_b =
23371            WorkspaceConfig::new(WorkspaceId::new("workspace-b").unwrap(), &root).unwrap();
23372        let mut shared = NodeShared::new(
23373            handle,
23374            "fixture-token".to_owned(),
23375            NodeId::new("node-retention-test").unwrap(),
23376            vec![workspace_a, workspace_b],
23377            vec![agent("claude")],
23378        );
23379        shared.state_path = Some(root.join("node-state-v10.json"));
23380        let now = unix_time_ms();
23381        for record in retention_fixture_records(now) {
23382            shared.insert_record(record).unwrap();
23383        }
23384        shared
23385            .persist_state_locked()
23386            .expect("fixture-only, empty-history persist against a fresh temp state path");
23387        (shared, runtime, root, now)
23388    }
23389
23390    fn retention_removed_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23391        shared
23392            .history
23393            .lock()
23394            .unwrap_or_else(|poisoned| poisoned.into_inner())
23395            .events
23396            .iter()
23397            .filter_map(|envelope| match &envelope.event {
23398                NodeEvent::SessionRecordRemoved { record_id } => {
23399                    Some(record_id.as_str().to_owned())
23400                }
23401                _ => None,
23402            })
23403            .collect()
23404    }
23405
23406    fn retention_remaining_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23407        shared
23408            .session_records
23409            .lock()
23410            .unwrap_or_else(|poisoned| poisoned.into_inner())
23411            .records
23412            .keys()
23413            .map(|record_id| record_id.as_str().to_owned())
23414            .collect()
23415    }
23416
23417    fn owned(values: &[&str]) -> std::collections::BTreeSet<String> {
23418        values.iter().map(|value| (*value).to_owned()).collect()
23419    }
23420
23421    #[tokio::test]
23422    async fn session_record_retention_keep_n_only_keeps_the_newest_per_workspace() {
23423        let (mut shared, runtime, root, _now) = retention_fixture_shared();
23424        shared.session_record_retention = SessionRecordRetentionConfig {
23425            age_ms: 0,
23426            keep_per_workspace: 1,
23427        };
23428        shared.retire_unavailable_session_records().await;
23429
23430        assert_eq!(
23431            retention_remaining_record_ids(&shared),
23432            owned(&["a3", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23433        );
23434        assert_eq!(
23435            retention_removed_record_ids(&shared),
23436            owned(&["a1", "a2", "b1", "b2"]),
23437        );
23438        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 4);
23439
23440        drop(shared);
23441        drop(runtime);
23442        std::fs::remove_dir_all(root).unwrap();
23443    }
23444
23445    /// Age-only would retire {a1, a2, b1}; keep-N-only would retire
23446    /// {a1, a2, b1, b2}. With both flags set the sweep must retire exactly
23447    /// the intersection {a1, a2, b1} -- `b2` fails the age test (it is only
23448    /// 200s old against a 250s threshold) even though it fails the keep-N
23449    /// test too, proving retirement needs BOTH, not either.
23450    #[tokio::test]
23451    async fn session_record_retention_both_set_retires_only_the_intersection() {
23452        let (mut shared, runtime, root, _now) = retention_fixture_shared();
23453        shared.session_record_retention = SessionRecordRetentionConfig {
23454            age_ms: 250_000,
23455            keep_per_workspace: 1,
23456        };
23457        shared.retire_unavailable_session_records().await;
23458
23459        assert_eq!(
23460            retention_remaining_record_ids(&shared),
23461            owned(&["a3", "b2", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23462        );
23463        assert_eq!(retention_removed_record_ids(&shared), owned(&["a1", "a2", "b1"]));
23464        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 3);
23465
23466        drop(shared);
23467        drop(runtime);
23468        std::fs::remove_dir_all(root).unwrap();
23469    }
23470
23471    #[tokio::test]
23472    async fn session_record_retention_both_disabled_is_a_no_op() {
23473        let (mut shared, runtime, root, _now) = retention_fixture_shared();
23474        shared.session_record_retention = SessionRecordRetentionConfig::default();
23475        shared.retire_unavailable_session_records().await;
23476
23477        assert_eq!(
23478            retention_remaining_record_ids(&shared),
23479            owned(&[
23480                "a1", "a2", "a3", "b1", "b2", "b3",
23481                "decoy-live", "decoy-pending", "decoy-dormant",
23482            ]),
23483        );
23484        assert!(retention_removed_record_ids(&shared).is_empty());
23485        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 0);
23486
23487        drop(shared);
23488        drop(runtime);
23489        std::fs::remove_dir_all(root).unwrap();
23490    }
23491
23492    #[test]
23493    fn session_record_context_export_selects_one_exact_history_identity() {
23494        let candidates = vec![
23495            HistoryCandidateSummary {
23496                id: "candidate-other".to_owned(),
23497                session_id_hint: "provider-other".to_owned(),
23498                modified_at_unix_ms: Some(1),
23499            },
23500            HistoryCandidateSummary {
23501                id: "candidate-exact".to_owned(),
23502                session_id_hint: "provider-exact".to_owned(),
23503                modified_at_unix_ms: Some(2),
23504            },
23505        ];
23506        assert_eq!(
23507            select_history_candidate(&candidates, Some("provider-exact")).unwrap(),
23508            "candidate-exact",
23509        );
23510    }
23511
23512    #[test]
23513    fn session_record_context_export_rejects_missing_and_ambiguous_history_identity() {
23514        let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
23515            id: id.to_owned(),
23516            session_id_hint: hint.to_owned(),
23517            modified_at_unix_ms: None,
23518        };
23519        let missing = select_history_candidate(
23520            &[candidate("candidate-other", "provider-other")],
23521            Some("provider-exact"),
23522        )
23523        .unwrap_err();
23524        assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23525        let ambiguous = select_history_candidate(
23526            &[
23527                candidate("candidate-a", "provider-exact"),
23528                candidate("candidate-b", "provider-exact"),
23529            ],
23530            Some("provider-exact"),
23531        )
23532        .unwrap_err();
23533        assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
23534    }
23535
23536    /// The PTY-sidecar-observation shape (no fleet provider today, but
23537    /// capability-keyed rather than vendor-locked) never carries a verified
23538    /// `provider_session`; its record-bound export instead falls back to
23539    /// "exactly one discovered candidate" (see
23540    /// `export_context_pack_for_session_record_inner`). This is the same
23541    /// ambiguity refusal as the identity-filtered path, just without an
23542    /// identity to filter by.
23543    #[test]
23544    fn session_record_context_export_selects_sole_history_candidate_without_identity() {
23545        let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
23546            id: id.to_owned(),
23547            session_id_hint: hint.to_owned(),
23548            modified_at_unix_ms: None,
23549        };
23550        assert_eq!(
23551            select_history_candidate(&[candidate("candidate-sole", "sidecar-session")], None)
23552                .unwrap(),
23553            "candidate-sole",
23554        );
23555        let missing = select_history_candidate(&[], None).unwrap_err();
23556        assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23557        let ambiguous = select_history_candidate(
23558            &[
23559                candidate("candidate-a", "sidecar-session-a"),
23560                candidate("candidate-b", "sidecar-session-b"),
23561            ],
23562            None,
23563        )
23564        .unwrap_err();
23565        assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
23566    }
23567
23568    #[test]
23569    fn session_record_context_export_revalidation_rejects_record_change() {
23570        let session = terminal_address(1);
23571        let identity = ProviderSessionIdentity {
23572            key: ProviderSessionKey::SessionId,
23573            id: "provider-exact".to_owned(),
23574            transcript_path: Some(r"C:\private\provider.jsonl".to_owned()),
23575        };
23576        let mut expected = record("claude", "record-context-export");
23577        expected.state = ManagedSessionState::Live;
23578        expected.provider_session = Some(identity.clone());
23579        expected.active_session = Some(session.clone());
23580        let binding = SessionBinding {
23581            workspace_id: session.workspace_id.clone(),
23582            generation: session.session.generation,
23583            runtime_policy: ProviderRuntimePolicy::raw_pty(),
23584            pending_resume: None,
23585            record_id: Some(expected.record_id.clone()),
23586            managed_worktree_lease_id: None,
23587            environment_profile: None,
23588            bundle: None,
23589            context: None,
23590            materialization_id: None,
23591        };
23592        let current_root = windows_path_text(&expected.canonical_root).to_owned();
23593        assert!(session_record_context_export_binding_is_exact(
23594            &expected,
23595            &expected,
23596            Some(&identity),
23597            &session,
23598            &binding,
23599            &expected.provider,
23600            &current_root,
23601            false,
23602        ));
23603        let mut changed = expected.clone();
23604        changed.updated_at_unix_ms += 1;
23605        assert!(!session_record_context_export_binding_is_exact(
23606            &expected,
23607            &changed,
23608            Some(&identity),
23609            &session,
23610            &binding,
23611            &expected.provider,
23612            &current_root,
23613            false,
23614        ));
23615        // A same-instant timestamp bump alone (state and active_session
23616        // otherwise unchanged) is not the clean-detachment transition
23617        // either, even with the reactive flag on: only the exact Live ->
23618        // Dormant + active_session cleared shape qualifies.
23619        assert!(!session_record_context_export_binding_is_exact(
23620            &expected,
23621            &changed,
23622            Some(&identity),
23623            &session,
23624            &binding,
23625            &expected.provider,
23626            &current_root,
23627            true,
23628        ));
23629        let mut detached = expected.clone();
23630        detached.state = ManagedSessionState::Dormant;
23631        detached.active_session = None;
23632        detached.updated_at_unix_ms += 1;
23633        assert!(!session_record_context_export_binding_is_exact(
23634            &expected,
23635            &detached,
23636            Some(&identity),
23637            &session,
23638            &binding,
23639            &expected.provider,
23640            &current_root,
23641            false,
23642        ));
23643        assert!(session_record_context_export_binding_is_exact(
23644            &expected,
23645            &detached,
23646            Some(&identity),
23647            &session,
23648            &binding,
23649            &expected.provider,
23650            &current_root,
23651            true,
23652        ));
23653    }
23654
23655    /// Mirrors `session_record_context_export_revalidation_rejects_record_change`
23656    /// for a record whose provider never carries a verified identity (the
23657    /// PTY-sidecar-observation shape -- capability-keyed, no fleet provider
23658    /// declares one today): `identity` is `None` throughout, and the
23659    /// exactness check falls back to comparing `provider_session` itself,
23660    /// which must still be `None` on both sides.
23661    #[test]
23662    fn session_record_context_export_binding_is_exact_without_identity() {
23663        let session = terminal_address(1);
23664        let mut expected = record("pty-sidecar-fixture", "record-context-export-no-identity");
23665        expected.state = ManagedSessionState::Live;
23666        expected.active_session = Some(session.clone());
23667        let binding = SessionBinding {
23668            workspace_id: session.workspace_id.clone(),
23669            generation: session.session.generation,
23670            runtime_policy: ProviderRuntimePolicy::raw_pty(),
23671            pending_resume: None,
23672            record_id: Some(expected.record_id.clone()),
23673            managed_worktree_lease_id: None,
23674            environment_profile: None,
23675            bundle: None,
23676            context: None,
23677            materialization_id: None,
23678        };
23679        let current_root = windows_path_text(&expected.canonical_root).to_owned();
23680        assert!(session_record_context_export_binding_is_exact(
23681            &expected,
23682            &expected,
23683            None,
23684            &session,
23685            &binding,
23686            &expected.provider,
23687            &current_root,
23688            false,
23689        ));
23690        // A record that gained an identity between the initial lookup and
23691        // this recheck no longer matches the `None` this shape expects to
23692        // keep seeing -- treated the same as any other mid-export drift.
23693        let mut drifted = expected.clone();
23694        drifted.provider_session = Some(ProviderSessionIdentity {
23695            key: ProviderSessionKey::SessionId,
23696            id: "unexpected-identity".to_owned(),
23697            transcript_path: None,
23698        });
23699        assert!(!session_record_context_export_binding_is_exact(
23700            &expected,
23701            &drifted,
23702            None,
23703            &session,
23704            &binding,
23705            &expected.provider,
23706            &current_root,
23707            false,
23708        ));
23709    }
23710
23711    #[test]
23712    fn session_record_context_export_preflight_requires_live_usable_source() {
23713        let mut source = record("claude", "record-context-export-preflight");
23714        source.state = ManagedSessionState::Live;
23715        assert!(session_record_context_export_source_is_usable(
23716            &source,
23717            &SessionStatus::Running,
23718            false,
23719        ));
23720        assert!(session_record_context_export_source_is_usable(
23721            &source,
23722            &SessionStatus::Exited { exit_code: Some(0) },
23723            false,
23724        ));
23725
23726        source.state = ManagedSessionState::Dormant;
23727        assert!(!session_record_context_export_source_is_usable(
23728            &source,
23729            &SessionStatus::Running,
23730            false,
23731        ));
23732        // The reactive auto-export-at-exit path (allow_clean_detachment) is
23733        // the only caller that may treat a Dormant, cleanly-exited record as
23734        // still usable — the on-demand path above never does.
23735        assert!(session_record_context_export_source_is_usable(
23736            &source,
23737            &SessionStatus::Exited { exit_code: Some(0) },
23738            true,
23739        ));
23740        assert!(!session_record_context_export_source_is_usable(
23741            &source,
23742            &SessionStatus::Exited { exit_code: Some(0) },
23743            false,
23744        ));
23745        source.state = ManagedSessionState::Live;
23746        assert!(!session_record_context_export_source_is_usable(
23747            &source,
23748            &SessionStatus::Starting,
23749            false,
23750        ));
23751    }
23752
23753    #[test]
23754    fn session_record_context_export_final_conflict_leaves_catalog_empty() {
23755        let catalog = active_registry().unwrap();
23756        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23757        let workspace = WorkspaceConfig::new(
23758            WorkspaceId::new("primary").unwrap(),
23759            std::env::current_dir().unwrap(),
23760        )
23761        .unwrap();
23762        let shared = NodeShared::new(
23763            handle,
23764            "fixture-token".to_owned(),
23765            NodeId::new("node-context-export-conflict").unwrap(),
23766            vec![workspace],
23767            vec![agent("claude")],
23768        );
23769        let history = HistorySessionRecord {
23770            session_id: "provider-exact".to_owned(),
23771            title: None,
23772            cwd: None,
23773            model: None,
23774            message_count: 1,
23775            completed_turn_count: None,
23776            total_tokens: 0,
23777            messages: vec![gate4agent_types::HistoryMessageRecord {
23778                role: gate4agent_types::HistoryMessageRole::User,
23779                text: "bounded context".to_owned(),
23780            }],
23781        };
23782        let pack = NodeContextPack::export(
23783            ContextPackLineageReceipt {
23784                source_node_id: shared.node_id.clone(),
23785                source_session: terminal_address(1),
23786                source_provider: agent("claude"),
23787            },
23788            &history,
23789        )
23790        .unwrap();
23791        let context_id = pack.receipt().id.clone();
23792        let session = terminal_address(1);
23793        let identity = ProviderSessionIdentity {
23794            key: ProviderSessionKey::SessionId,
23795            id: history.session_id.clone(),
23796            transcript_path: None,
23797        };
23798        let mut expected = record("claude", "record-context-export-final-conflict");
23799        expected.state = ManagedSessionState::Live;
23800        expected.provider_session = Some(identity.clone());
23801        expected.active_session = Some(session.clone());
23802        let mut changed = expected.clone();
23803        changed.updated_at_unix_ms += 1;
23804        shared.insert_record(changed).unwrap();
23805
23806        let error = shared
23807            .commit_context_pack_for_session_record(
23808                &expected,
23809                Some(&identity),
23810                &session,
23811                "candidate-exact",
23812                &history,
23813                pack,
23814                false,
23815            )
23816            .unwrap_err();
23817
23818        assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
23819        assert!(shared
23820            .context_catalog
23821            .read()
23822            .unwrap_or_else(|poisoned| poisoned.into_inner())
23823            .get(&context_id)
23824            .is_none());
23825    }
23826
23827    #[tokio::test]
23828    async fn session_task_binding_mints_assigns_clears_with_cas_and_persists_v9() {
23829        let root = temporary_workspace_root("session-task-binding");
23830        std::fs::create_dir_all(&root).unwrap();
23831        let state_path = root.join("node-state.json");
23832        let catalog = active_registry().unwrap();
23833        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23834        let workspace = WorkspaceConfig::new(WorkspaceId::new("primary").unwrap(), &root).unwrap();
23835        let node_id = NodeId::new("node-1").unwrap();
23836        let mut shared = NodeShared::new(
23837            handle,
23838            "fixture-token".to_owned(),
23839            node_id.clone(),
23840            vec![workspace],
23841            vec![agent("claude")],
23842        );
23843        shared.state_path = Some(state_path.clone());
23844        let record = record("claude", "sr-task");
23845        let record_id = record.record_id.clone();
23846        shared.insert_record(record).unwrap();
23847        shared.persist_state().unwrap();
23848        shared
23849            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
23850            .unwrap();
23851
23852        let minted = process_request_inner(
23853            &shared,
23854            77,
23855            ClientRole::Operator,
23856            NodeRequest::SetSessionTask {
23857                record_id: record_id.clone(),
23858                expected_revision: 0,
23859                target: SessionTaskTargetV1::New,
23860            },
23861        )
23862        .await
23863        .unwrap();
23864        let NodeResponse::SessionRecordUpdated { record: minted } = minted else {
23865            panic!("session task mutation returned another response");
23866        };
23867        let minted_binding = minted.task_binding.clone().unwrap();
23868        let minted_task_id = minted_binding.task_id.clone().unwrap();
23869        assert_eq!(minted_binding.revision, 1);
23870        assert!(minted_task_id.as_str().starts_with("task-"));
23871        assert_eq!(minted_task_id.as_str().len(), 29);
23872
23873        let event_count = shared.current_sequence();
23874        let repeated = process_request_inner(
23875            &shared,
23876            77,
23877            ClientRole::Operator,
23878            NodeRequest::SetSessionTask {
23879                record_id: record_id.clone(),
23880                expected_revision: 1,
23881                target: SessionTaskTargetV1::Existing {
23882                    task_id: minted_task_id.clone(),
23883                },
23884            },
23885        )
23886        .await
23887        .unwrap();
23888        assert_eq!(repeated, NodeResponse::SessionRecordUpdated { record: minted.clone() });
23889        assert_eq!(shared.current_sequence(), event_count);
23890
23891        let stale = process_request_inner(
23892            &shared,
23893            77,
23894            ClientRole::Operator,
23895            NodeRequest::SetSessionTask {
23896                record_id: record_id.clone(),
23897                expected_revision: 0,
23898                target: SessionTaskTargetV1::Existing {
23899                    task_id: minted_task_id,
23900                },
23901            },
23902        )
23903        .await
23904        .unwrap_err();
23905        assert_eq!(stale.code, NodeFailureCode::SessionRecordConflict);
23906
23907        let assigned_id: TaskId = "task-ffeeddccbbaa998877665544".parse().unwrap();
23908        let assigned = process_request_inner(
23909            &shared,
23910            77,
23911            ClientRole::Operator,
23912            NodeRequest::SetSessionTask {
23913                record_id: record_id.clone(),
23914                expected_revision: 1,
23915                target: SessionTaskTargetV1::Existing {
23916                    task_id: assigned_id.clone(),
23917                },
23918            },
23919        )
23920        .await
23921        .unwrap();
23922        let NodeResponse::SessionRecordUpdated { record: assigned } = assigned else {
23923            panic!("session task assignment returned another response");
23924        };
23925        assert_eq!(assigned.task_binding.as_ref().unwrap().revision, 2);
23926        assert_eq!(assigned.task_binding.as_ref().unwrap().task_id.as_ref(), Some(&assigned_id));
23927
23928        let cleared = process_request_inner(
23929            &shared,
23930            77,
23931            ClientRole::Operator,
23932            NodeRequest::SetSessionTask {
23933                record_id: record_id.clone(),
23934                expected_revision: 2,
23935                target: SessionTaskTargetV1::Clear,
23936            },
23937        )
23938        .await
23939        .unwrap();
23940        let NodeResponse::SessionRecordUpdated { record: cleared } = cleared else {
23941            panic!("session task clear returned another response");
23942        };
23943        assert_eq!(cleared.task_binding.as_ref().unwrap().revision, 3);
23944        assert!(cleared.task_binding.as_ref().unwrap().task_id.is_none());
23945        assert!(cleared.task_binding.as_ref().unwrap().changed_at_unix_ms > 0);
23946        assert_eq!(
23947            cleared.task_binding.as_ref().unwrap().changed_at_unix_ms,
23948            cleared.updated_at_unix_ms,
23949        );
23950
23951        let persisted = std::fs::read(&state_path).unwrap();
23952        assert_eq!(serde_json::from_slice::<serde_json::Value>(&persisted).unwrap()["version"], NODE_STATE_SCHEMA_V10);
23953        let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
23954        assert_eq!(loaded.records[0].task_binding, cleared.task_binding);
23955        std::fs::remove_dir_all(root).unwrap();
23956    }
23957
23958    #[test]
23959    fn session_task_capability_strips_snapshots_events_and_replies() {
23960        let mut bound = record("claude", "sr-task-projection");
23961        bound.updated_at_unix_ms = 2;
23962        bound.task_binding = Some(SessionTaskBindingV1 {
23963            revision: 1,
23964            task_id: Some("task-00112233445566778899aaff".parse().unwrap()),
23965            changed_at_unix_ms: 2,
23966        });
23967        let request = NodeRequest::SetSessionTask {
23968            record_id: bound.record_id.clone(),
23969            expected_revision: 1,
23970            target: SessionTaskTargetV1::Clear,
23971        };
23972        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23973            capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
23974        }));
23975        assert!(!request_uses_unnegotiated_capability(
23976            &request,
23977            &baseline_capabilities().unwrap(),
23978        ));
23979        assert!(request_uses_unnegotiated_capability(&request, &[]));
23980
23981        let mut snapshot = terminal_test_shared().snapshot();
23982        snapshot.session_records.push(bound.clone());
23983        let mut reply = ResponseEnvelope {
23984            request_id: 1,
23985            result: Ok(NodeResponse::Resync {
23986                event_sequence: 1,
23987                oldest_available_sequence: 1,
23988                snapshot,
23989                events: vec![NodeEventEnvelope {
23990                    sequence: 1,
23991                    event: NodeEvent::SessionRecordUpserted {
23992                        record: bound.clone(),
23993                    },
23994                }],
23995            }),
23996        };
23997        project_response_without_session_task_binding(&mut reply);
23998        let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
23999            panic!("task projection changed the response kind");
24000        };
24001        assert!(snapshot.session_records[0].task_binding.is_none());
24002        let NodeEvent::SessionRecordUpserted { record } = &events[0].event else {
24003            panic!("task projection changed the event kind");
24004        };
24005        assert!(record.task_binding.is_none());
24006
24007        let projected = project_event_without_session_task_binding(NodeEventEnvelope {
24008            sequence: 2,
24009            event: NodeEvent::SessionRecordUpserted { record: bound.clone() },
24010        });
24011        let NodeEvent::SessionRecordUpserted { record } = projected.event else {
24012            panic!("task event projection changed the event kind");
24013        };
24014        assert!(record.task_binding.is_none());
24015
24016        let mut direct = ResponseEnvelope {
24017            request_id: 2,
24018            result: Ok(NodeResponse::SessionRecordUpdated { record: bound }),
24019        };
24020        project_response_without_session_task_binding(&mut direct);
24021        let Ok(NodeResponse::SessionRecordUpdated { record }) = direct.result else {
24022            panic!("task reply projection changed the response kind");
24023        };
24024        assert!(record.task_binding.is_none());
24025    }
24026
24027    #[test]
24028    fn windows_runtime_default_node_endpoint_is_exact_and_valid() {
24029        assert_eq!(DEFAULT_NODE_ENDPOINT, r"\\.\pipe\gate4agent-node");
24030        let workspace = WorkspaceConfig::new(
24031            WorkspaceId::new("primary").unwrap(),
24032            std::env::current_dir().unwrap(),
24033        )
24034        .unwrap();
24035        let config = NodeServerConfig::new(
24036            DEFAULT_NODE_ENDPOINT,
24037            "fixture-token",
24038            NodeId::new("node-1").unwrap(),
24039            [workspace],
24040        )
24041        .unwrap();
24042        assert_eq!(config.endpoint, DEFAULT_NODE_ENDPOINT);
24043    }
24044
24045    #[cfg(all(feature = "fixture", windows))]
24046    #[test]
24047    fn provider_bundle_argv_hold_fixture_is_parent_bounded_and_waits_after_proof() {
24048        let root = temporary_workspace_root("provider-bundle-argv-hold");
24049        let outside = temporary_workspace_root("provider-bundle-argv-hold-outside");
24050        std::fs::create_dir_all(&root).unwrap();
24051        std::fs::create_dir_all(&outside).unwrap();
24052        let proof_path = root.join("provider.proof");
24053        let release_signal = root.join("release.signal");
24054        let config = NodeServerConfig::new(
24055            format!(
24056                r"\\.\pipe\gate4agent-provider-bundle-hold-{}",
24057                std::process::id(),
24058            ),
24059            "fixture-token",
24060            NodeId::new("provider-bundle-hold-node").unwrap(),
24061            [WorkspaceConfig::new(
24062                WorkspaceId::new("primary").unwrap(),
24063                std::env::current_dir().unwrap(),
24064            )
24065            .unwrap()],
24066        )
24067        .unwrap()
24068        .with_spawn_profiles(SpawnProfileRegistry::new([
24069            crate::protocol::SpawnProfileDefaults {
24070                profile_id: crate::protocol::SpawnProfileId::new("kimi-bundle-hold").unwrap(),
24071                revision: crate::protocol::SpawnProfileRevision::new("kimi-bundle-hold-r1")
24072                    .unwrap(),
24073                provider: agent("kimi"),
24074                mode: SessionMode::Pty,
24075                terminal_size: gate4agent_types::TerminalSize {
24076                    rows: 24,
24077                    columns: 80,
24078                },
24079                prompt: None,
24080                bundle_id: None,
24081                context_id: None,
24082                environment_profile_id: None,
24083            },
24084        ])
24085        .unwrap());
24086        let server = NodeServer::new_provider_bundle_argv_hold_fixture(
24087            config,
24088            agent("kimi"),
24089            proof_path.clone(),
24090            release_signal.clone(),
24091        )
24092        .unwrap();
24093        assert_eq!(server.shared.enabled_providers, [agent("kimi")]);
24094
24095        let spec = NodeServer::provider_bundle_argv_fixture_spec(
24096            agent("kimi"),
24097            proof_path.clone(),
24098            Some(release_signal.clone()),
24099        )
24100        .unwrap();
24101        let wrapper = &spec.launch.fixed_args[spec.launch.fixed_args.len() - 3];
24102        let proof_write = wrapper.find("WriteAllLines($proofPath").unwrap();
24103        let release_wait = wrapper
24104            .find("while (-not (Test-Path -LiteralPath $releaseSignal -PathType Leaf))")
24105            .unwrap();
24106        let fixture_ready = wrapper.find("fixture-ready>").unwrap();
24107        assert!(proof_write < release_wait);
24108        assert!(release_wait < fixture_ready);
24109        assert!(wrapper.contains("[DateTime]::UtcNow.AddSeconds(45)"));
24110        assert!(wrapper.contains("Start-Sleep -Milliseconds 20"));
24111        assert_eq!(
24112            &spec.launch.fixed_args[spec.launch.fixed_args.len() - 2..],
24113            &[
24114                proof_path.to_string_lossy().into_owned(),
24115                release_signal.to_string_lossy().into_owned(),
24116            ],
24117        );
24118
24119        let error = NodeServer::provider_bundle_argv_fixture_spec(
24120            agent("kimi"),
24121            proof_path,
24122            Some(outside.join("release.signal")),
24123        )
24124        .unwrap_err();
24125        assert!(matches!(
24126            error,
24127            NodeServerError::Registry(message)
24128                if message == "provider bundle argv release path must share the proof parent"
24129        ));
24130        drop(server);
24131        std::fs::remove_dir_all(root).unwrap();
24132        std::fs::remove_dir_all(outside).unwrap();
24133    }
24134
24135    #[cfg(feature = "fixture")]
24136    #[test]
24137    fn four_provider_context_fixture_registry_is_unambiguous_and_history_bound() {
24138        let root = temporary_workspace_root("context-fixture-registry");
24139        std::fs::create_dir_all(&root).unwrap();
24140        let proof_path = root.join("context.proof");
24141        let config = NodeServerConfig::new(
24142            format!(
24143                r"\\.\pipe\gate4agent-context-fixture-registry-{}",
24144                std::process::id(),
24145            ),
24146            "fixture-token",
24147            NodeId::new("context-fixture-node").unwrap(),
24148            [WorkspaceConfig::new(
24149                WorkspaceId::new("primary").unwrap(),
24150                std::env::current_dir().unwrap(),
24151            )
24152            .unwrap()],
24153        )
24154        .unwrap();
24155        let server = NodeServer::new_context_pack_fixture(config, proof_path).unwrap();
24156        let mut enabled = server
24157            .shared
24158            .enabled_providers
24159            .iter()
24160            .map(AgentId::as_str)
24161            .collect::<Vec<_>>();
24162        enabled.sort_unstable();
24163        assert_eq!(enabled, ["claude", "codex", "grok", "kimi"]);
24164        for provider in &server.shared.enabled_providers {
24165            assert!(server
24166                .shared
24167                .provider_adapter_contracts
24168                .iter()
24169                .any(|contract| {
24170                    &contract.provider == provider
24171                        && contract.family == AdapterFamily::History
24172                }));
24173        }
24174        drop(server);
24175        std::fs::remove_dir_all(root).unwrap();
24176    }
24177
24178    #[cfg(feature = "fixture")]
24179    #[test]
24180    fn monitoring_context_fixture_combines_claude_and_codex_hooks_with_history() {
24181        let root = temporary_workspace_root("monitoring-context-fixture");
24182        std::fs::create_dir_all(&root).unwrap();
24183        let proof_path = root.join("context.proof");
24184        let standard = NodeServer::context_pack_fixture_catalog(
24185            proof_path.clone(),
24186            None,
24187            false,
24188        ).unwrap();
24189        let monitoring = NodeServer::context_pack_fixture_catalog(
24190            proof_path.clone(),
24191            None,
24192            true,
24193        ).unwrap();
24194        let claude = AgentId::new("claude").unwrap();
24195        let standard_claude = standard.get(&claude).unwrap();
24196        let monitoring_claude = monitoring.get(&claude).unwrap();
24197        // Lifecycle hooks are retired (owner ruling 2026-09-25): neither the
24198        // standard nor the monitoring fixture declares a hook adapter
24199        // anymore.
24200        assert!(monitoring_claude.capabilities.adapters.hook.is_none());
24201        assert_eq!(
24202            monitoring_claude.capabilities.adapters.history,
24203            standard_claude.capabilities.adapters.history,
24204        );
24205        assert_ne!(monitoring_claude.launch, standard_claude.launch);
24206        let codex = AgentId::new("codex").unwrap();
24207        let standard_codex = standard.get(&codex).unwrap();
24208        let monitoring_codex = monitoring.get(&codex).unwrap();
24209        assert!(monitoring_codex.capabilities.adapters.hook.is_none());
24210        assert_eq!(
24211            monitoring_codex.capabilities.adapters.history,
24212            standard_codex.capabilities.adapters.history,
24213        );
24214        assert_ne!(monitoring_codex.launch, standard_codex.launch);
24215        let codex_validation = monitoring_codex.launch.fixed_args.iter()
24216            .find(|argument| argument.contains("F7_CODEX_BUNDLE_CONTEXT_VALIDATED"))
24217            .unwrap();
24218        for required in [
24219            "$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')",
24220            "$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')",
24221            "@('role', 'text')",
24222            "g4a-private-provider-session-canary",
24223            "private-provider-login-identity",
24224            "$rawProof",
24225            "$env:GATE4AGENT_HOOK_URL",
24226            "g4a-private-provider-session-canary",
24227        ] {
24228            assert!(codex_validation.contains(required), "missing privacy validation: {required}");
24229        }
24230        for provider in ["grok", "kimi"] {
24231            let provider = AgentId::new(provider).unwrap();
24232            assert_eq!(monitoring.get(&provider), standard.get(&provider));
24233        }
24234
24235        let config = NodeServerConfig::new(
24236            format!(
24237                r"\\.\pipe\gate4agent-monitoring-context-fixture-{}",
24238                std::process::id(),
24239            ),
24240            "fixture-token",
24241            NodeId::new("monitoring-context-fixture-node").unwrap(),
24242            [WorkspaceConfig::new(
24243                WorkspaceId::new("primary").unwrap(),
24244                std::env::current_dir().unwrap(),
24245            )
24246            .unwrap()],
24247        )
24248        .unwrap();
24249        let server = NodeServer::new_monitoring_context_pack_fixture(config, proof_path).unwrap();
24250        assert!(server.shared.fixture_semantic_hook_policy);
24251        drop(server);
24252        std::fs::remove_dir_all(root).unwrap();
24253    }
24254
24255    #[cfg(feature = "fixture")]
24256    #[test]
24257    fn kimi_context_only_fixture_is_bounded_and_preserves_codex_validation() {
24258        let root = temporary_workspace_root("kimi-context-only-fixture");
24259        std::fs::create_dir_all(&root).unwrap();
24260        let proof_path = root.join("context.proof");
24261        let standard = NodeServer::context_pack_fixture_catalog(
24262            proof_path.clone(),
24263            None,
24264            false,
24265        ).unwrap();
24266        let kimi = AgentId::new("kimi").unwrap();
24267        let context_only = NodeServer::context_pack_fixture_catalog(
24268            proof_path,
24269            Some(&kimi),
24270            false,
24271        )
24272        .unwrap();
24273
24274        let codex = AgentId::new("codex").unwrap();
24275        assert_eq!(
24276            standard.get(&codex).unwrap().launch.fixed_args,
24277            context_only.get(&codex).unwrap().launch.fixed_args,
24278        );
24279        let script = context_only
24280            .get(&kimi)
24281            .unwrap()
24282            .launch
24283            .fixed_args
24284            .iter()
24285            .find(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED"))
24286            .expect("Kimi context-only validation script is missing");
24287        for required in [
24288            "$bundleArgs.Count -ne 0",
24289            "GATE4AGENT_CONTEXT_ROOT",
24290            "context-pack.json",
24291            "$entries.Count -ne 1",
24292            "g4a-context-pack-v1",
24293            "$document.PSObject.Properties['cwd']",
24294            "'claude', 'codex', 'grok', 'kimi'",
24295            "$hasUser",
24296            "$hasAssistant",
24297            "SHA256",
24298            "context-only",
24299        ] {
24300            assert!(script.contains(required), "missing validation: {required}");
24301        }
24302        for forbidden in ["CODEX_HOME", "USERPROFILE", "auth", "token", "global config"] {
24303            assert!(!script.contains(forbidden), "forbidden home/auth access: {forbidden}");
24304        }
24305        assert!(!standard
24306            .get(&kimi)
24307            .unwrap()
24308            .launch
24309            .fixed_args
24310            .iter()
24311            .any(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED")));
24312
24313        std::fs::remove_dir_all(root).unwrap();
24314    }
24315
24316    #[cfg(all(feature = "fixture", windows))]
24317    #[tokio::test]
24318    async fn kimi_context_only_fixture_spawns_catalogued_context_pack_through_spawn_spec() {
24319        let root = temporary_workspace_root("kimi-context-only-spawn");
24320        let workspace_root = root.join("workspace");
24321        std::fs::create_dir_all(&workspace_root).unwrap();
24322        let materialization_root = root.join("private-materializations");
24323        let proof_path = root.join("context.proof");
24324        let node_id = NodeId::new("node-kimi-context-only-spawn").unwrap();
24325        let workspace_id = WorkspaceId::new("primary").unwrap();
24326        let profile_id = crate::protocol::SpawnProfileId::new("target-kimi").unwrap();
24327        let endpoint = format!(
24328            r"\\.\pipe\gate4agent-kimi-context-only-spawn-{}-{}",
24329            std::process::id(),
24330            unix_time_ms(),
24331        );
24332        let spawn_profiles = SpawnProfileRegistry::new([
24333            crate::protocol::SpawnProfileDefaults {
24334                profile_id: profile_id.clone(),
24335                revision: crate::protocol::SpawnProfileRevision::new("target-kimi-r1").unwrap(),
24336                provider: agent("kimi"),
24337                mode: SessionMode::Pty,
24338                terminal_size: gate4agent_types::TerminalSize {
24339                    rows: 30,
24340                    columns: 120,
24341                },
24342                prompt: None,
24343                bundle_id: None,
24344                context_id: None,
24345                environment_profile_id: None,
24346            },
24347        ])
24348        .unwrap();
24349        let mut config = NodeServerConfig::new(
24350            &endpoint,
24351            "fixture-token",
24352            node_id.clone(),
24353            [WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap()],
24354        )
24355        .unwrap()
24356        .with_spawn_profiles(spawn_profiles)
24357        .with_session_environment_materialization(
24358            materialization_root,
24359            Arc::new(FixtureSecretResolver {
24360                deny: Arc::new(AtomicBool::new(false)),
24361            }),
24362        )
24363        .unwrap();
24364        config.fixture_raw_pty_runtime = true;
24365        let server = NodeServer::new_context_only_proof_fixture(
24366            config,
24367            agent("kimi"),
24368            proof_path.clone(),
24369        )
24370        .unwrap();
24371        let shared = Arc::clone(&server.shared);
24372        let shutdown = server.shutdown_handle();
24373
24374        let history = HistorySessionRecord {
24375            session_id: "context-only-source-history".to_owned(),
24376            title: None,
24377            cwd: None,
24378            model: None,
24379            message_count: 2,
24380            completed_turn_count: None,
24381            total_tokens: 0,
24382            messages: vec![
24383                gate4agent_types::HistoryMessageRecord {
24384                    role: gate4agent_types::HistoryMessageRole::User,
24385                    text: "continue the bounded parent run through native C2".to_owned(),
24386                },
24387                gate4agent_types::HistoryMessageRecord {
24388                    role: gate4agent_types::HistoryMessageRole::Assistant,
24389                    text: "the exact parent context is ready for the child run".to_owned(),
24390                },
24391            ],
24392        };
24393        let pack = NodeContextPack::export(
24394            ContextPackLineageReceipt {
24395                source_node_id: node_id.clone(),
24396                source_session: SessionAddress {
24397                    workspace_id: workspace_id.clone(),
24398                    session: SessionKey {
24399                        instance_id: AgentInstanceId(71),
24400                        generation: SessionGeneration(1),
24401                    },
24402                },
24403                source_provider: agent("codex"),
24404            },
24405            &history,
24406        )
24407        .unwrap();
24408        let expected_context_hash = digest(&SHA256, pack.bytes())
24409            .as_ref()
24410            .iter()
24411            .map(|byte| format!("{byte:02x}"))
24412            .collect::<String>();
24413        let context = shared
24414            .context_catalog
24415            .write()
24416            .unwrap_or_else(|poisoned| poisoned.into_inner())
24417            .insert(pack)
24418            .unwrap();
24419        let spec = SpawnSpec {
24420            target: crate::protocol::SpawnTarget {
24421                node_id,
24422                workspace_id: workspace_id.clone(),
24423                worktree_id: None,
24424            },
24425            profile_id,
24426            expected_profile_revision: crate::protocol::SpawnProfileRevision::new(
24427                "target-kimi-r1",
24428            ).unwrap(),
24429            overrides: crate::protocol::SpawnOverrides {
24430                context_id: crate::protocol::SpawnOverride::Set {
24431                    value: context.id.clone(),
24432                },
24433                ..crate::protocol::SpawnOverrides::default()
24434            },
24435            deadline_ms: crate::protocol::SpawnDeadlineMs::new(10_000).unwrap(),
24436            idempotency_key: SpawnIdempotencyKey::new("kimi-context-only-spawn").unwrap(),
24437            required_capabilities: SpawnRequiredCapabilities::default(),
24438        };
24439        let resolved = match shared.resolve_spawn_spec(&spec) {
24440            Ok(resolved) => resolved,
24441            Err(error) => panic!(
24442                "Kimi context-only resolve rejected: exact_code={:?} message={}",
24443                error.code,
24444                error.message,
24445            ),
24446        };
24447        let resolved_context = match shared.resolve_context(&resolved) {
24448            Ok(Some(resolved_context)) => resolved_context,
24449            Ok(None) => panic!("Kimi context-only resolve omitted the exact ContextPack"),
24450            Err(error) => panic!(
24451                "Kimi context catalog resolve rejected: exact_code={:?} message={}",
24452                error.code,
24453                error.message,
24454            ),
24455        };
24456        assert_eq!(resolved_context, context);
24457        let runtime_policy = match shared
24458            .admit_provider_runtime(&agent("kimi"), ProviderRuntimeRequirement::RawPty)
24459            .await
24460        {
24461            Ok(runtime_policy) => runtime_policy,
24462            Err(error) => panic!(
24463                "Kimi RawPty admission rejected: exact_code={:?} message={}",
24464                error.code,
24465                error.message,
24466            ),
24467        };
24468        assert!(runtime_policy.raw_pty_lifecycle);
24469
24470        let launch_control = shared.native_launch_profile_control.as_ref().unwrap();
24471        let generic_overlay = NativeLaunchEnvironmentOverlay::new(
24472            agent("kimi"),
24473            TransportKind::Pty,
24474            vec![EnvMutation {
24475                key: OsString::from("GATE4AGENT_UNPROFILED_GENERIC"),
24476                value: Some(OsString::from("must-remain-rejected")),
24477            }],
24478        )
24479        .unwrap();
24480        assert_eq!(
24481            launch_control
24482                .install_native_launch_environment_overlay(
24483                    AgentInstanceId(72),
24484                    generic_overlay,
24485                )
24486                .unwrap_err(),
24487            gate4agent_runtime_native::NativeLaunchProfileError::EnvironmentOverlaySelectionMissing,
24488        );
24489
24490        let diagnostic_address = SessionAddress {
24491            workspace_id: workspace_id.clone(),
24492            session: SessionKey {
24493                instance_id: AgentInstanceId(73),
24494                generation: SessionGeneration(1),
24495            },
24496        };
24497        let (diagnostic_overlay, diagnostic_materialization) = match shared
24498            .prepare_session_materialization(
24499                &diagnostic_address,
24500                &agent("kimi"),
24501                SessionMode::Pty,
24502                None,
24503                None,
24504                Some(&resolved_context),
24505                None,
24506            )
24507        {
24508            Ok(Some(prepared)) => prepared,
24509            Ok(None) => panic!("Kimi context-only materialization produced no launch overlay"),
24510            Err(error) => panic!(
24511                "Kimi context-only materialization rejected: exact_code={:?} message={}",
24512                error.code,
24513                error.message,
24514            ),
24515        };
24516        let diagnostic_overlay = diagnostic_overlay
24517            .expect("Kimi context-only materialization omitted its environment overlay");
24518        assert!(matches!(
24519            &diagnostic_overlay,
24520            PreparedNativeLaunchOverlay::Environment(_),
24521        ));
24522        match shared.install_prepared_launch_overlay(
24523            diagnostic_address.session.instance_id,
24524            diagnostic_overlay,
24525        ) {
24526            Ok(None) => {}
24527            Ok(Some(_)) => panic!("Kimi context-only overlay unexpectedly retained argv authority"),
24528            Err(error) => panic!(
24529                "Kimi context-only overlay installation rejected: exact_code={:?} message={}",
24530                error.code,
24531                error.message,
24532            ),
24533        }
24534        assert!(launch_control.clear_native_instance_launch_overlay(
24535            diagnostic_address.session.instance_id,
24536        ));
24537        drop(diagnostic_materialization);
24538        assert!(shared
24539            .materializations
24540            .lock()
24541            .unwrap_or_else(|poisoned| poisoned.into_inner())
24542            .is_empty());
24543
24544        let node_task = tokio::spawn(server.run());
24545        let ready_client = timeout(Duration::from_secs(5), async {
24546            loop {
24547                match gate4agent_node_wire::LocalNodeClient::connect(
24548                    &endpoint,
24549                    &shared.node_id,
24550                    ClientRole::Observer,
24551                    "fixture-token",
24552                )
24553                .await
24554                {
24555                    Ok(client) => break client,
24556                    Err(_) if !node_task.is_finished() => sleep(Duration::from_millis(10)).await,
24557                    Err(error) => panic!("Kimi context-only Node stopped before readiness: {error}"),
24558                }
24559            }
24560        })
24561        .await
24562        .expect("Kimi context-only Node did not become ready");
24563        assert_eq!(
24564            ready_client
24565                .hello()
24566                .snapshot
24567                .provider_runtime_statuses
24568                .iter()
24569                .find(|status| status.provider().as_str() == "kimi")
24570                .expect("Kimi runtime status is missing")
24571                .mode(),
24572            crate::protocol::ProviderRuntimeMode::RawPassthrough,
24573        );
24574        drop(ready_client);
24575
24576        let receipt = match shared.spawn_from_spec(spec).await {
24577            Ok(receipt) => receipt,
24578            Err(error) => panic!(
24579                "Kimi context-only SpawnSpec rejected: exact_code={:?} message={}",
24580                error.code,
24581                error.message,
24582            ),
24583        };
24584        assert_eq!(receipt.provider, agent("kimi"));
24585        assert_eq!(receipt.context.as_ref(), Some(&context));
24586        assert!(receipt.context_binding_is_valid());
24587        timeout(Duration::from_secs(5), async {
24588            while !proof_path.is_file() {
24589                sleep(Duration::from_millis(10)).await;
24590            }
24591        })
24592        .await
24593        .expect("Kimi context-only fixture did not write its proof");
24594
24595        let proof = std::fs::read_to_string(&proof_path).unwrap();
24596        let lines = proof.lines().collect::<Vec<_>>();
24597        assert_eq!(lines.len(), 7);
24598        assert_eq!(lines[0], "context-only");
24599        assert!(Path::new(lines[1]).is_absolute());
24600        assert_ne!(
24601            std::fs::canonicalize(Path::new(lines[1])).unwrap(),
24602            std::fs::canonicalize(&workspace_root).unwrap(),
24603        );
24604        assert_eq!(
24605            std::fs::canonicalize(Path::new(lines[2])).unwrap(),
24606            std::fs::canonicalize(&workspace_root).unwrap(),
24607        );
24608        assert_eq!(lines[3], expected_context_hash);
24609        assert_eq!(lines[4], "g4a-context-pack-v1");
24610        assert_eq!(lines[5], "codex");
24611        assert_eq!(lines[6], "2");
24612        assert!(shared.handle.snapshot().sessions.iter().any(|session| {
24613            session.instance_id == receipt.session.session.instance_id
24614                && session.agent_id.as_str() == "kimi"
24615        }));
24616
24617        shutdown.request_shutdown().await.unwrap();
24618        timeout(Duration::from_secs(5), node_task)
24619            .await
24620            .unwrap()
24621            .unwrap()
24622            .unwrap();
24623        drop(shutdown);
24624        drop(shared);
24625        std::fs::remove_dir_all(root).unwrap();
24626    }
24627
24628    #[cfg(feature = "fixture")]
24629    #[test]
24630    fn clean_exit_fixture_constructor_is_fixture_scoped_and_path_bounded() {
24631        let root = temporary_workspace_root("clean-exit-constructor");
24632        std::fs::create_dir_all(&root).unwrap();
24633        let config = construction_test_config("clean-exit-constructor")
24634            .with_state_path(root.join("node-state.json"))
24635            .unwrap();
24636        let server = NodeServer::new_clean_exit_fixture(
24637            config,
24638            root.clone(),
24639            root.join("started.marker"),
24640            root.join("release.signal"),
24641        )
24642        .unwrap();
24643        assert_eq!(
24644            server
24645                .shared
24646                .enabled_providers
24647                .iter()
24648                .map(AgentId::as_str)
24649                .collect::<Vec<_>>(),
24650            ["claude"],
24651        );
24652        drop(server);
24653        std::fs::remove_dir_all(root).unwrap();
24654    }
24655
24656    #[test]
24657    fn production_registry_provider_contract_manifest_is_exact() {
24658        let registry = active_registry().unwrap();
24659        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
24660        assert_eq!(
24661            providers
24662                .iter()
24663                .map(|contract| (contract.provider.as_str(), contract.revision.as_str()))
24664                .collect::<Vec<_>>(),
24665            vec![
24666                ("claude", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24667                ("codex", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24668                ("grok", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24669                ("kimi", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24670            ]
24671        );
24672        assert_eq!(
24673            adapters
24674                .iter()
24675                .map(|contract| (
24676                    contract.provider.as_str(),
24677                    contract.family,
24678                    contract.adapter_id.as_str(),
24679                    contract.revision.as_str(),
24680                ))
24681                .collect::<Vec<_>>(),
24682            vec![
24683                ("claude", AdapterFamily::PtySemantic, "claude-code", "gate4agent-adapter/v1"),
24684                ("claude", AdapterFamily::Pipe, "claude-code", "gate4agent-adapter/v1"),
24685                ("claude", AdapterFamily::Acp, "claude-code", "gate4agent-adapter/v1"),
24686                ("claude", AdapterFamily::OneShot, "claude", "gate4agent-inline/claude-code-2.1/v1"),
24687                ("claude", AdapterFamily::History, "claude-code", "gate4agent-adapter/v1"),
24688                ("claude", AdapterFamily::Resume, "claude-code", "gate4agent-adapter/v1"),
24689                ("claude", AdapterFamily::SessionOptions, "claude-code", "gate4agent-session-options/orca-d8629c4/v1"),
24690                ("codex", AdapterFamily::PtySemantic, "codex", "gate4agent-adapter/v1"),
24691                ("codex", AdapterFamily::Pipe, "codex", "gate4agent-adapter/v1"),
24692                ("codex", AdapterFamily::Acp, "codex", "gate4agent-adapter/v1"),
24693                ("codex", AdapterFamily::OneShot, "codex", "gate4agent-inline/codex-cli-0.144/v1"),
24694                ("codex", AdapterFamily::History, "codex", "gate4agent-adapter/v1"),
24695                ("codex", AdapterFamily::Resume, "codex", "gate4agent-adapter/v1"),
24696                ("codex", AdapterFamily::SessionOptions, "codex", "gate4agent-session-options/orca-d8629c4/v1"),
24697                ("grok", AdapterFamily::Acp, "grok", "gate4agent-adapter/v1"),
24698                ("grok", AdapterFamily::History, "grok", "gate4agent-adapter/v1"),
24699                ("grok", AdapterFamily::Resume, "grok", "gate4agent-adapter/v1"),
24700                ("kimi", AdapterFamily::PtySemantic, "kimi", "gate4agent-adapter/v1"),
24701                ("kimi", AdapterFamily::Pipe, "kimi", "gate4agent-adapter/v1"),
24702                ("kimi", AdapterFamily::Acp, "kimi", "gate4agent-adapter/v1"),
24703                ("kimi", AdapterFamily::OneShot, "kimi", "gate4agent-inline/kimi-code-0.31/v1"),
24704                ("kimi", AdapterFamily::History, "kimi", "gate4agent-adapter/v1"),
24705                ("kimi", AdapterFamily::Resume, "kimi", "gate4agent-adapter/v1"),
24706            ]
24707        );
24708    }
24709
24710    fn construction_test_config(name: &str) -> NodeServerConfig {
24711        let workspace = WorkspaceConfig::new(
24712            WorkspaceId::new("primary").unwrap(),
24713            std::env::current_dir().unwrap(),
24714        )
24715        .unwrap();
24716        NodeServerConfig::new(
24717            format!(r"\\.\pipe\gate4agent-{name}"),
24718            "fixture-token",
24719            NodeId::new(name).unwrap(),
24720            [workspace],
24721        )
24722        .unwrap()
24723    }
24724
24725    fn maximum_size_provider_manifest_registry() -> AgentRegistry {
24726        let adapter = AdapterBinding::new(
24727            gate4agent_types::AdapterId::new("a".repeat(96)).unwrap(),
24728            "r".repeat(crate::protocol::MAX_ADAPTER_CONTRACT_REVISION_BYTES),
24729            gate4agent_types::AdapterVerification::SyntheticFixture,
24730        )
24731        .unwrap();
24732        let agent_ids = ["claude", "codex", "kimi"]
24733            .into_iter()
24734            .map(|agent_id| AgentId::new(agent_id).unwrap())
24735            .collect::<Vec<_>>();
24736        let adapters = gate4agent_catalog::AdapterRegistry::new(
24737            [
24738                AdapterFamily::PtySemantic,
24739                AdapterFamily::Pipe,
24740                AdapterFamily::OneShot,
24741                AdapterFamily::Acp,
24742                AdapterFamily::Hook,
24743                AdapterFamily::ManagedHook,
24744                AdapterFamily::History,
24745                AdapterFamily::Resume,
24746                AdapterFamily::SessionOptions,
24747                AdapterFamily::CapabilityProbe,
24748            ]
24749            .into_iter()
24750            .map(|family| gate4agent_catalog::AdapterDescriptor {
24751                family,
24752                binding: adapter.clone(),
24753                agents: agent_ids.clone(),
24754            }),
24755        )
24756        .unwrap();
24757        let specs = agent_ids
24758            .iter()
24759            .map(|agent_id| {
24760                let agent_id = agent_id.as_str();
24761                let mut spec = builtin_registry().get_by_id(agent_id).unwrap().clone();
24762                spec.revision =
24763                    "r".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES);
24764                spec.capabilities.transports.pty_adapter = Some(adapter.clone());
24765                spec.capabilities.transports.pipe = Some(gate4agent_types::PipeTransportSpec {
24766                    adapter: adapter.clone(),
24767                    protocol: gate4agent_types::PipeProtocol::SemanticNdjson,
24768                    launch_override: None,
24769                    prompt_delivery: gate4agent_types::PipePromptDelivery::None,
24770                });
24771                spec.capabilities.transports.acp = Some(gate4agent_types::AcpTransportSpec {
24772                    adapter: adapter.clone(),
24773                    launch_override: None,
24774                });
24775                spec.capabilities.adapters.hook = Some(adapter.clone());
24776                spec.capabilities.adapters.managed_hook = Some(adapter.clone());
24777                spec.capabilities.adapters.one_shot = Some(adapter.clone());
24778                spec.capabilities.adapters.history = Some(adapter.clone());
24779                spec.capabilities.adapters.resume = Some(adapter.clone());
24780                spec.capabilities.adapters.session_options = Some(adapter.clone());
24781                spec.capabilities.adapters.capability_probe = Some(adapter.clone());
24782                spec
24783            })
24784            .collect::<Vec<_>>();
24785        AgentRegistry::new_with_adapters(specs, &adapters).unwrap()
24786    }
24787
24788    /// The real fleet's manifest is accepted by a real server construction.
24789    ///
24790    /// The count is asserted against `active_registry` itself rather than a
24791    /// literal, so this stays a test that construction succeeds for whatever
24792    /// the fleet is -- which is the property worth guarding -- instead of a
24793    /// second place the fleet size is written down and has to be kept in
24794    /// step with the first.
24795    #[test]
24796    fn node_server_construction_accepts_the_production_manifest() {
24797        let registry = active_registry().unwrap();
24798        let (providers, _) = provider_contract_manifest(&registry).unwrap();
24799        assert_eq!(providers.len(), registry.iter().count());
24800        assert!(!providers.is_empty());
24801        let server = NodeServer::new_with_registry(
24802            construction_test_config("manifest-production-bound"),
24803            registry,
24804        )
24805        .unwrap();
24806        drop(server);
24807    }
24808
24809    #[test]
24810    fn node_server_construction_rejects_count_valid_manifest_over_handshake_capacity() {
24811        let registry = maximum_size_provider_manifest_registry();
24812        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
24813        assert_eq!(providers.len(), 3);
24814        assert_eq!(adapters.len(), 30);
24815        assert!(adapters.len() <= crate::protocol::MAX_PROVIDER_ADAPTER_CONTRACTS);
24816        let error = match NodeServer::new_with_registry(
24817            construction_test_config("manifest-handshake-overflow"),
24818            registry,
24819        ) {
24820            Ok(_) => panic!("oversized negotiated manifest was accepted"),
24821            Err(error) => error,
24822        };
24823        assert!(matches!(
24824            error,
24825            NodeServerError::ProviderContractManifest(message)
24826                if message.contains("authentication binding length")
24827                    && message.contains("8192-byte limit")
24828        ));
24829    }
24830
24831    #[test]
24832    fn provider_contract_manifest_rejects_wire_revision_overflow_before_startup() {
24833        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
24834        spec.revision = "x".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES + 1);
24835        let registry = AgentRegistry::new([spec]).unwrap();
24836        assert!(matches!(
24837            provider_contract_manifest(&registry),
24838            Err(NodeServerError::ProviderContractManifest(message))
24839                if message.contains("exceeds the 128-byte limit")
24840        ));
24841    }
24842
24843    #[test]
24844    fn provider_contract_manifest_rejects_invalid_wire_revision_before_startup() {
24845        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
24846        spec.revision = "invalid revision".to_owned();
24847        let registry = AgentRegistry::new([spec]).unwrap();
24848        assert!(matches!(
24849            provider_contract_manifest(&registry),
24850            Err(NodeServerError::ProviderContractManifest(message))
24851                if message.contains("bounded lowercase ASCII")
24852        ));
24853    }
24854
24855    #[test]
24856    fn provider_contract_manifest_rejects_conflicting_duplicate_family_before_startup() {
24857        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
24858        let transport_binding = spec.capabilities.adapters.one_shot.clone().unwrap();
24859        let conflicting_binding = builtin_registry()
24860            .get_by_id("codex")
24861            .unwrap()
24862            .capabilities
24863            .adapters
24864            .one_shot
24865            .clone()
24866            .unwrap();
24867        let pipe = spec.capabilities.transports.pipe.as_mut().unwrap();
24868        pipe.protocol = gate4agent_types::PipeProtocol::OneShotText;
24869        pipe.adapter = transport_binding;
24870        spec.capabilities.adapters.one_shot = Some(conflicting_binding);
24871        let registry = AgentRegistry::new([spec]).unwrap();
24872        assert!(matches!(
24873            provider_contract_manifest(&registry),
24874            Err(NodeServerError::ProviderContractManifest(message))
24875                if message.contains("conflicting OneShot adapter bindings")
24876        ));
24877    }
24878
24879    #[test]
24880    fn public_node_failures_expose_only_fixed_categories() {
24881        let provider = failure(
24882            NodeFailureCode::BackendOperationFailed,
24883            r"provider bearer-secret failed at C:\private\session.jsonl",
24884        );
24885        assert_eq!(provider.message, "backend-operation-failed");
24886        assert!(!provider.message.contains("bearer-secret"));
24887        assert!(!provider.message.contains(r"C:\private"));
24888        let persistence = persistence_failure(io::Error::new(
24889            io::ErrorKind::Other,
24890            r"state write failed at C:\private\state-v1.json",
24891        ));
24892        assert_eq!(persistence.message, DURABLE_STATE_COMMIT_FAILED_ERROR);
24893        assert!(!persistence.message.contains(r"C:\private"));
24894
24895        for kind in [io::ErrorKind::PermissionDenied, io::ErrorKind::Unsupported] {
24896            let generic = durable_state_load_error(io::Error::new(kind, "host I/O failure"));
24897            assert_eq!(
24898                generic.to_string(),
24899                "node durable state failed: durable-state-load-failed",
24900            );
24901        }
24902    }
24903
24904    #[test]
24905    fn terminal_byte_sequences_are_bounded_before_dispatch() {
24906        let sequence = b"\x1b[1;5D".to_vec();
24907        assert_eq!(
24908            terminal_bytes_action(sequence.clone()).unwrap(),
24909            InputAction::TerminalBytes(sequence),
24910        );
24911        assert_eq!(
24912            terminal_bytes_action(Vec::new()).unwrap_err().code,
24913            NodeFailureCode::InvalidRequest,
24914        );
24915        assert_eq!(
24916            terminal_bytes_action(vec![0; MAX_NODE_TERMINAL_BYTES + 1])
24917                .unwrap_err()
24918                .code,
24919            NodeFailureCode::InvalidRequest,
24920        );
24921    }
24922
24923    #[test]
24924    fn node_server_rejects_a_second_owner_of_the_same_durable_state_path() {
24925        let root = temporary_workspace_root("exclusive-state-owner");
24926        std::fs::create_dir_all(&root).unwrap();
24927        let workspace = WorkspaceConfig::new(
24928            WorkspaceId::new("primary").unwrap(),
24929            &root,
24930        )
24931        .unwrap();
24932        let state_path = root.join("state-v1.json");
24933        let endpoint = format!(
24934            r"\\.\pipe\gate4agent-node-state-lock-{}-{}",
24935            std::process::id(),
24936            std::time::SystemTime::now()
24937                .duration_since(std::time::UNIX_EPOCH)
24938                .unwrap()
24939                .as_nanos(),
24940        );
24941        let config = NodeServerConfig::new(
24942            endpoint,
24943            "fixture-token",
24944            NodeId::new("node-1").unwrap(),
24945            [workspace],
24946        )
24947        .unwrap()
24948        .with_state_path(&state_path)
24949        .unwrap();
24950
24951        let first = NodeServer::new(config.clone()).unwrap();
24952        let error = match NodeServer::new(config.clone()) {
24953            Ok(_) => panic!("a second node server acquired the same durable state path"),
24954            Err(error) => error,
24955        };
24956        assert!(matches!(
24957            &error,
24958            NodeServerError::DurableState(error)
24959                if error.kind() == io::ErrorKind::WouldBlock
24960        ));
24961        assert_eq!(
24962            error.to_string(),
24963            "node durable state failed: durable-state-lock-failed",
24964        );
24965        assert!(!error.to_string().contains(&state_path.to_string_lossy().into_owned()));
24966        drop(first);
24967        let second = NodeServer::new(config).unwrap();
24968        drop(second);
24969        std::fs::remove_dir_all(root).unwrap();
24970    }
24971
24972    #[test]
24973    fn session_binding_is_workspace_scoped_and_removed_fail_closed() {
24974        let catalog = active_registry().unwrap();
24975        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24976        let workspace_id = WorkspaceId::new("primary").unwrap();
24977        let workspace = WorkspaceConfig::new(
24978            workspace_id.clone(),
24979            std::env::current_dir().unwrap(),
24980        )
24981        .unwrap();
24982        let shared = NodeShared::new(
24983            handle,
24984            "fixture-token".to_owned(),
24985            NodeId::new("node-1").unwrap(),
24986            vec![workspace.clone()],
24987            vec![agent("claude"), agent("codex"), agent("kimi")],
24988        );
24989        let instance_id = AgentInstanceId(17);
24990        let generation = SessionGeneration(3);
24991        assert!(shared.address_for(instance_id, generation).is_none());
24992        shared.bind_session(instance_id, workspace_id.clone(), generation);
24993        let address = SessionAddress {
24994            workspace_id,
24995            session: SessionKey { instance_id, generation },
24996        };
24997        assert_eq!(
24998            shared.workspace_root(&address.workspace_id).unwrap(),
24999            workspace.canonical_root,
25000        );
25001        assert_eq!(shared.address_for(instance_id, generation), Some(address.clone()));
25002        shared.remove_binding(&address);
25003        assert!(shared.address_for(instance_id, generation).is_none());
25004    }
25005
25006    #[test]
25007    fn open_provider_request_gate_is_state_aware_and_fail_closed() {
25008        let address = SessionAddress {
25009            workspace_id: WorkspaceId::new("primary").unwrap(),
25010            session: SessionKey {
25011                instance_id: AgentInstanceId(7),
25012                generation: SessionGeneration(2),
25013            },
25014        };
25015        let record_id = SessionRecordId::new("record-a").unwrap();
25016        let size = gate4agent_types::TerminalSize {
25017            rows: 24,
25018            columns: 80,
25019        };
25020        let address_requests = [
25021            NodeRequest::Resume {
25022                session: address.clone(),
25023                terminal_size: size,
25024                initial_prompt: None,
25025            },
25026            NodeRequest::Prompt { session: address.clone(), text: "p".to_owned() },
25027            NodeRequest::Paste { session: address.clone(), text: "p".to_owned() },
25028            NodeRequest::Input { session: address.clone(), text: "p".to_owned() },
25029            NodeRequest::TerminalBytes { session: address.clone(), bytes: vec![1] },
25030            NodeRequest::TerminalControl {
25031                session: address.clone(),
25032                control: TerminalControl::Enter,
25033            },
25034            NodeRequest::Resize { session: address.clone(), size },
25035            NodeRequest::Interrupt { session: address.clone() },
25036            NodeRequest::Stop { session: address.clone(), force: false },
25037            NodeRequest::Remove { session: address },
25038        ];
25039        for request in &address_requests {
25040            assert!(request_requires_open_provider_ids_with(
25041                request,
25042                |_| Some(agent("third-party-agent")),
25043                |_| Some(agent("claude")),
25044            ));
25045            assert!(!request_requires_open_provider_ids_with(
25046                request,
25047                |_| Some(agent("claude")),
25048                |_| Some(agent("claude")),
25049            ));
25050            assert!(request_requires_open_provider_ids_with(
25051                request,
25052                |_| None,
25053                |_| Some(agent("claude")),
25054            ));
25055        }
25056
25057        let record_requests = [
25058            NodeRequest::RenameSessionRecord {
25059                record_id: record_id.clone(),
25060                display_name: "renamed".to_owned(),
25061            },
25062            NodeRequest::ResumeSessionRecord {
25063                record_id: record_id.clone(),
25064                terminal_size: size,
25065                initial_prompt: None,
25066            },
25067            NodeRequest::ForgetSessionRecord { record_id },
25068        ];
25069        for request in &record_requests {
25070            assert!(request_requires_open_provider_ids_with(
25071                request,
25072                |_| Some(agent("claude")),
25073                |_| Some(agent("grok")),
25074            ));
25075            assert!(!request_requires_open_provider_ids_with(
25076                request,
25077                |_| Some(agent("claude")),
25078                |_| Some(agent("kimi")),
25079            ));
25080            assert!(request_requires_open_provider_ids_with(
25081                request,
25082                |_| Some(agent("claude")),
25083                |_| None,
25084            ));
25085        }
25086
25087        assert!(request_requires_open_provider_ids_with(
25088            &NodeRequest::Spawn {
25089                workspace_id: WorkspaceId::new("primary").unwrap(),
25090                provider: agent("third-party-agent"),
25091                mode: SessionMode::Pty,
25092                terminal_size: size,
25093                initial_prompt: None,
25094            },
25095            |_| None,
25096            |_| None,
25097        ));
25098        assert!(!request_requires_open_provider_ids_with(
25099            &NodeRequest::Snapshot,
25100            |_| None,
25101            |_| None,
25102        ));
25103        assert!(request_requires_open_provider_ids_with(
25104            &NodeRequest::ForgetContextPack {
25105                context_id: SpawnContextId::new("ctx-forget").unwrap(),
25106            },
25107            |_| None,
25108            |_| None,
25109        ));
25110    }
25111
25112    #[test]
25113    fn legacy_projection_filters_open_record_replies_and_event_history() {
25114        let catalog = active_registry().unwrap();
25115        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25116        let workspace = WorkspaceConfig::new(
25117            WorkspaceId::new("primary").unwrap(),
25118            std::env::current_dir().unwrap(),
25119        )
25120        .unwrap();
25121        let shared = NodeShared::new(
25122            handle,
25123            "fixture-token".to_owned(),
25124            NodeId::new("node-1").unwrap(),
25125            vec![workspace],
25126            vec![agent("claude"), agent("grok")],
25127        );
25128        let legacy = record("claude", "legacy-record");
25129        let open = record("grok", "open-record");
25130        let legacy_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
25131            record: legacy.clone(),
25132        });
25133        let open_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
25134            record: open.clone(),
25135        });
25136        let legacy_removed = shared.publish(NodeEvent::SessionRecordRemoved {
25137            record_id: legacy.record_id.clone(),
25138        });
25139        let open_removed = shared.publish(NodeEvent::SessionRecordRemoved {
25140            record_id: open.record_id.clone(),
25141        });
25142        assert!(project_event_legacy_provider_ids(&shared, legacy_upsert.clone()).is_some());
25143        assert!(project_event_legacy_provider_ids(&shared, open_upsert.clone()).is_none());
25144        assert!(project_event_legacy_provider_ids(&shared, legacy_removed.clone()).is_some());
25145        assert!(project_event_legacy_provider_ids(&shared, open_removed.clone()).is_none());
25146
25147        let mut record_reply = ResponseEnvelope {
25148            request_id: 1,
25149            result: Ok(NodeResponse::SessionRecordUpdated { record: open }),
25150        };
25151        project_response_legacy_provider_ids(&shared, &mut record_reply);
25152        assert!(matches!(
25153            record_reply.result,
25154            Err(NodeFailure { code: NodeFailureCode::UnsupportedCapability, .. })
25155        ));
25156
25157        let mut resync = ResponseEnvelope {
25158            request_id: 2,
25159            result: Ok(NodeResponse::Resync {
25160                event_sequence: open_removed.sequence,
25161                oldest_available_sequence: 1,
25162                snapshot: shared.snapshot(),
25163                events: vec![legacy_upsert, open_upsert, legacy_removed, open_removed],
25164            }),
25165        };
25166        project_response_legacy_provider_ids(&shared, &mut resync);
25167        let Ok(NodeResponse::Resync { snapshot, events, .. }) = resync.result else {
25168            panic!("legacy projection changed the resync response kind");
25169        };
25170        assert!(snapshot.session_records.iter().all(|item| {
25171            provider_id_is_legacy(&item.provider)
25172        }));
25173        assert_eq!(events.len(), 2);
25174        assert!(events.iter().all(|event| matches!(
25175            &event.event,
25176            NodeEvent::SessionRecordUpserted { record }
25177                if provider_id_is_legacy(&record.provider)
25178        ) || matches!(&event.event, NodeEvent::SessionRecordRemoved { .. })));
25179    }
25180
25181    #[test]
25182    fn session_binding_capacity_is_bounded_by_the_control_session_limit() {
25183        let catalog = active_registry().unwrap();
25184        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25185        let workspace_id = WorkspaceId::new("primary").unwrap();
25186        let workspace = WorkspaceConfig::new(
25187            workspace_id.clone(),
25188            std::env::current_dir().unwrap(),
25189        )
25190        .unwrap();
25191        let shared = NodeShared::new(
25192            handle,
25193            "fixture-token".to_owned(),
25194            NodeId::new("node-1").unwrap(),
25195            vec![workspace],
25196            vec![agent("claude")],
25197        );
25198        for index in 0..CONTROL_SESSIONS_MAX {
25199            shared.bind_session(
25200                AgentInstanceId((index + 1) as u64),
25201                workspace_id.clone(),
25202                SessionGeneration(1),
25203            );
25204        }
25205        let error = shared.ensure_binding_capacity().unwrap_err();
25206        assert_eq!(error.code, NodeFailureCode::BackendBusy);
25207    }
25208
25209    #[tokio::test]
25210    async fn provider_spawn_admission_rejects_before_session_mutation() {
25211        let catalog = active_registry().unwrap();
25212        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25213        let workspace = WorkspaceConfig::new(
25214            WorkspaceId::new("primary").unwrap(),
25215            std::env::current_dir().unwrap(),
25216        )
25217        .unwrap();
25218        let mut shared = NodeShared::new(
25219            handle,
25220            "fixture-token".to_owned(),
25221            NodeId::new("node-provider-admission").unwrap(),
25222            vec![workspace],
25223            vec![agent("claude")],
25224        );
25225        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25226            crate::protocol::ProviderRuntimeStatus::unavailable(agent("claude")),
25227        ])
25228        .unwrap();
25229        let terminal_size = gate4agent_types::TerminalSize {
25230            rows: 24,
25231            columns: 80,
25232        };
25233
25234        let unavailable = shared
25235            .spawn_session(
25236                WorkspaceId::new("primary").unwrap(),
25237                agent("claude"),
25238                SessionMode::Pty,
25239                terminal_size,
25240                None,
25241            )
25242            .await
25243            .unwrap_err();
25244        assert_eq!(unavailable.code, NodeFailureCode::BackendOperationFailed);
25245        assert_eq!(unavailable.message, "backend-operation-failed");
25246
25247        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25248            crate::protocol::ProviderRuntimeStatus::raw_passthrough(
25249                agent("claude"),
25250                Some(crate::protocol::ProviderRuntimeVersion::new("999.0.0").unwrap()),
25251            ),
25252        ])
25253        .unwrap();
25254        let semantic = shared
25255            .spawn_session(
25256                WorkspaceId::new("primary").unwrap(),
25257                agent("claude"),
25258                SessionMode::Inline,
25259                terminal_size,
25260                None,
25261            )
25262            .await
25263            .unwrap_err();
25264        assert_eq!(semantic.code, NodeFailureCode::UnsupportedCapability);
25265        assert_eq!(semantic.message, "unsupported-capability");
25266
25267        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
25268        assert!(shared
25269            .session_bindings
25270            .lock()
25271            .unwrap_or_else(|poisoned| poisoned.into_inner())
25272            .is_empty());
25273        assert!(shared
25274            .session_records
25275            .lock()
25276            .unwrap_or_else(|poisoned| poisoned.into_inner())
25277            .records
25278            .is_empty());
25279        assert!(shared.handle.snapshot().sessions.is_empty());
25280    }
25281
25282    #[tokio::test]
25283    async fn raw_spawn_binding_is_visible_with_a_durable_live_record() {
25284        let catalog = active_registry().unwrap();
25285        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25286        let workspace_id = WorkspaceId::new("primary").unwrap();
25287        let workspace = WorkspaceConfig::new(
25288            workspace_id.clone(),
25289            std::env::current_dir().unwrap(),
25290        )
25291        .unwrap();
25292        let shared = NodeShared::new(
25293            handle,
25294            "fixture-token".to_owned(),
25295            NodeId::new("node-raw-spawn-record").unwrap(),
25296            vec![workspace],
25297            vec![agent("claude")],
25298        );
25299        let address = SessionAddress {
25300            workspace_id,
25301            session: SessionKey {
25302                instance_id: AgentInstanceId(71),
25303                generation: SessionGeneration::default(),
25304            },
25305        };
25306
25307        let record_id = shared
25308            .bind_spawn_session(
25309                &address,
25310                agent("claude"),
25311                SessionMode::Pty,
25312                ProviderRuntimePolicy::raw_pty(),
25313                SpawnRecordPolicy::Always,
25314                None,
25315            )
25316            .unwrap()
25317            .unwrap();
25318        shared
25319            .handle
25320            .dispatch(shared.prepare_command(ControlCommand::Register {
25321                instance_id: address.session.instance_id,
25322                agent_id: agent("claude"),
25323                transport: TransportKind::Pty,
25324            }))
25325            .unwrap();
25326        runtime.tick().await;
25327
25328        assert!(shared.handle.snapshot().sessions.iter().any(|session| {
25329            session.instance_id == address.session.instance_id
25330                && session.generation == address.session.generation
25331        }));
25332        let record = shared.record(&record_id).unwrap();
25333        assert_eq!(record.display_name, "claude #1");
25334        assert_eq!(record.state, ManagedSessionState::Live);
25335        assert_eq!(record.active_session.as_ref(), Some(&address));
25336        assert!(record.provider_session.is_none());
25337        let binding_record_id = shared
25338            .session_bindings
25339            .lock()
25340            .unwrap_or_else(|poisoned| poisoned.into_inner())
25341            .get(&address.session.instance_id)
25342            .and_then(|binding| binding.record_id.clone());
25343        assert_eq!(binding_record_id.as_ref(), Some(&record_id));
25344
25345        let renamed = shared
25346            .rename_session_record(&record_id, "raw review".to_owned())
25347            .unwrap();
25348        assert_eq!(renamed.record_id, record_id);
25349        assert_eq!(renamed.display_name, "raw review");
25350        shared.reconcile_managed_record(
25351            &record_id,
25352            &address,
25353            &ControlEvent {
25354                sequence: 1,
25355                command_id: None,
25356                instance_id: address.session.instance_id,
25357                generation: address.session.generation,
25358                event: ControlEventKind::Running { process_id: None },
25359            },
25360            false,
25361            false,
25362        );
25363        let running = shared.record(&record_id).unwrap();
25364        assert_eq!(running.state, ManagedSessionState::Live);
25365        assert_eq!(running.active_session.as_ref(), Some(&address));
25366        assert_eq!(running.display_name, "raw review");
25367
25368        shared.reconcile_managed_record(
25369            &record_id,
25370            &address,
25371            &ControlEvent {
25372                sequence: 2,
25373                command_id: None,
25374                instance_id: address.session.instance_id,
25375                generation: address.session.generation,
25376                event: ControlEventKind::Exited {
25377                    exit_code: Some(0),
25378                    forced: false,
25379                },
25380            },
25381            false,
25382            false,
25383        );
25384        let detached = shared.record(&record_id).unwrap();
25385        assert_eq!(detached.state, ManagedSessionState::Unavailable);
25386        assert!(detached.active_session.is_none());
25387        assert_eq!(detached.display_name, "raw review");
25388        let renamed_detached = shared
25389            .rename_session_record(&record_id, "raw review detached".to_owned())
25390            .unwrap();
25391        assert_eq!(renamed_detached.record_id, record_id);
25392        assert_eq!(renamed_detached.state, ManagedSessionState::Unavailable);
25393        assert!(renamed_detached.active_session.is_none());
25394        assert_eq!(renamed_detached.display_name, "raw review detached");
25395        assert_eq!(shared.snapshot().session_records, vec![renamed_detached]);
25396    }
25397
25398    #[test]
25399    fn legacy_raw_spawn_binding_remains_untracked() {
25400        let catalog = active_registry().unwrap();
25401        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25402        let workspace_id = WorkspaceId::new("primary").unwrap();
25403        let workspace = WorkspaceConfig::new(
25404            workspace_id.clone(),
25405            std::env::current_dir().unwrap(),
25406        )
25407        .unwrap();
25408        let shared = NodeShared::new(
25409            handle,
25410            "fixture-token".to_owned(),
25411            NodeId::new("node-legacy-raw-spawn").unwrap(),
25412            vec![workspace],
25413            vec![agent("claude")],
25414        );
25415        let address = SessionAddress {
25416            workspace_id,
25417            session: SessionKey {
25418                instance_id: AgentInstanceId(72),
25419                generation: SessionGeneration::default(),
25420            },
25421        };
25422
25423        let record_id = shared
25424            .bind_spawn_session(
25425                &address,
25426                agent("claude"),
25427                SessionMode::Pty,
25428                ProviderRuntimePolicy::raw_pty(),
25429                SpawnRecordPolicy::ProviderIdentityOnly,
25430                None,
25431            )
25432            .unwrap();
25433
25434        assert!(record_id.is_none());
25435        let binding_record_id = shared
25436            .session_bindings
25437            .lock()
25438            .unwrap_or_else(|poisoned| poisoned.into_inner())
25439            .get(&address.session.instance_id)
25440            .and_then(|binding| binding.record_id.clone());
25441        assert!(binding_record_id.is_none());
25442        assert!(shared.snapshot().session_records.is_empty());
25443    }
25444
25445    #[tokio::test]
25446    async fn raw_runtime_policy_rejects_prompt_paste_and_resume_before_mutation() {
25447        let catalog = active_registry().unwrap();
25448        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25449        let workspace_id = WorkspaceId::new("primary").unwrap();
25450        let workspace = WorkspaceConfig::new(
25451            workspace_id.clone(),
25452            std::env::current_dir().unwrap(),
25453        )
25454        .unwrap();
25455        let mut shared = NodeShared::new(
25456            handle,
25457            "fixture-token".to_owned(),
25458            NodeId::new("node-raw-semantic-gate").unwrap(),
25459            vec![workspace],
25460            vec![agent("claude")],
25461        );
25462        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25463            crate::protocol::ProviderRuntimeStatus::raw_passthrough(agent("claude"), None),
25464        ])
25465        .unwrap();
25466        shared
25467            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25468            .unwrap();
25469        let address = SessionAddress {
25470            workspace_id,
25471            session: SessionKey {
25472                instance_id: AgentInstanceId(72),
25473                generation: SessionGeneration::default(),
25474            },
25475        };
25476        shared.bind_session_with_policy(
25477            &address,
25478            ProviderRuntimePolicy::raw_pty(),
25479            None,
25480        );
25481        shared
25482            .handle
25483            .dispatch(shared.prepare_command(ControlCommand::Register {
25484                instance_id: address.session.instance_id,
25485                agent_id: agent("claude"),
25486                transport: TransportKind::Pty,
25487            }))
25488            .unwrap();
25489        runtime.tick().await;
25490        let command_id_before = shared.next_command_id.load(Ordering::Acquire);
25491        let revision_before = shared.handle.snapshot().revision;
25492        let history_sequence_before = shared.current_sequence();
25493        let terminal_size = gate4agent_types::TerminalSize {
25494            rows: 24,
25495            columns: 80,
25496        };
25497
25498        for request in [
25499            NodeRequest::Prompt {
25500                session: address.clone(),
25501                text: "prompt".to_owned(),
25502            },
25503            NodeRequest::Paste {
25504                session: address.clone(),
25505                text: "paste".to_owned(),
25506            },
25507            // `initial_prompt: Some(_)` selects `ResumeWithPrompt`, which needs
25508            // the same semantic capabilities as `Prompt`/`Paste`. A bare
25509            // (promptless) resume is a raw PTY relaunch and IS admitted under
25510            // `raw_pty()` — see `raw_pty_admits_provider_native_resume_without_prompt_only`
25511            // in `provider_runtime.rs`; it is deliberately not exercised here.
25512            NodeRequest::Resume {
25513                session: address.clone(),
25514                terminal_size,
25515                initial_prompt: Some("resume".to_owned()),
25516            },
25517        ] {
25518            let error = process_request_inner(
25519                &shared,
25520                77,
25521                ClientRole::Operator,
25522                request,
25523            )
25524            .await
25525            .unwrap_err();
25526            assert_eq!(error.code, NodeFailureCode::UnsupportedCapability);
25527        }
25528
25529        assert_eq!(shared.next_command_id.load(Ordering::Acquire), command_id_before);
25530        assert_eq!(shared.handle.snapshot().revision, revision_before);
25531        assert_eq!(shared.current_sequence(), history_sequence_before);
25532        let bindings = shared
25533            .session_bindings
25534            .lock()
25535            .unwrap_or_else(|poisoned| poisoned.into_inner());
25536        let binding = bindings.get(&address.session.instance_id).unwrap();
25537        assert_eq!(binding.generation, address.session.generation);
25538        assert!(binding.pending_resume.is_none());
25539        assert!(binding.record_id.is_none());
25540    }
25541
25542    #[tokio::test]
25543    async fn windows_rejects_unix_bytes_workspace_path_before_filesystem_access() {
25544        let catalog = active_registry().unwrap();
25545        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25546        let primary = WorkspaceConfig::new(
25547            WorkspaceId::new("primary").unwrap(),
25548            std::env::current_dir().unwrap(),
25549        )
25550        .unwrap();
25551        let shared = NodeShared::new(
25552            handle,
25553            "fixture-token".to_owned(),
25554            NodeId::new("node-1").unwrap(),
25555            vec![primary],
25556            vec![agent("claude")],
25557        );
25558        shared
25559            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25560            .unwrap();
25561        let before = shared.snapshot();
25562        let error = process_request_inner(
25563            &shared,
25564            77,
25565            ClientRole::Operator,
25566            NodeRequest::RegisterWorkspace {
25567                workspace_id: WorkspaceId::new("foreign").unwrap(),
25568                root: OpaqueHostPath::unix_bytes(b"/srv/repo".to_vec()).unwrap(),
25569            },
25570        )
25571        .await
25572        .unwrap_err();
25573        assert_eq!(error.code, NodeFailureCode::InvalidRequest);
25574        assert_eq!(error.message, "invalid-request");
25575        assert_eq!(shared.snapshot().workspaces, before.workspaces);
25576    }
25577
25578    #[tokio::test]
25579    async fn dynamic_workspaces_reject_duplicates_busy_and_last_then_publish_ordered_events() {
25580        let catalog = active_registry().unwrap();
25581        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25582        let primary_id = WorkspaceId::new("primary").unwrap();
25583        let primary = WorkspaceConfig::new(
25584            primary_id.clone(),
25585            std::env::current_dir().unwrap(),
25586        )
25587        .unwrap();
25588        let shared = NodeShared::new(
25589            handle,
25590            "fixture-token".to_owned(),
25591            NodeId::new("node-1").unwrap(),
25592            vec![primary.clone()],
25593            vec![agent("claude")],
25594        );
25595        let secondary_id = WorkspaceId::new("secondary").unwrap();
25596        let secondary = shared
25597            .register_workspace(
25598                secondary_id.clone(),
25599                std::env::temp_dir().to_string_lossy().into_owned(),
25600            )
25601            .await
25602            .unwrap();
25603        assert_eq!(secondary.workspace_id, secondary_id);
25604        assert!(secondary.sessions.is_empty());
25605        assert_eq!(shared.snapshot().workspaces.len(), 2);
25606
25607        let duplicate_id = shared
25608            .register_workspace(
25609                secondary_id.clone(),
25610                primary.canonical_root().to_owned(),
25611            )
25612            .await
25613            .unwrap_err();
25614        assert_eq!(duplicate_id.code, NodeFailureCode::DuplicateWorkspaceId);
25615        let duplicate_root = shared
25616            .register_workspace(
25617                WorkspaceId::new("duplicate-root").unwrap(),
25618                windows_path_text(&secondary.canonical_root).to_owned(),
25619            )
25620            .await
25621            .unwrap_err();
25622        assert_eq!(duplicate_root.code, NodeFailureCode::DuplicateWorkspaceRoot);
25623
25624        let address = SessionAddress {
25625            workspace_id: secondary_id.clone(),
25626            session: SessionKey {
25627                instance_id: AgentInstanceId(41),
25628                generation: SessionGeneration(1),
25629            },
25630        };
25631        shared.bind_session(
25632            address.session.instance_id,
25633            secondary_id.clone(),
25634            address.session.generation,
25635        );
25636        let busy = shared.unregister_workspace(&secondary_id).unwrap_err();
25637        assert_eq!(busy.code, NodeFailureCode::WorkspaceBusy);
25638        shared.remove_binding(&address);
25639        shared.unregister_workspace(&secondary_id).unwrap();
25640        assert_eq!(shared.snapshot().workspaces.len(), 1);
25641
25642        let last = shared.unregister_workspace(&primary_id).unwrap_err();
25643        assert_eq!(last.code, NodeFailureCode::LastWorkspace);
25644        let history = shared
25645            .history
25646            .lock()
25647            .unwrap_or_else(|poisoned| poisoned.into_inner());
25648        assert!(matches!(
25649            &history.events[0],
25650            NodeEventEnvelope {
25651                sequence: 1,
25652                event: NodeEvent::WorkspaceAdded { workspace },
25653            } if workspace.workspace_id == secondary_id
25654        ));
25655        assert!(matches!(
25656            &history.events[1],
25657            NodeEventEnvelope {
25658                sequence: 2,
25659                event: NodeEvent::WorkspaceRemoved { workspace_id },
25660            } if workspace_id == &secondary_id
25661        ));
25662    }
25663
25664    #[tokio::test]
25665    async fn provider_session_reference_index_is_controller_gated_and_idempotent() {
25666        let secondary_root = temporary_workspace_root("provider-session-reference-index");
25667        std::fs::create_dir_all(&secondary_root).unwrap();
25668        let catalog = active_registry().unwrap();
25669        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25670        let primary_id = WorkspaceId::new("primary").unwrap();
25671        let secondary_id = WorkspaceId::new("secondary").unwrap();
25672        let shared = NodeShared::new(
25673            handle,
25674            "fixture-token".to_owned(),
25675            NodeId::new("node-session-reference-index").unwrap(),
25676            vec![
25677                WorkspaceConfig::new(primary_id.clone(), std::env::current_dir().unwrap())
25678                    .unwrap(),
25679                WorkspaceConfig::new(secondary_id.clone(), &secondary_root).unwrap(),
25680            ],
25681            vec![agent("claude"), agent("codex")],
25682        );
25683        let identity = ProviderSessionIdentity {
25684            key: gate4agent_types::ProviderSessionKey::SessionId,
25685            id: "provider-session-42".to_owned(),
25686            transcript_path: None,
25687        };
25688        let index_request = |workspace_id: WorkspaceId,
25689                             provider: AgentId,
25690                             identity: ProviderSessionIdentity,
25691                             display_name: &str| {
25692            NodeRequest::IndexProviderSession {
25693                workspace_id,
25694                provider,
25695                identity,
25696                display_name: display_name.to_owned(),
25697            }
25698        };
25699        let request = index_request(
25700            primary_id.clone(),
25701            agent("claude"),
25702            identity.clone(),
25703            "release shepherd",
25704        );
25705        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
25706            capability.as_str() == NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY
25707        }));
25708        let observer = process_request_inner(
25709            &shared,
25710            77,
25711            ClientRole::Observer,
25712            request.clone(),
25713        )
25714        .await
25715        .unwrap_err();
25716        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
25717        let uncontrolled = process_request_inner(
25718            &shared,
25719            77,
25720            ClientRole::Operator,
25721            request.clone(),
25722        )
25723        .await
25724        .unwrap_err();
25725        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
25726        shared
25727            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25728            .unwrap();
25729
25730        let indexed = process_request_inner(
25731            &shared,
25732            77,
25733            ClientRole::Operator,
25734            request.clone(),
25735        )
25736        .await
25737        .unwrap();
25738        let NodeResponse::ProviderSessionIndexed { record } = indexed else {
25739            panic!("provider session reference index returned another response");
25740        };
25741        assert_eq!(record.state, ManagedSessionState::Dormant);
25742        assert_eq!(record.mode, SessionMode::Pty);
25743        assert_eq!(record.workspace_id, primary_id);
25744        assert_eq!(record.provider_session.as_ref(), Some(&identity));
25745        assert!(record.active_session.is_none());
25746        assert!(record.environment_profile.is_none());
25747        assert!(record.bundle.is_none());
25748        assert!(record.context.is_none());
25749
25750        let repeated = process_request_inner(
25751            &shared,
25752            77,
25753            ClientRole::Operator,
25754            index_request(
25755                primary_id.clone(),
25756                agent("claude"),
25757                identity.clone(),
25758                "ignored replacement",
25759            ),
25760        )
25761        .await
25762        .unwrap();
25763        assert!(matches!(
25764            repeated,
25765            NodeResponse::ProviderSessionIndexed { record: ref existing }
25766                if existing.record_id == record.record_id
25767                    && existing.display_name == "release shepherd"
25768        ));
25769        let conflicting_workspace = process_request_inner(
25770            &shared,
25771            77,
25772            ClientRole::Operator,
25773            index_request(
25774                secondary_id,
25775                agent("claude"),
25776                identity.clone(),
25777                "release shepherd",
25778            ),
25779        )
25780        .await
25781        .unwrap_err();
25782        assert_eq!(conflicting_workspace.code, NodeFailureCode::SessionRecordConflict);
25783        let conflicting_provider = process_request_inner(
25784            &shared,
25785            77,
25786            ClientRole::Operator,
25787            index_request(
25788                primary_id.clone(),
25789                agent("codex"),
25790                identity.clone(),
25791                "release shepherd",
25792            ),
25793        )
25794        .await
25795        .unwrap_err();
25796        assert_eq!(conflicting_provider.code, NodeFailureCode::SessionRecordConflict);
25797        let transcript_rejected = process_request_inner(
25798            &shared,
25799            77,
25800            ClientRole::Operator,
25801            index_request(
25802                primary_id,
25803                agent("claude"),
25804                ProviderSessionIdentity {
25805                    transcript_path: Some("C:/provider/transcript.jsonl".to_owned()),
25806                    ..identity
25807                },
25808                "release shepherd",
25809            ),
25810        )
25811        .await
25812        .unwrap_err();
25813        assert_eq!(transcript_rejected.code, NodeFailureCode::InvalidRequest);
25814        let upserts = shared
25815            .history
25816            .lock()
25817            .unwrap_or_else(|poisoned| poisoned.into_inner())
25818            .events
25819            .iter()
25820            .filter(|event| matches!(
25821                &event.event,
25822                NodeEvent::SessionRecordUpserted { record: published }
25823                    if published.record_id == record.record_id
25824            ))
25825            .count();
25826        assert_eq!(upserts, 1);
25827        std::fs::remove_dir_all(secondary_root).unwrap();
25828    }
25829
25830    #[tokio::test]
25831    async fn standalone_workspace_is_independent_registered_and_controller_gated() {
25832        let fixture_root = temporary_workspace_root("standalone-independent");
25833        let target = fixture_root.join("independent");
25834        std::fs::create_dir_all(&fixture_root).unwrap();
25835        let catalog = active_registry().unwrap();
25836        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25837        let selected = WorkspaceConfig::new(
25838            WorkspaceId::new("primary").unwrap(),
25839            std::env::current_dir().unwrap(),
25840        )
25841        .unwrap();
25842        let selected_root = selected.canonical_root().to_owned();
25843        let shared = NodeShared::new(
25844            handle,
25845            "fixture-token".to_owned(),
25846            NodeId::new("node-standalone").unwrap(),
25847            vec![selected],
25848            vec![agent("claude")],
25849        );
25850        let workspace_id = WorkspaceId::new("independent").unwrap();
25851        let request = NodeRequest::CreateStandaloneWorkspace {
25852            workspace_id: workspace_id.clone(),
25853            root: opaque_windows_path(target.to_string_lossy().into_owned()),
25854            initial_branch: Some("main".to_owned()),
25855        };
25856        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
25857            capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
25858        }));
25859        assert!(!request_uses_unnegotiated_capability(
25860            &request,
25861            &baseline_capabilities().unwrap(),
25862        ));
25863        assert!(request_uses_unnegotiated_capability(&request, &[]));
25864
25865        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
25866            .await
25867            .unwrap_err();
25868        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
25869        assert!(!target.exists());
25870        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
25871            .await
25872            .unwrap_err();
25873        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
25874        assert!(!target.exists());
25875        shared
25876            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25877            .unwrap();
25878
25879        let response = process_request_inner(&shared, 77, ClientRole::Operator, request)
25880            .await
25881            .unwrap();
25882        let NodeResponse::StandaloneWorkspaceCreated { workspace } = response else {
25883            panic!("standalone workspace creation returned another response");
25884        };
25885        assert_eq!(workspace.workspace_id, workspace_id);
25886        assert!(target.join(".git").is_dir());
25887        let branch = run_git_bounded(
25888            target.to_str().unwrap(),
25889            &["branch", "--show-current"],
25890            GIT_OUTPUT_MAX_BYTES,
25891        )
25892        .await
25893        .unwrap();
25894        assert!(branch.success);
25895        assert_eq!(String::from_utf8(branch.stdout).unwrap().trim(), "main");
25896        let worktrees = list_git_worktrees(target.to_str().unwrap()).await.unwrap();
25897        assert_eq!(worktrees.len(), 1);
25898        let listed_root = WorkspaceConfig::new(
25899            WorkspaceId::new("listed-independent").unwrap(),
25900            &worktrees[0].path,
25901        )
25902        .unwrap();
25903        assert!(platform::roots_equal(
25904            listed_root.canonical_root(),
25905            windows_path_text(&workspace.canonical_root),
25906        ));
25907        assert!(!platform::roots_equal(listed_root.canonical_root(), &selected_root));
25908        assert!(shared.snapshot().workspaces.iter().any(|snapshot| {
25909            snapshot.workspace_id == workspace_id
25910                && snapshot.canonical_root == workspace.canonical_root
25911        }));
25912        assert!(shared
25913            .history
25914            .lock()
25915            .unwrap_or_else(|poisoned| poisoned.into_inner())
25916            .events
25917            .iter()
25918            .any(|event| matches!(
25919                &event.event,
25920                NodeEvent::WorkspaceAdded { workspace }
25921                    if workspace.workspace_id == workspace_id
25922            )));
25923        std::fs::remove_dir_all(fixture_root).unwrap();
25924    }
25925
25926    #[tokio::test]
25927    async fn standalone_workspace_registration_failure_preserves_guarded_repository_for_recovery() {
25928        let fixture_root = temporary_workspace_root("standalone-compensation");
25929        let created_target = fixture_root.join("created-target");
25930        let existing_target = fixture_root.join("existing-target");
25931        let invalid_state_parent = fixture_root.join("state-parent-is-a-file");
25932        let invalid_state_path = invalid_state_parent.join("state.json");
25933        std::fs::create_dir_all(&existing_target).unwrap();
25934        std::fs::write(&invalid_state_parent, "not a directory").unwrap();
25935        let catalog = active_registry().unwrap();
25936        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25937        let primary = WorkspaceConfig::new(
25938            WorkspaceId::new("primary").unwrap(),
25939            std::env::current_dir().unwrap(),
25940        )
25941        .unwrap();
25942        let mut shared = NodeShared::new(
25943            handle,
25944            "fixture-token".to_owned(),
25945            NodeId::new("node-standalone-compensation").unwrap(),
25946            vec![primary],
25947            vec![agent("claude")],
25948        );
25949        shared.state_path = Some(invalid_state_path);
25950
25951        let created_recovery = shared
25952            .create_standalone_workspace(
25953                WorkspaceId::new("compensated").unwrap(),
25954                created_target.to_string_lossy().into_owned(),
25955                Some("main".to_owned()),
25956            )
25957            .await
25958            .unwrap_err();
25959        assert_eq!(
25960            created_recovery.code,
25961            NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
25962        );
25963        assert!(created_target.join(".git").is_dir());
25964        assert!(shared.snapshot().workspaces.iter().all(|workspace| {
25965            workspace.workspace_id.as_str() != "compensated"
25966        }));
25967
25968        let existing_recovery = shared
25969            .create_standalone_workspace(
25970                WorkspaceId::new("restored").unwrap(),
25971                existing_target.to_string_lossy().into_owned(),
25972                None,
25973            )
25974            .await
25975            .unwrap_err();
25976        assert_eq!(
25977            existing_recovery.code,
25978            NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
25979        );
25980        assert!(existing_target.is_dir());
25981        assert!(existing_target.join(".git").is_dir());
25982        assert!(shared.snapshot().workspaces.iter().all(|workspace| {
25983            workspace.workspace_id.as_str() != "restored"
25984        }));
25985
25986        let registered_empty = fixture_root.join("registered-empty");
25987        std::fs::create_dir(&registered_empty).unwrap();
25988        let catalog = active_registry().unwrap();
25989        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25990        let registered = WorkspaceConfig::new(
25991            WorkspaceId::new("registered-empty").unwrap(),
25992            &registered_empty,
25993        )
25994        .unwrap();
25995        let duplicate_shared = NodeShared::new(
25996            handle,
25997            "fixture-token".to_owned(),
25998            NodeId::new("node-standalone-duplicate-root").unwrap(),
25999            vec![registered],
26000            vec![agent("claude")],
26001        );
26002        let duplicate = duplicate_shared
26003            .create_standalone_workspace(
26004                WorkspaceId::new("duplicate-root").unwrap(),
26005                registered_empty.to_string_lossy().into_owned(),
26006                None,
26007            )
26008            .await
26009            .unwrap_err();
26010        assert_eq!(duplicate.code, NodeFailureCode::DuplicateWorkspaceRoot);
26011        assert!(std::fs::read_dir(&registered_empty).unwrap().next().is_none());
26012        std::fs::remove_dir_all(fixture_root).unwrap();
26013    }
26014
26015    #[test]
26016    fn managed_record_id_collision_preserves_the_existing_record() {
26017        let catalog = active_registry().unwrap();
26018        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26019        let workspace = WorkspaceConfig::new(
26020            WorkspaceId::new("primary").unwrap(),
26021            std::env::current_dir().unwrap(),
26022        )
26023        .unwrap();
26024        let shared = NodeShared::new(
26025            handle,
26026            "fixture-token".to_owned(),
26027            NodeId::new("node-1").unwrap(),
26028            vec![workspace.clone()],
26029            vec![agent("claude")],
26030        );
26031        let record_id = SessionRecordId::new("sr-collision").unwrap();
26032        let original = ManagedSessionRecord {
26033            record_id: record_id.clone(),
26034            display_name: "original".to_owned(),
26035            provider: agent("claude"),
26036            mode: SessionMode::Pty,
26037            state: ManagedSessionState::Dormant,
26038            workspace_id: workspace.workspace_id,
26039            canonical_root: opaque_windows_path(workspace.canonical_root),
26040            provider_session: None,
26041            active_session: None,
26042            environment_profile: None,
26043            bundle: None,
26044            context_id: None,
26045            context: None,
26046            exported_context: None,
26047            task_binding: None,
26048            created_at_unix_ms: 1,
26049            updated_at_unix_ms: 1,
26050            last_error: None,
26051        };
26052        shared.insert_record(original.clone()).unwrap();
26053        let mut collision = original.clone();
26054        collision.display_name = "replacement".to_owned();
26055        let error = shared.insert_record(collision).unwrap_err();
26056        assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
26057        assert_eq!(shared.record(&record_id).unwrap(), original);
26058    }
26059
26060    #[test]
26061    fn managed_record_runtime_errors_are_exposed_only_as_allowlisted_categories() {
26062        let catalog = active_registry().unwrap();
26063        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26064        let workspace = WorkspaceConfig::new(
26065            WorkspaceId::new("primary").unwrap(),
26066            std::env::current_dir().unwrap(),
26067        )
26068        .unwrap();
26069        let shared = NodeShared::new(
26070            handle,
26071            "fixture-token".to_owned(),
26072            NodeId::new("node-1").unwrap(),
26073            vec![workspace.clone()],
26074            vec![agent("claude")],
26075        );
26076        let record_id = SessionRecordId::new("sr-runtime-error").unwrap();
26077        shared
26078            .insert_record(ManagedSessionRecord {
26079                record_id: record_id.clone(),
26080                display_name: "runtime error".to_owned(),
26081                provider: agent("claude"),
26082                mode: SessionMode::Pty,
26083                state: ManagedSessionState::IdentityPending,
26084                workspace_id: workspace.workspace_id,
26085                canonical_root: opaque_windows_path(workspace.canonical_root),
26086                provider_session: None,
26087                active_session: None,
26088                environment_profile: None,
26089                bundle: None,
26090                context_id: None,
26091                context: None,
26092                exported_context: None,
26093                task_binding: None,
26094                created_at_unix_ms: 1,
26095                updated_at_unix_ms: 1,
26096                last_error: None,
26097            })
26098            .unwrap();
26099
26100        shared
26101            .mark_record_error(
26102                &record_id,
26103                r"provider failed with bearer-secret-123 at C:\private\session.jsonl",
26104            )
26105            .unwrap();
26106        let record = shared.record(&record_id).unwrap();
26107        assert_eq!(record.state, ManagedSessionState::Unavailable);
26108        assert_eq!(
26109            record.last_error.as_deref(),
26110            Some("provider-runtime-failed"),
26111        );
26112        let snapshot = shared.snapshot();
26113        assert_eq!(snapshot.session_records, vec![record]);
26114    }
26115
26116    /// Regression coverage for the "operator sees `deadline` instead of the
26117    /// kernel's own named rejection" defect: a spawn-dispatch waiter must be
26118    /// able to learn a command's asynchronous kernel rejection (here,
26119    /// specifically classified as `UnsupportedTransport`) exactly once, and
26120    /// must not still find it after that -- proving the fast path this
26121    /// mechanism exists for (`spawn_session_with_deadline`'s commit-wait
26122    /// loop) drains the reason instead of leaving it to be rediscovered (or
26123    /// missed) on a later poll.
26124    #[test]
26125    fn rejected_command_reason_is_classified_and_consumed_exactly_once() {
26126        let catalog = active_registry().unwrap();
26127        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26128        let workspace = WorkspaceConfig::new(
26129            WorkspaceId::new("primary").unwrap(),
26130            std::env::current_dir().unwrap(),
26131        )
26132        .unwrap();
26133        let shared = NodeShared::new(
26134            handle,
26135            "fixture-token".to_owned(),
26136            NodeId::new("node-1").unwrap(),
26137            vec![workspace],
26138            vec![agent("claude")],
26139        );
26140
26141        assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
26142
26143        shared.record_rejected_command(
26144            CommandId(1),
26145            "agent 'claude' does not support transport Acp".to_owned(),
26146            true,
26147        );
26148        // An unrelated command_id must not see another command's reason.
26149        assert!(shared.take_rejected_command_reason(CommandId(2)).is_none());
26150
26151        let reason = shared.take_rejected_command_reason(CommandId(1)).unwrap();
26152        assert_eq!(reason.message, "agent 'claude' does not support transport Acp");
26153        assert!(reason.unsupported_transport);
26154
26155        // Consumed: a second read for the same command_id finds nothing.
26156        assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
26157
26158        // A rejection unrelated to transport support keeps its own
26159        // classification distinct, so the reader can choose the generic
26160        // `BackendOperationFailed` code instead of misreporting
26161        // `UnsupportedTransport`.
26162        shared.record_rejected_command(
26163            CommandId(3),
26164            "backend busy".to_owned(),
26165            false,
26166        );
26167        let unclassified = shared.take_rejected_command_reason(CommandId(3)).unwrap();
26168        assert!(!unclassified.unsupported_transport);
26169    }
26170
26171    #[test]
26172    fn provider_identity_update_is_ignored_without_exact_session_policy() {
26173        let catalog = active_registry().unwrap();
26174        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26175        let workspace = WorkspaceConfig::new(
26176            WorkspaceId::new("primary").unwrap(),
26177            std::env::current_dir().unwrap(),
26178        )
26179        .unwrap();
26180        let shared = NodeShared::new(
26181            handle,
26182            "fixture-token".to_owned(),
26183            NodeId::new("node-identity-policy").unwrap(),
26184            vec![workspace.clone()],
26185            vec![agent("claude")],
26186        );
26187        let address = SessionAddress {
26188            workspace_id: workspace.workspace_id.clone(),
26189            session: SessionKey {
26190                instance_id: AgentInstanceId(90),
26191                generation: SessionGeneration(1),
26192            },
26193        };
26194        let record_id = SessionRecordId::new("sr-policy-denied").unwrap();
26195        shared
26196            .insert_record(ManagedSessionRecord {
26197                record_id: record_id.clone(),
26198                display_name: "policy denied".to_owned(),
26199                provider: agent("claude"),
26200                mode: SessionMode::Pty,
26201                state: ManagedSessionState::IdentityPending,
26202                workspace_id: workspace.workspace_id,
26203                canonical_root: opaque_windows_path(workspace.canonical_root),
26204                provider_session: None,
26205                active_session: Some(address.clone()),
26206                environment_profile: None,
26207                bundle: None,
26208                context_id: None,
26209                context: None,
26210                exported_context: None,
26211                task_binding: None,
26212                created_at_unix_ms: 1,
26213                updated_at_unix_ms: 1,
26214                last_error: None,
26215            })
26216            .unwrap();
26217        let event = ControlEvent {
26218            sequence: 1,
26219            command_id: None,
26220            instance_id: address.session.instance_id,
26221            generation: address.session.generation,
26222            event: ControlEventKind::ProviderEvent {
26223                sequence: 1,
26224                source: gate4agent_types::ProviderSource {
26225                    family: AdapterFamily::PtySemantic,
26226                    binding: AdapterBinding::new(
26227                        gate4agent_types::AdapterId::new("claude").unwrap(),
26228                        "fixture/v1",
26229                        gate4agent_types::AdapterVerification::SyntheticFixture,
26230                    )
26231                    .unwrap(),
26232                },
26233                source_sequence: 1,
26234                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26235                    identity: gate4agent_types::ProviderSessionIdentity {
26236                        key: gate4agent_types::ProviderSessionKey::SessionId,
26237                        id: "provider-session".to_owned(),
26238                        transcript_path: None,
26239                    },
26240                },
26241            },
26242        };
26243
26244        shared.reconcile_managed_record(&record_id, &address, &event, false, false);
26245
26246        let record = shared.record(&record_id).unwrap();
26247        assert!(record.provider_session.is_none());
26248        assert_eq!(record.state, ManagedSessionState::IdentityPending);
26249        assert_eq!(record.active_session.as_ref(), Some(&address));
26250    }
26251
26252    #[test]
26253    fn provider_identity_cannot_rebind_a_record_across_workspaces() {
26254        let secondary_root = temporary_workspace_root("identity-scope");
26255        std::fs::create_dir_all(&secondary_root).unwrap();
26256        let catalog = active_registry().unwrap();
26257        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26258        let primary = WorkspaceConfig::new(
26259            WorkspaceId::new("primary").unwrap(),
26260            std::env::current_dir().unwrap(),
26261        )
26262        .unwrap();
26263        let secondary = WorkspaceConfig::new(
26264            WorkspaceId::new("secondary").unwrap(),
26265            &secondary_root,
26266        )
26267        .unwrap();
26268        let shared = NodeShared::new(
26269            handle,
26270            "fixture-token".to_owned(),
26271            NodeId::new("node-1").unwrap(),
26272            vec![primary.clone(), secondary.clone()],
26273            vec![agent("claude")],
26274        );
26275        let identity = gate4agent_types::ProviderSessionIdentity {
26276            key: gate4agent_types::ProviderSessionKey::SessionId,
26277            id: "provider-session".to_owned(),
26278            transcript_path: None,
26279        };
26280        let dormant_id = SessionRecordId::new("sr-dormant").unwrap();
26281        shared
26282            .insert_record(ManagedSessionRecord {
26283                record_id: dormant_id.clone(),
26284                display_name: "dormant".to_owned(),
26285                provider: agent("claude"),
26286                mode: SessionMode::Pty,
26287                state: ManagedSessionState::Dormant,
26288                workspace_id: primary.workspace_id,
26289                canonical_root: opaque_windows_path(primary.canonical_root),
26290                provider_session: Some(identity.clone()),
26291                active_session: None,
26292                environment_profile: None,
26293                bundle: None,
26294                context_id: None,
26295                context: None,
26296                exported_context: None,
26297                task_binding: None,
26298                created_at_unix_ms: 1,
26299                updated_at_unix_ms: 1,
26300                last_error: None,
26301            })
26302            .unwrap();
26303        let address = SessionAddress {
26304            workspace_id: secondary.workspace_id.clone(),
26305            session: SessionKey {
26306                instance_id: AgentInstanceId(91),
26307                generation: SessionGeneration(1),
26308            },
26309        };
26310        let current_id = SessionRecordId::new("sr-current").unwrap();
26311        shared
26312            .insert_record(ManagedSessionRecord {
26313                record_id: current_id.clone(),
26314                display_name: "current".to_owned(),
26315                provider: agent("claude"),
26316                mode: SessionMode::Pty,
26317                state: ManagedSessionState::IdentityPending,
26318                workspace_id: secondary.workspace_id,
26319                canonical_root: opaque_windows_path(secondary.canonical_root),
26320                provider_session: None,
26321                active_session: Some(address.clone()),
26322                environment_profile: None,
26323                bundle: None,
26324                context_id: None,
26325                context: None,
26326                exported_context: None,
26327                task_binding: None,
26328                created_at_unix_ms: 2,
26329                updated_at_unix_ms: 2,
26330                last_error: None,
26331            })
26332            .unwrap();
26333        shared.bind_managed_session(
26334            &address,
26335            current_id.clone(),
26336            ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26337            None,
26338        );
26339        let event = ControlEvent {
26340            sequence: 1,
26341            command_id: None,
26342            instance_id: address.session.instance_id,
26343            generation: address.session.generation,
26344            event: ControlEventKind::ProviderEvent {
26345                sequence: 1,
26346                source: gate4agent_types::ProviderSource {
26347                    family: gate4agent_types::AdapterFamily::PtySemantic,
26348                    binding: gate4agent_types::AdapterBinding::new(
26349                        gate4agent_types::AdapterId::new("claude").unwrap(),
26350                        "fixture/v1",
26351                        gate4agent_types::AdapterVerification::SyntheticFixture,
26352                    )
26353                    .unwrap(),
26354                },
26355                source_sequence: 1,
26356                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26357                    identity,
26358                },
26359            },
26360        };
26361        shared.reconcile_managed_record(&current_id, &address, &event, true, false);
26362
26363        let binding = shared
26364            .session_bindings
26365            .lock()
26366            .unwrap_or_else(|poisoned| poisoned.into_inner())
26367            .get(&address.session.instance_id)
26368            .cloned()
26369            .unwrap();
26370        assert_eq!(binding.record_id.as_ref(), Some(&current_id));
26371        assert_eq!(shared.record(&dormant_id).unwrap().state, ManagedSessionState::Dormant);
26372        let current = shared.record(&current_id).unwrap();
26373        assert_eq!(current.state, ManagedSessionState::Unavailable);
26374        assert_eq!(current.active_session.as_ref(), Some(&address));
26375        assert_eq!(
26376            current.last_error.as_deref(),
26377            Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR),
26378        );
26379        std::fs::remove_dir_all(secondary_root).unwrap();
26380    }
26381
26382    #[tokio::test]
26383    async fn semantic_identity_with_transcript_path_reuses_record_and_survives_reload() {
26384        let root = temporary_workspace_root("semantic-provider-session");
26385        std::fs::create_dir_all(&root).unwrap();
26386        let state_path = root.join("state-v1.json");
26387        let catalog = active_registry().unwrap();
26388        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26389        let workspace = WorkspaceConfig::new(
26390            WorkspaceId::new("primary").unwrap(),
26391            &root,
26392        )
26393        .unwrap();
26394        let node_id = NodeId::new("node-semantic-identity").unwrap();
26395        let shared = NodeShared::new_with_incarnation(
26396            handle,
26397            "fixture-token".to_owned(),
26398            node_id.clone(),
26399            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
26400            vec![workspace.clone()],
26401            vec![agent("claude")],
26402            ProviderRuntimeStatuses::default(),
26403            None,
26404            Vec::new(),
26405            Vec::new(),
26406            SpawnProfileRegistry::default(),
26407            None,
26408            None,
26409            None,
26410            Some(state_path.clone()),
26411            Vec::new(),
26412            Vec::new(),
26413            Vec::new(),
26414            Vec::new(),
26415            None,
26416            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
26417        );
26418        let persisted_identity = gate4agent_types::ProviderSessionIdentity {
26419            key: gate4agent_types::ProviderSessionKey::SessionId,
26420            id: "provider-session".to_owned(),
26421            transcript_path: None,
26422        };
26423        let durable_id = SessionRecordId::new("sr-durable").unwrap();
26424        shared
26425            .insert_record(ManagedSessionRecord {
26426                record_id: durable_id.clone(),
26427                display_name: "durable".to_owned(),
26428                provider: agent("claude"),
26429                mode: SessionMode::Pty,
26430                state: ManagedSessionState::Dormant,
26431                workspace_id: workspace.workspace_id.clone(),
26432                canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26433                provider_session: Some(persisted_identity.clone()),
26434                active_session: None,
26435                environment_profile: None,
26436                bundle: None,
26437                context_id: None,
26438                context: None,
26439                exported_context: None,
26440                task_binding: None,
26441                created_at_unix_ms: 1,
26442                updated_at_unix_ms: 1,
26443                last_error: None,
26444            })
26445            .unwrap();
26446        let address = SessionAddress {
26447            workspace_id: workspace.workspace_id.clone(),
26448            session: SessionKey {
26449                instance_id: AgentInstanceId(92),
26450                generation: SessionGeneration(2),
26451            },
26452        };
26453        let pending_id = SessionRecordId::new("sr-pending").unwrap();
26454        shared
26455            .insert_record(ManagedSessionRecord {
26456                record_id: pending_id.clone(),
26457                display_name: "pending".to_owned(),
26458                provider: agent("claude"),
26459                mode: SessionMode::Pty,
26460                state: ManagedSessionState::IdentityPending,
26461                workspace_id: workspace.workspace_id.clone(),
26462                canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26463                provider_session: None,
26464                active_session: Some(address.clone()),
26465                environment_profile: None,
26466                bundle: None,
26467                context_id: None,
26468                context: None,
26469                exported_context: None,
26470                task_binding: None,
26471                created_at_unix_ms: 2,
26472                updated_at_unix_ms: 2,
26473                last_error: None,
26474            })
26475            .unwrap();
26476        shared.bind_managed_session(
26477            &address,
26478            pending_id.clone(),
26479            ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26480            None,
26481        );
26482        let observed_identity = gate4agent_types::ProviderSessionIdentity {
26483            transcript_path: Some(r"C:\private\provider-transcript.jsonl".to_owned()),
26484            ..persisted_identity
26485        };
26486        let event = ControlEvent {
26487            sequence: 1,
26488            command_id: None,
26489            instance_id: address.session.instance_id,
26490            generation: address.session.generation,
26491            event: ControlEventKind::ProviderEvent {
26492                sequence: 1,
26493                source: gate4agent_types::ProviderSource {
26494                    family: gate4agent_types::AdapterFamily::PtySemantic,
26495                    binding: gate4agent_types::AdapterBinding::new(
26496                        gate4agent_types::AdapterId::new("claude").unwrap(),
26497                        "fixture/v1",
26498                        gate4agent_types::AdapterVerification::SyntheticFixture,
26499                    )
26500                    .unwrap(),
26501                },
26502                source_sequence: 1,
26503                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26504                    identity: observed_identity.clone(),
26505                },
26506            },
26507        };
26508        shared.reconcile_managed_record(&pending_id, &address, &event, true, false);
26509
26510        let snapshot = shared.snapshot();
26511        assert_eq!(snapshot.session_records.len(), 1);
26512        let live = &snapshot.session_records[0];
26513        assert_eq!(live.record_id, durable_id);
26514        assert_eq!(live.state, ManagedSessionState::Live);
26515        assert_eq!(live.active_session.as_ref(), Some(&address));
26516        assert_eq!(live.provider_session.as_ref(), Some(&observed_identity));
26517        let binding = shared
26518            .session_bindings
26519            .lock()
26520            .unwrap_or_else(|poisoned| poisoned.into_inner())
26521            .get(&address.session.instance_id)
26522            .cloned()
26523            .unwrap();
26524        assert_eq!(binding.record_id.as_ref(), Some(&durable_id));
26525
26526        let mut duplicate = live.clone();
26527        duplicate.record_id = SessionRecordId::new("sr-duplicate").unwrap();
26528        duplicate.display_name = "duplicate".to_owned();
26529        duplicate.state = ManagedSessionState::Dormant;
26530        duplicate.active_session = None;
26531        duplicate.provider_session.as_mut().unwrap().transcript_path = None;
26532        let duplicate_error = shared.insert_record(duplicate).unwrap_err();
26533        assert_eq!(duplicate_error.code, NodeFailureCode::SessionRecordConflict);
26534        assert_eq!(shared.snapshot().session_records.len(), 1);
26535
26536        let terminal_size = gate4agent_types::TerminalSize {
26537            rows: 24,
26538            columns: 80,
26539        };
26540        let (first_busy, second_busy) = tokio::join!(
26541            shared.resume_session_record(&durable_id, terminal_size, None),
26542            shared.resume_session_record(&durable_id, terminal_size, None),
26543        );
26544        for busy in [first_busy.unwrap_err(), second_busy.unwrap_err()] {
26545            assert_eq!(busy.code, NodeFailureCode::SessionRecordBusy);
26546            assert_eq!(busy.message, "session-record-busy");
26547        }
26548
26549        let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
26550        assert_eq!(loaded.records.len(), 1);
26551        assert_eq!(loaded.records[0].record_id, durable_id);
26552        assert_eq!(loaded.records[0].state, ManagedSessionState::Dormant);
26553        assert_eq!(
26554            loaded.records[0]
26555                .provider_session
26556                .as_ref()
26557                .unwrap()
26558                .transcript_path,
26559            None,
26560        );
26561        let catalog = active_registry().unwrap();
26562        let (reloaded_handle, _reloaded_runtime) =
26563            NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26564        let reloaded = NodeShared::new_with_incarnation(
26565            reloaded_handle,
26566            "fixture-token".to_owned(),
26567            node_id,
26568            NodeIncarnationId::from_bytes([1; crate::protocol::NODE_INCARNATION_ID_BYTES]),
26569            vec![workspace],
26570            vec![agent("claude")],
26571            ProviderRuntimeStatuses::default(),
26572            None,
26573            Vec::new(),
26574            Vec::new(),
26575            SpawnProfileRegistry::default(),
26576            None,
26577            None,
26578            None,
26579            None,
26580            loaded.records,
26581            loaded.managed_worktrees,
26582            loaded.managed_worktree_tombstones,
26583            loaded.materializations,
26584            loaded.warning,
26585            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
26586        );
26587        assert_eq!(reloaded.snapshot().session_records.len(), 1);
26588        assert_eq!(reloaded.snapshot().session_records[0].record_id, durable_id);
26589        std::fs::remove_dir_all(root).unwrap();
26590    }
26591
26592    #[tokio::test]
26593    async fn workspace_reregistration_requires_the_recorded_root_and_restores_records() {
26594        let secondary_root = temporary_workspace_root("workspace-restore");
26595        let other_root = temporary_workspace_root("workspace-conflict");
26596        std::fs::create_dir_all(&secondary_root).unwrap();
26597        std::fs::create_dir_all(&other_root).unwrap();
26598        let catalog = active_registry().unwrap();
26599        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26600        let primary = WorkspaceConfig::new(
26601            WorkspaceId::new("primary").unwrap(),
26602            std::env::current_dir().unwrap(),
26603        )
26604        .unwrap();
26605        let secondary = WorkspaceConfig::new(
26606            WorkspaceId::new("secondary").unwrap(),
26607            &secondary_root,
26608        )
26609        .unwrap();
26610        let shared = NodeShared::new(
26611            handle,
26612            "fixture-token".to_owned(),
26613            NodeId::new("node-1").unwrap(),
26614            vec![primary, secondary.clone()],
26615            vec![agent("claude")],
26616        );
26617        let record_id = SessionRecordId::new("sr-workspace").unwrap();
26618        shared
26619            .insert_record(ManagedSessionRecord {
26620                record_id: record_id.clone(),
26621                display_name: "workspace record".to_owned(),
26622                provider: agent("claude"),
26623                mode: SessionMode::Pty,
26624                state: ManagedSessionState::Dormant,
26625                workspace_id: secondary.workspace_id.clone(),
26626                canonical_root: opaque_windows_path(secondary.canonical_root.clone()),
26627                provider_session: Some(gate4agent_types::ProviderSessionIdentity {
26628                    key: gate4agent_types::ProviderSessionKey::SessionId,
26629                    id: "provider-session".to_owned(),
26630                    transcript_path: None,
26631                }),
26632                active_session: None,
26633                environment_profile: None,
26634                bundle: None,
26635                context_id: None,
26636                context: None,
26637                exported_context: None,
26638                task_binding: None,
26639                created_at_unix_ms: 1,
26640                updated_at_unix_ms: 1,
26641                last_error: None,
26642            })
26643            .unwrap();
26644        shared.unregister_workspace(&secondary.workspace_id).unwrap();
26645        let unavailable = shared.record(&record_id).unwrap();
26646        assert_eq!(unavailable.state, ManagedSessionState::Unavailable);
26647        assert_eq!(windows_path_text(&unavailable.canonical_root), secondary.canonical_root);
26648
26649        let conflict = shared
26650            .register_workspace(
26651                secondary.workspace_id.clone(),
26652                other_root.to_string_lossy().into_owned(),
26653            )
26654            .await
26655            .unwrap_err();
26656        assert_eq!(conflict.code, NodeFailureCode::SessionRecordConflict);
26657        shared
26658            .register_workspace(
26659                secondary.workspace_id,
26660                secondary_root.to_string_lossy().into_owned(),
26661            )
26662            .await
26663            .unwrap();
26664        let restored = shared.record(&record_id).unwrap();
26665        assert_eq!(restored.state, ManagedSessionState::Dormant);
26666        assert!(restored.last_error.is_none());
26667        std::fs::remove_dir_all(secondary_root).unwrap();
26668        std::fs::remove_dir_all(other_root).unwrap();
26669    }
26670
26671    #[test]
26672    fn dynamic_workspace_root_is_rejected_before_filesystem_canonicalization() {
26673        let error = validate_workspace_request_root(
26674            &"x".repeat(MAX_WORKSPACE_ROOT_BYTES + 1),
26675        )
26676        .unwrap_err();
26677        assert_eq!(error.code, NodeFailureCode::InvalidWorkspaceRoot);
26678        let control = validate_workspace_request_root("C:\\repo\nchild").unwrap_err();
26679        assert_eq!(control.code, NodeFailureCode::InvalidWorkspaceRoot);
26680    }
26681
26682    #[test]
26683    fn concurrent_publish_and_resync_never_acknowledge_an_unappended_event() {
26684        let catalog = active_registry().unwrap();
26685        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26686        let workspace = WorkspaceConfig::new(
26687            WorkspaceId::new("primary").unwrap(),
26688            std::env::current_dir().unwrap(),
26689        )
26690        .unwrap();
26691        let shared = NodeShared::new(
26692            handle,
26693            "fixture-token".to_owned(),
26694            NodeId::new("node-1").unwrap(),
26695            vec![workspace],
26696            vec![agent("claude")],
26697        );
26698        let publishers = 4_u64;
26699        let events_per_publisher = 256_u64;
26700        let start = std::sync::Barrier::new(publishers as usize + 1);
26701        let finished = AtomicU64::new(0);
26702
26703        std::thread::scope(|scope| {
26704            for _ in 0..publishers {
26705                scope.spawn(|| {
26706                    start.wait();
26707                    for _ in 0..events_per_publisher {
26708                        shared.publish(NodeEvent::ControllerChanged { controller: None });
26709                    }
26710                    finished.fetch_add(1, Ordering::Release);
26711                });
26712            }
26713            start.wait();
26714            while finished.load(Ordering::Acquire) != publishers {
26715                assert_resync_history_is_linearized(shared.resync(0));
26716                std::thread::yield_now();
26717            }
26718        });
26719        let response = shared.resync(0);
26720        assert_resync_history_is_linearized(response.clone());
26721        let NodeResponse::Resync { event_sequence, events, .. } = response else {
26722            unreachable!("resync helper returned another response");
26723        };
26724        assert_eq!(event_sequence, publishers * events_per_publisher);
26725        assert_eq!(events.len() as u64, event_sequence);
26726    }
26727
26728    fn assert_resync_history_is_linearized(response: NodeResponse) {
26729        let NodeResponse::Resync { event_sequence, events, .. } = response else {
26730            unreachable!("resync helper returned another response");
26731        };
26732        assert!(events.windows(2).all(|pair| {
26733            pair[0].sequence.checked_add(1) == Some(pair[1].sequence)
26734        }));
26735        assert_eq!(events.last().map_or(0, |event| event.sequence), event_sequence);
26736    }
26737
26738    #[test]
26739    fn canonical_workspace_roots_are_safe_for_cmd_exe() {
26740        assert_eq!(
26741            platform::normalize_canonical_root(r"\\?\C:\repo\worktree".to_owned()),
26742            r"C:\repo\worktree",
26743        );
26744        assert_eq!(
26745            platform::normalize_canonical_root(r"\\?\UNC\server\share\repo".to_owned()),
26746            r"\\server\share\repo",
26747        );
26748        assert!(validate_workspace_root(
26749            &WorkspaceId::new("network").unwrap(),
26750            r"\\server\share\repo",
26751        )
26752        .is_err());
26753        let workspace = WorkspaceConfig::new(
26754            WorkspaceId::new("primary").unwrap(),
26755            std::env::current_dir().unwrap(),
26756        )
26757        .unwrap();
26758        assert!(!workspace.canonical_root.starts_with(r"\\?\"));
26759    }
26760
26761    #[test]
26762    fn resume_rebinds_only_on_the_expected_authorized_generation() {
26763        let catalog = active_registry().unwrap();
26764        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26765        let workspace_id = WorkspaceId::new("primary").unwrap();
26766        let workspace = WorkspaceConfig::new(
26767            workspace_id.clone(),
26768            std::env::current_dir().unwrap(),
26769        )
26770        .unwrap();
26771        let shared = NodeShared::new(
26772            handle,
26773            "fixture-token".to_owned(),
26774            NodeId::new("node-1").unwrap(),
26775            vec![workspace],
26776            vec![agent("claude")],
26777        );
26778        let instance_id = AgentInstanceId(17);
26779        let original = SessionAddress {
26780            workspace_id,
26781            session: SessionKey {
26782                instance_id,
26783                generation: SessionGeneration(3),
26784            },
26785        };
26786        shared.bind_session(
26787            instance_id,
26788            original.workspace_id.clone(),
26789            original.session.generation,
26790        );
26791        shared
26792            .arm_resume(
26793                &original,
26794                CommandId(41),
26795                ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26796            )
26797            .unwrap();
26798
26799        shared.publish_control(ControlEvent {
26800            sequence: 1,
26801            command_id: None,
26802            instance_id,
26803            generation: SessionGeneration(4),
26804            event: ControlEventKind::Running { process_id: None },
26805        });
26806        assert_eq!(
26807            shared.address_for(instance_id, SessionGeneration(3)),
26808            Some(original.clone()),
26809        );
26810        assert!(shared.address_for(instance_id, SessionGeneration(4)).is_none());
26811
26812        shared.publish_control(ControlEvent {
26813            sequence: 2,
26814            command_id: Some(CommandId(42)),
26815            instance_id,
26816            generation: SessionGeneration(3),
26817            event: ControlEventKind::CommandRejected {
26818                message: "unrelated command".to_owned(),
26819            },
26820        });
26821
26822        shared.publish_control(ControlEvent {
26823            sequence: 3,
26824            command_id: None,
26825            instance_id,
26826            generation: SessionGeneration(4),
26827            event: ControlEventKind::ResumeAuthorized {
26828                session: gate4agent_types::ResumeSessionSummary {
26829                    key: gate4agent_types::ProviderSessionKey::SessionId,
26830                    id: "fixture-session".to_owned(),
26831                },
26832            },
26833        });
26834        assert!(shared.address_for(instance_id, SessionGeneration(3)).is_none());
26835        let rebound = shared.address_for(instance_id, SessionGeneration(4)).unwrap();
26836        assert_eq!(rebound.workspace_id, original.workspace_id);
26837        let history = shared
26838            .history
26839            .lock()
26840            .unwrap_or_else(|poisoned| poisoned.into_inner());
26841        assert!(history.events.iter().any(|envelope| matches!(
26842            &envelope.event,
26843            NodeEvent::Control { address, event }
26844                if address == &rebound
26845                    && matches!(&event.event, ControlEventKind::ResumeAuthorized { .. })
26846        )));
26847    }
26848
26849    #[test]
26850    fn all_node_pty_prompts_use_bracketed_paste_framing() {
26851        for agent in ["claude", "codex", "kimi"] {
26852            assert_eq!(
26853                prompt_framing(&AgentId::new(agent).unwrap()),
26854                PromptFraming::BracketedPaste
26855            );
26856        }
26857    }
26858
26859    fn generous_walk_deadline() -> Instant {
26860        Instant::now() + Duration::from_secs(30)
26861    }
26862
26863    #[test]
26864    fn workspace_tree_is_relative_and_skips_heavy_directories() {
26865        let root = temporary_workspace_root("tree");
26866        std::fs::create_dir_all(root.join("src")).unwrap();
26867        std::fs::create_dir_all(root.join(".git/objects")).unwrap();
26868        std::fs::create_dir_all(root.join("target/debug")).unwrap();
26869        std::fs::create_dir_all(root.join("node_modules/package")).unwrap();
26870        std::fs::write(root.join("src/lib.rs"), b"pub fn fixture() {}\n").unwrap();
26871        std::fs::write(root.join("README.md"), b"fixture\n").unwrap();
26872        // A renamed/agent-scoped build directory (`target-a2`-style, per the
26873        // real repository's own build layout) that carries the Cache
26874        // Directory Tagging Specification marker cargo writes into every
26875        // `--target-dir` it creates: must be skipped even though its name
26876        // does not exactly match `target`.
26877        std::fs::create_dir_all(root.join("target-a2/debug")).unwrap();
26878        std::fs::write(root.join("target-a2/CACHEDIR.TAG"), b"Signature: 8a477f597d28d172789f06886806bc55\n").unwrap();
26879        // A directory that merely *starts with* `target` but carries no
26880        // CACHEDIR.TAG: the new prefix-adjacent check must not false-match
26881        // on the name alone, so this stays in the response.
26882        std::fs::create_dir_all(root.join("target-notes")).unwrap();
26883        std::fs::write(root.join("target-notes/plan.md"), b"not a build directory\n").unwrap();
26884
26885        let (entries, truncated, _budget) =
26886            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
26887        assert!(!truncated);
26888        assert!(entries.iter().any(|entry| {
26889            entry.relative_path.as_utf8() == Some("src")
26890                && entry.kind == WorkspaceEntryKind::Directory
26891        }));
26892        assert!(entries.iter().any(|entry| {
26893            entry.relative_path.as_utf8() == Some("src/lib.rs")
26894                && entry.kind == WorkspaceEntryKind::File
26895        }));
26896        assert!(entries.iter().any(|entry| {
26897            entry.relative_path.as_utf8() == Some("target-notes")
26898                && entry.kind == WorkspaceEntryKind::Directory
26899        }));
26900        assert!(entries.iter().any(|entry| {
26901            entry.relative_path.as_utf8() == Some("target-notes/plan.md")
26902                && entry.kind == WorkspaceEntryKind::File
26903        }));
26904        assert!(entries.iter().all(|entry| {
26905            let path = entry.relative_path.as_utf8().unwrap();
26906            !path.starts_with(".git")
26907                && !(path == "target" || path.starts_with("target/"))
26908                && !(path == "target-a2" || path.starts_with("target-a2/"))
26909                && !path.starts_with("node_modules")
26910        }));
26911
26912        std::fs::remove_dir_all(&root).unwrap();
26913    }
26914
26915    #[test]
26916    fn workspace_walk_stops_at_its_entry_cap_and_marks_a_partial_result() {
26917        let root = temporary_workspace_root("tree-entry-cap");
26918        std::fs::create_dir_all(&root).unwrap();
26919        for index in 0..20 {
26920            std::fs::write(root.join(format!("file-{index:02}.txt")), b"fixture\n").unwrap();
26921        }
26922
26923        let (entries, truncated, budget) =
26924            collect_workspace_entries(&root, generous_walk_deadline(), 5);
26925        assert!(truncated);
26926        assert!(budget.entry_cap_exceeded);
26927        assert!(!budget.time_budget_exceeded);
26928        assert!(entries.len() <= 5);
26929
26930        let inspection_truncation = workspace_inspection_truncation(&budget, false, Duration::from_millis(1))
26931            .expect("an entry-cap-truncated walk must report a truncation marker");
26932        assert!(inspection_truncation.walk_entry_cap_exceeded);
26933        assert!(!inspection_truncation.walk_time_budget_exceeded);
26934        assert!(!inspection_truncation.git_time_budget_exceeded);
26935        assert!(inspection_truncation.entries_visited >= 5);
26936
26937        std::fs::remove_dir_all(&root).unwrap();
26938    }
26939
26940    #[test]
26941    fn workspace_walk_within_budget_reports_no_truncation() {
26942        let root = temporary_workspace_root("tree-no-truncation");
26943        std::fs::create_dir_all(&root).unwrap();
26944        std::fs::write(root.join("only-file.txt"), b"fixture\n").unwrap();
26945
26946        let (_entries, truncated, budget) =
26947            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
26948        assert!(!truncated);
26949        assert!(workspace_inspection_truncation(&budget, false, Duration::from_millis(1)).is_none());
26950
26951        std::fs::remove_dir_all(&root).unwrap();
26952    }
26953
26954    #[test]
26955    fn workspace_walk_emits_strict_global_byte_order_for_prefix_named_siblings() {
26956        let root = temporary_workspace_root("tree-prefix-siblings");
26957        std::fs::create_dir_all(root.join("gate4agent-c2/src")).unwrap();
26958        std::fs::write(root.join("gate4agent-c2/src/lib.rs"), b"fixture\n").unwrap();
26959        std::fs::create_dir_all(root.join("gate4agent-c2-client")).unwrap();
26960        std::fs::write(root.join("gate4agent-c2-client/lib.rs"), b"fixture\n").unwrap();
26961
26962        let (entries, truncated, _budget) =
26963            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
26964        assert!(!truncated);
26965        let paths = entries
26966            .iter()
26967            .map(|entry| entry.relative_path.as_utf8().unwrap().to_owned())
26968            .collect::<Vec<_>>();
26969        assert!(paths.windows(2).all(|pair| pair[0] < pair[1]));
26970        assert_eq!(
26971            paths,
26972            vec![
26973                "gate4agent-c2".to_owned(),
26974                "gate4agent-c2-client".to_owned(),
26975                "gate4agent-c2-client/lib.rs".to_owned(),
26976                "gate4agent-c2/src".to_owned(),
26977                "gate4agent-c2/src/lib.rs".to_owned(),
26978            ],
26979        );
26980
26981        std::fs::remove_dir_all(&root).unwrap();
26982    }
26983
26984    #[test]
26985    fn git_parsers_cap_status_and_commit_results() {
26986        let mut status_output = Vec::new();
26987        for index in 0..=GIT_STATUS_MAX_ENTRIES {
26988            status_output.extend_from_slice(format!(" M src/file-{index}.rs\0").as_bytes());
26989        }
26990        let mut snapshot = git_snapshot_for_parser();
26991        parse_git_status(&status_output, &mut snapshot);
26992        assert_eq!(snapshot.status.len(), GIT_STATUS_MAX_ENTRIES);
26993        assert!(snapshot.truncated);
26994        assert_eq!(snapshot.status[0].index_status, " ");
26995        assert_eq!(snapshot.status[0].worktree_status, "M");
26996
26997        snapshot.truncated = false;
26998        let commit_output = (0..=GIT_COMMIT_MAX_ENTRIES)
26999            .map(|index| format!("abc{index}\u{1f}commit {index}\n"))
27000            .collect::<String>();
27001        parse_git_commits(commit_output.as_bytes(), &mut snapshot);
27002        assert_eq!(snapshot.recent_commits.len(), GIT_COMMIT_MAX_ENTRIES);
27003        assert!(snapshot.truncated);
27004    }
27005
27006    #[test]
27007    fn porcelain_v1_z_parser_preserves_current_then_previous_rename_and_copy_order() {
27008        let mut snapshot = git_snapshot_for_parser();
27009        parse_git_status(
27010            b"R  src/current name.rs\0src/previous name.rs\0 C src/copy.rs\0src/original.rs\0?? src/line\nname.rs\0",
27011            &mut snapshot,
27012        );
27013
27014        assert!(!snapshot.truncated);
27015        assert_eq!(snapshot.status.len(), 3);
27016        assert_eq!(snapshot.status[0].index_status, "R");
27017        assert_eq!(snapshot.status[0].worktree_status, " ");
27018        assert_eq!(snapshot.status[0].path.as_utf8(), Some("src/current name.rs"));
27019        assert_eq!(
27020            snapshot.status[0]
27021                .previous_path
27022                .as_ref()
27023                .and_then(RepositoryPath::as_utf8),
27024            Some("src/previous name.rs"),
27025        );
27026        assert_eq!(snapshot.status[1].index_status, " ");
27027        assert_eq!(snapshot.status[1].worktree_status, "C");
27028        assert_eq!(snapshot.status[1].path.as_utf8(), Some("src/copy.rs"));
27029        assert_eq!(
27030            snapshot.status[1]
27031                .previous_path
27032                .as_ref()
27033                .and_then(RepositoryPath::as_utf8),
27034            Some("src/original.rs"),
27035        );
27036        assert_eq!(snapshot.status[2].path.as_utf8(), Some("src/line\nname.rs"));
27037        assert!(snapshot.status[2].previous_path.is_none());
27038    }
27039
27040    #[test]
27041    fn porcelain_v1_z_parser_never_invents_invalid_or_incomplete_path_identity() {
27042        let mut snapshot = git_snapshot_for_parser();
27043        parse_git_status(
27044            b"?? valid.rs\0?? invalid-\xff.rs\0?? invalid\\windows.rs\0R  current.rs\0",
27045            &mut snapshot,
27046        );
27047
27048        assert!(snapshot.truncated);
27049        assert_eq!(snapshot.status.len(), 1);
27050        assert_eq!(snapshot.status[0].path.as_utf8(), Some("valid.rs"));
27051        assert!(snapshot.status[0].previous_path.is_none());
27052    }
27053
27054    #[tokio::test]
27055    async fn observer_can_browse_host_directories_without_controller() {
27056        let root = temporary_workspace_root("host-directory-observer");
27057        std::fs::create_dir_all(root.join("directory-b")).unwrap();
27058        std::fs::create_dir_all(root.join("directory-a")).unwrap();
27059        std::fs::write(root.join("ordinary-file"), b"not a directory").unwrap();
27060        let workspace_id = WorkspaceId::new("primary").unwrap();
27061        let workspace = WorkspaceConfig::new(workspace_id, &root).unwrap();
27062        let catalog = active_registry().unwrap();
27063        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27064        let shared = NodeShared::new(
27065            handle,
27066            "fixture-token".to_owned(),
27067            NodeId::new("node-1").unwrap(),
27068            vec![workspace],
27069            vec![agent("claude")],
27070        );
27071        let request = NodeRequest::BrowseHostDirectories {
27072            directory: Some(OpaqueHostPath::utf8(root.to_string_lossy().into_owned()).unwrap()),
27073            after: None,
27074        };
27075        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
27076            capability.as_str() == CAPABILITY_HOST_DIRECTORY_BROWSE_V1
27077        }));
27078        assert!(!request_uses_unnegotiated_capability(
27079            &request,
27080            &baseline_capabilities().unwrap(),
27081        ));
27082        assert!(request_uses_unnegotiated_capability(&request, &[]));
27083
27084        let response = process_request_inner(&shared, 77, ClientRole::Observer, request)
27085            .await
27086            .unwrap();
27087        let NodeResponse::HostDirectoriesBrowsed { listing } = response else {
27088            panic!("host directory browse returned another response");
27089        };
27090        assert_eq!(
27091            listing
27092                .entries
27093                .iter()
27094                .map(|entry| entry.display_name.as_str())
27095                .collect::<Vec<_>>(),
27096            vec!["directory-a", "directory-b"],
27097        );
27098        assert!(listing.entries.iter().all(|entry| !entry.is_link));
27099        assert!(!listing.incomplete);
27100        std::fs::remove_dir_all(root).unwrap();
27101    }
27102
27103    #[tokio::test]
27104    async fn observer_can_inspect_only_a_registered_workspace() {
27105        let root = temporary_workspace_root("observer");
27106        std::fs::create_dir_all(root.join("src")).unwrap();
27107        std::fs::write(root.join("src/main.rs"), b"fn main() {}\n").unwrap();
27108        let workspace_id = WorkspaceId::new("primary").unwrap();
27109        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27110        let catalog = active_registry().unwrap();
27111        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27112        let shared = NodeShared::new(
27113            handle,
27114            "fixture-token".to_owned(),
27115            NodeId::new("node-1").unwrap(),
27116            vec![workspace],
27117            vec![agent("claude")],
27118        );
27119
27120        let response = process_request_inner(
27121            &shared,
27122            77,
27123            ClientRole::Observer,
27124            NodeRequest::InspectWorkspace {
27125                workspace_id: workspace_id.clone(),
27126            },
27127        )
27128        .await
27129        .unwrap();
27130        let NodeResponse::WorkspaceInspected { inspection } = response else {
27131            panic!("workspace inspection returned another response");
27132        };
27133        assert_eq!(inspection.workspace_id, workspace_id);
27134        assert!(inspection.entries.iter().any(|entry| {
27135            entry.relative_path.as_utf8() == Some("src/main.rs")
27136        }));
27137
27138        let unknown = process_request_inner(
27139            &shared,
27140            77,
27141            ClientRole::Observer,
27142            NodeRequest::InspectWorkspace {
27143                workspace_id: WorkspaceId::new("unknown").unwrap(),
27144            },
27145        )
27146        .await
27147        .unwrap_err();
27148        assert_eq!(unknown.code, NodeFailureCode::UnknownWorkspace);
27149        std::fs::remove_dir_all(&root).unwrap();
27150    }
27151
27152    #[test]
27153    fn context_pack_source_accepts_only_running_or_successful_exit() {
27154        assert!(context_pack_source_status_is_usable(&SessionStatus::Running));
27155        assert!(context_pack_source_status_is_usable(&SessionStatus::Exited {
27156            exit_code: Some(0),
27157        }));
27158        assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
27159            exit_code: Some(1),
27160        }));
27161        assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
27162            exit_code: None,
27163        }));
27164        assert!(!context_pack_source_status_is_usable(&SessionStatus::Failed {
27165            message: "provider failed".to_owned(),
27166        }));
27167        assert!(!context_pack_source_status_is_usable(&SessionStatus::Registered));
27168        assert!(!context_pack_source_status_is_usable(&SessionStatus::Starting));
27169        assert!(!context_pack_source_status_is_usable(&SessionStatus::Stopping));
27170    }
27171
27172    #[test]
27173    fn context_pack_repository_rejects_changed_head_with_same_git_snapshot() {
27174        let git = git_snapshot_for_parser();
27175        let first = ContextPackRepositoryObservation {
27176            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27177            git: git.clone(),
27178            files: Vec::new(),
27179        };
27180        let second = ContextPackRepositoryObservation {
27181            head: ContextPackRepositoryHead::Commit("b".repeat(40)),
27182            git,
27183            files: Vec::new(),
27184        };
27185
27186        let error = stable_context_pack_repository(first, second).unwrap_err();
27187
27188        assert_eq!(
27189            error.code,
27190            NodeFailureCode::ContextPackMaterializationFailed,
27191        );
27192    }
27193
27194    #[test]
27195    fn context_pack_repository_rejects_changed_file_with_same_git_snapshot() {
27196        let git = git_snapshot_for_parser();
27197        let first = ContextPackRepositoryObservation {
27198            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27199            git: git.clone(),
27200            files: vec![ContextPackRepositoryFileSource::utf8(
27201                "README.md",
27202                "first\n".to_owned(),
27203                6,
27204            )],
27205        };
27206        let second = ContextPackRepositoryObservation {
27207            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27208            git,
27209            files: vec![ContextPackRepositoryFileSource::utf8(
27210                "README.md",
27211                "other\n".to_owned(),
27212                6,
27213            )],
27214        };
27215
27216        let error = stable_context_pack_repository(first, second).unwrap_err();
27217
27218        assert_eq!(
27219            error.code,
27220            NodeFailureCode::ContextPackMaterializationFailed,
27221        );
27222    }
27223
27224    #[tokio::test]
27225    async fn native_session_catalog_zero_active_session_fixture() {
27226        let workspace_root = temporary_workspace_root("native-catalog-workspace");
27227        let history_root = temporary_workspace_root("native-catalog-history");
27228        let outside_root = temporary_workspace_root("native-catalog-outside");
27229        std::fs::create_dir_all(&workspace_root).unwrap();
27230        std::fs::create_dir_all(&history_root).unwrap();
27231        std::fs::create_dir_all(&outside_root).unwrap();
27232        let transcript = |session_id: &str, cwd: &Path, title: &str| {
27233            [
27234                serde_json::json!({
27235                    "type": "user",
27236                    "sessionId": session_id,
27237                    "cwd": cwd.to_str().unwrap(),
27238                    "message": { "content": "raw user text must not cross the catalog wire" }
27239                }),
27240                serde_json::json!({
27241                    "type": "assistant",
27242                    "sessionId": session_id,
27243                    "cwd": cwd.to_str().unwrap(),
27244                    "message": { "model": "claude-sonnet", "content": "raw answer" }
27245                }),
27246                serde_json::json!({
27247                    "type": "ai-title",
27248                    "sessionId": session_id,
27249                    "aiTitle": title
27250                }),
27251            ]
27252            .into_iter()
27253            .map(|record| record.to_string())
27254            .collect::<Vec<_>>()
27255            .join("\n")
27256        };
27257        std::fs::write(
27258            history_root.join("stable-session.jsonl"),
27259            transcript("stable-session", &workspace_root, "Workspace review"),
27260        )
27261        .unwrap();
27262        std::fs::write(
27263            history_root.join("outside-session.jsonl"),
27264            transcript("outside-session", &outside_root, "Outside review"),
27265        )
27266        .unwrap();
27267        let history = NativeHistoryConfig::new(vec![
27268            NativeHistoryRoot::new(
27269                gate4agent_types::AdapterId::new("claude-code").unwrap(),
27270                HistorySourceLayout::SingleNdjson,
27271                history_root.clone(),
27272            )
27273            .unwrap(),
27274        ])
27275        .unwrap();
27276        let catalog = active_registry().unwrap();
27277        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27278        assert!(handle.snapshot().sessions.is_empty());
27279        let workspace_id = WorkspaceId::new("primary").unwrap();
27280        let workspace = WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap();
27281        let mut shared = NodeShared::new(
27282            handle,
27283            "fixture-token".to_owned(),
27284            NodeId::new("node-native-catalog").unwrap(),
27285            vec![workspace],
27286            vec![agent("claude")],
27287        );
27288        shared.native_session_catalog = Some(Arc::new(Mutex::new(
27289            NativeSessionCatalogAuthority::new(history),
27290        )));
27291        let route = NativeSessionCatalogRoute::workspace(
27292            workspace_id.clone(),
27293            agent("claude"),
27294        );
27295        let request = NodeRequest::CatalogNativeSessions {
27296            route: route.clone(),
27297            limit: 2,
27298        };
27299        let observer = process_request_inner(&shared, 7, ClientRole::Observer, request.clone())
27300            .await
27301            .unwrap_err();
27302        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27303        let response = process_request_inner(&shared, 7, ClientRole::Operator, request)
27304            .await
27305            .unwrap();
27306        let NodeResponse::NativeSessionsCataloged { entries, summary, .. } = response else {
27307            panic!("native catalog returned a different response");
27308        };
27309        let summary = summary.expect("node must emit native session catalog summary metadata");
27310        assert_eq!(entries.len(), 1);
27311        assert!(gate4agent_types::validate_candidate_id(&entries[0].selection_id).is_ok());
27312        assert_eq!(entries[0].external_group, None);
27313        assert_eq!(entries[0].record_id, None);
27314        assert_eq!(entries[0].title, None);
27315        assert_eq!(entries[0].model, None);
27316        assert_eq!(entries[0].message_count, 0);
27317        let encoded_entries = serde_json::to_string(&entries).unwrap();
27318        assert!(!encoded_entries.contains("stable-session"));
27319        assert!(!encoded_entries.contains(history_root.to_string_lossy().as_ref()));
27320        let page_request = NodeRequest::PageNativeSessions {
27321            route: route.clone(),
27322            window: crate::protocol::NativeSessionCatalogWindow::Recent,
27323            catalog_revision: summary.catalog_revision,
27324            recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27325            after_selection_id: None,
27326            limit: 2,
27327        };
27328        let observer = process_request_inner(
27329            &shared,
27330            7,
27331            ClientRole::Observer,
27332            page_request.clone(),
27333        )
27334        .await
27335        .unwrap_err();
27336        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27337        let page_response = process_request_inner(
27338            &shared,
27339            7,
27340            ClientRole::Operator,
27341            page_request.clone(),
27342        )
27343        .await
27344        .unwrap();
27345        assert!(matches!(
27346            page_response,
27347            NodeResponse::NativeSessionsPaged { page, .. }
27348                if page.entries.len() == 1 && !page.has_more && page.remaining_count == 0
27349        ));
27350        let stale = process_request_inner(
27351            &shared,
27352            7,
27353            ClientRole::Operator,
27354            NodeRequest::PageNativeSessions {
27355                route: route.clone(),
27356                window: crate::protocol::NativeSessionCatalogWindow::Recent,
27357                catalog_revision: summary.catalog_revision.wrapping_add(1),
27358                recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27359                after_selection_id: None,
27360                limit: 2,
27361            },
27362        )
27363        .await
27364        .unwrap_err();
27365        assert_eq!(stale.code, NodeFailureCode::StaleNativeSessionCatalog);
27366        let selection = NativeSessionSelection {
27367            route: route.clone(),
27368            catalog_revision: summary.catalog_revision,
27369            recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27370            selection_id: entries[0].selection_id.clone(),
27371        };
27372        let preview_response = process_request_inner(
27373            &shared,
27374            7,
27375            ClientRole::Operator,
27376            NodeRequest::PreviewNativeSession {
27377                selection: selection.clone(),
27378                message_limit: 1,
27379            },
27380        )
27381        .await
27382        .unwrap();
27383        let NodeResponse::NativeSessionPreviewed { preview, .. } = preview_response else {
27384            panic!("native preview returned a different response");
27385        };
27386        assert_eq!(preview.messages.len(), 1);
27387        assert!(preview.message_count_exact);
27388        assert_eq!(preview.messages[0].text, "raw answer");
27389        assert!(preview.truncated);
27390
27391        let index_request = NodeRequest::IndexNativeSession {
27392            selection: selection.clone(),
27393            display_name: "Saved Claude".to_owned(),
27394        };
27395        let observer = process_request_inner(
27396            &shared,
27397            7,
27398            ClientRole::Observer,
27399            index_request.clone(),
27400        )
27401        .await
27402        .unwrap_err();
27403        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27404        let without_controller = process_request_inner(
27405            &shared,
27406            7,
27407            ClientRole::Operator,
27408            index_request.clone(),
27409        )
27410        .await
27411        .unwrap_err();
27412        assert_eq!(without_controller.code, NodeFailureCode::ControllerRequired);
27413        let acquired = process_request_inner(
27414            &shared,
27415            7,
27416            ClientRole::Operator,
27417            NodeRequest::AcquireController { lease_ms: 5_000 },
27418        )
27419        .await
27420        .unwrap();
27421        assert!(matches!(acquired, NodeResponse::Controller { controller: Some(_) }));
27422        let indexed = process_request_inner(
27423            &shared,
27424            7,
27425            ClientRole::Operator,
27426            index_request,
27427        )
27428        .await
27429        .unwrap();
27430        assert!(indexed.native_session_index_contract_is_valid());
27431        let NodeResponse::NativeSessionIndexed {
27432            selection: echoed_selection,
27433            record,
27434        } = indexed else {
27435            panic!("native session index returned a different response");
27436        };
27437        assert_eq!(echoed_selection, selection);
27438        let recataloged = process_request_inner(
27439            &shared,
27440            7,
27441            ClientRole::Operator,
27442            NodeRequest::CatalogNativeSessions {
27443                route,
27444                limit: 2,
27445            },
27446        )
27447        .await
27448        .unwrap();
27449        let NodeResponse::NativeSessionsCataloged { entries, .. } = recataloged else {
27450            panic!("native catalog returned a different response after indexing");
27451        };
27452        assert_eq!(entries[0].record_id.as_ref(), Some(&record.record_id));
27453
27454        let unregistered_route = NativeSessionCatalogRoute::unregistered(agent("claude"));
27455        let external_catalog = process_request_inner(
27456            &shared,
27457            7,
27458            ClientRole::Operator,
27459            NodeRequest::CatalogNativeSessions {
27460                route: unregistered_route.clone(),
27461                limit: 2,
27462            },
27463        )
27464        .await
27465        .unwrap();
27466        let external_json = serde_json::to_string(&external_catalog).unwrap();
27467        assert!(!external_json.contains("outside-session"));
27468        assert!(!external_json.contains(outside_root.to_string_lossy().as_ref()));
27469        assert!(!external_json.contains(history_root.to_string_lossy().as_ref()));
27470        assert!(!external_json.contains("\"session_id\""));
27471        assert!(!external_json.contains("\"cwd\""));
27472        assert!(!external_json.contains("\"path\""));
27473        let NodeResponse::NativeSessionsCataloged {
27474            route: echoed_route,
27475            entries: external_entries,
27476            summary: Some(external_summary),
27477        } = external_catalog
27478        else {
27479            panic!("unregistered native catalog returned a different response");
27480        };
27481        assert_eq!(echoed_route, unregistered_route);
27482        assert_eq!(external_entries.len(), 1);
27483        assert_eq!(external_entries[0].record_id, None);
27484        let external_group = external_entries[0]
27485            .external_group
27486            .as_ref()
27487            .expect("unregistered session must have an external group");
27488        assert_eq!(
27489            external_group.kind,
27490            gate4agent_types::NativeSessionExternalGroupKind::Project,
27491        );
27492        assert!(external_group.group_id.starts_with("external-"));
27493        assert_eq!(
27494            external_group.display_name,
27495            outside_root.file_name().unwrap().to_string_lossy(),
27496        );
27497        let external_selection = NativeSessionSelection {
27498            route: unregistered_route,
27499            catalog_revision: external_summary.catalog_revision,
27500            recent_cutoff_unix_ms: external_summary.recent_cutoff_unix_ms,
27501            selection_id: external_entries[0].selection_id.clone(),
27502        };
27503        let external_preview = process_request_inner(
27504            &shared,
27505            7,
27506            ClientRole::Operator,
27507            NodeRequest::PreviewNativeSession {
27508                selection: external_selection.clone(),
27509                message_limit: 1,
27510            },
27511        )
27512        .await
27513        .unwrap();
27514        let external_preview_json = serde_json::to_string(&external_preview).unwrap();
27515        assert!(!external_preview_json.contains("outside-session"));
27516        assert!(!external_preview_json.contains(outside_root.to_string_lossy().as_ref()));
27517        assert!(!external_preview_json.contains("\"session_id\""));
27518        let NodeResponse::NativeSessionPreviewed { preview, .. } = external_preview else {
27519            panic!("unregistered native preview returned a different response");
27520        };
27521        assert_eq!(preview.messages.len(), 1);
27522        let external_index = process_request_inner(
27523            &shared,
27524            7,
27525            ClientRole::Operator,
27526            NodeRequest::IndexNativeSession {
27527                selection: external_selection,
27528                display_name: "External Claude".to_owned(),
27529            },
27530        )
27531        .await
27532        .unwrap_err();
27533        assert_eq!(
27534            external_index.code,
27535            NodeFailureCode::WorkspaceRegistrationRequired,
27536        );
27537        let record_response = process_request_inner(
27538            &shared,
27539            7,
27540            ClientRole::Operator,
27541            NodeRequest::PreviewSessionRecord {
27542                record_id: record.record_id.clone(),
27543                message_limit: 2,
27544            },
27545        )
27546        .await
27547        .unwrap();
27548        let NodeResponse::SessionRecordPreviewed { record_id, preview } = record_response else {
27549            panic!("record preview returned a different response");
27550        };
27551        assert_eq!(record_id, record.record_id);
27552        assert_eq!(preview.messages.len(), 2);
27553        let encoded = serde_json::to_string(&preview).unwrap();
27554        assert!(!encoded.contains("stable-session"));
27555        assert!(!encoded.contains(history_root.to_string_lossy().as_ref()));
27556        let NodeResponse::Resync { events, .. } = shared.resync(0) else {
27557            panic!("record preview history summary returned a different response");
27558        };
27559        let summaries = events
27560            .iter()
27561            .filter_map(|envelope| match &envelope.event {
27562                NodeEvent::SessionRecordHistorySummarized { record_id, summary }
27563                    if record_id == &record.record_id =>
27564                {
27565                    Some(*summary)
27566                }
27567                _ => None,
27568            })
27569            .collect::<Vec<_>>();
27570        assert_eq!(summaries, vec![SessionHistorySummaryV1::from(&preview)]);
27571        let summary_json = serde_json::to_string(&summaries).unwrap();
27572        for private in [
27573            "stable-session",
27574            "raw user text must not cross the catalog wire",
27575            "raw answer",
27576            "Workspace review",
27577            "claude-sonnet",
27578        ] {
27579            assert!(!summary_json.contains(private), "{private}");
27580        }
27581        let identity = record.provider_session.clone().unwrap();
27582        assert_eq!(
27583            shared.revalidate_session_record_preview(&record, &identity),
27584            Ok(())
27585        );
27586        shared.remove_record_memory(&record.record_id);
27587        assert_eq!(
27588            shared
27589                .revalidate_session_record_preview(&record, &identity)
27590                .unwrap_err()
27591                .code,
27592            NodeFailureCode::UnknownSessionRecord
27593        );
27594        let released = process_request_inner(
27595            &shared,
27596            7,
27597            ClientRole::Operator,
27598            NodeRequest::ReleaseController,
27599        )
27600        .await
27601        .unwrap();
27602        assert!(matches!(released, NodeResponse::Controller { controller: None }));
27603        assert!(shared.controller_state().is_none());
27604        std::fs::remove_dir_all(workspace_root).unwrap();
27605        std::fs::remove_dir_all(history_root).unwrap();
27606        std::fs::remove_dir_all(outside_root).unwrap();
27607    }
27608
27609    #[tokio::test]
27610    async fn native_session_catalog_concurrent_operation_fails_fast_without_waiting_on_mutex() {
27611        let history_root = temporary_workspace_root("native-catalog-concurrent-operation");
27612        std::fs::create_dir_all(&history_root).unwrap();
27613        let history = NativeHistoryConfig::new(vec![
27614            NativeHistoryRoot::new(
27615                gate4agent_types::AdapterId::new("claude-code").unwrap(),
27616                HistorySourceLayout::SingleNdjson,
27617                history_root.clone(),
27618            )
27619            .unwrap(),
27620        ])
27621        .unwrap();
27622        let catalog = Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)));
27623        let held = catalog.lock().unwrap();
27624        let result = timeout(
27625            Duration::from_secs(1),
27626            run_native_session_catalog_operation(
27627                Arc::clone(&catalog),
27628                "native session catalog",
27629                |_| Ok::<(), std::convert::Infallible>(()),
27630            ),
27631        )
27632        .await
27633        .expect("concurrent native catalog request must not wait for the operation deadline")
27634        .unwrap_err();
27635        assert_eq!(result.code, NodeFailureCode::BackendBusy);
27636        drop(held);
27637        std::fs::remove_dir_all(history_root).unwrap();
27638    }
27639
27640    fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
27641        let unique = std::time::SystemTime::now()
27642            .duration_since(std::time::UNIX_EPOCH)
27643            .unwrap()
27644            .as_nanos();
27645        std::env::temp_dir().join(format!(
27646            "gate4agent-node-{label}-{}-{unique}",
27647            std::process::id(),
27648        ))
27649    }
27650
27651    fn git_snapshot_for_parser() -> GitSnapshot {
27652        GitSnapshot {
27653            is_repository: true,
27654            branch: Some("main".to_owned()),
27655            status: Vec::new(),
27656            recent_commits: Vec::new(),
27657            worktrees: Vec::new(),
27658            managed_worktree: None,
27659            truncated: false,
27660            diagnostic: None,
27661        }
27662    }
27663
27664    #[tokio::test]
27665    async fn workspace_inspection_scopes_only_exact_active_managed_branch() {
27666        let root = temporary_workspace_root("managed-git-scope");
27667        std::fs::create_dir_all(&root).unwrap();
27668        run_test_git(&root, &["init"]);
27669        run_test_git(&root, &["config", "user.email", "gate4agent@example.invalid"]);
27670        run_test_git(&root, &["config", "user.name", "Gate4Agent Test"]);
27671        std::fs::write(root.join("seed.txt"), "seed\n").unwrap();
27672        run_test_git(&root, &["add", "seed.txt"]);
27673        run_test_git(&root, &["commit", "-m", "seed"]);
27674        run_test_git(&root, &["checkout", "-b", "gate4agent/a"]);
27675
27676        let workspace_id = WorkspaceId::new("managed-a").unwrap();
27677        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27678        let catalog = active_registry().unwrap();
27679        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27680        let shared = NodeShared::new(
27681            handle,
27682            "fixture-token".to_owned(),
27683            NodeId::new("node-1").unwrap(),
27684            vec![workspace],
27685            vec![agent("claude")],
27686        );
27687        let mut lease = ManagedWorktreeLeaseRecord {
27688            lease_id: ManagedWorktreeLeaseId::new("mw-a").unwrap(),
27689            source_workspace_id: WorkspaceId::new("primary").unwrap(),
27690            workspace_id: workspace_id.clone(),
27691            profile_id: WorktreeProfileId::new("default").unwrap(),
27692            profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
27693            target_root: root.to_string_lossy().into_owned(),
27694            branch: "gate4agent/a".to_owned(),
27695            base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
27696            expected_head: None,
27697            retention: ManagedWorktreeRetention::Retain,
27698            state: ManagedWorktreeLeaseState::InUse,
27699            session_holders: vec![ManagedWorktreeSessionHolder {
27700                incarnation_id: shared.incarnation_id,
27701                instance_id: AgentInstanceId(7),
27702                generation: SessionGeneration(1),
27703            }],
27704            record_holders: Vec::new(),
27705            cleanup_failure: None,
27706            created_at_unix_ms: 1,
27707            updated_at_unix_ms: 1,
27708        };
27709        *shared
27710            .managed_worktrees
27711            .lock()
27712            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
27713            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
27714
27715        let inspection = shared.inspect_workspace(workspace_id.clone()).await.unwrap();
27716        let scope = inspection.git.managed_worktree.unwrap();
27717        assert_eq!(scope.lease_id, lease.lease_id);
27718        assert_eq!(scope.branch, "gate4agent/a");
27719        assert_eq!(scope.active_session_count, 1);
27720
27721        lease.branch = "gate4agent/b".to_owned();
27722        *shared
27723            .managed_worktrees
27724            .lock()
27725            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
27726            ManagedWorktreeRegistry::from_records(vec![lease], Vec::new()).unwrap();
27727        let mismatch = shared.inspect_workspace(workspace_id).await.unwrap();
27728        assert!(mismatch.git.managed_worktree.is_none());
27729        std::fs::remove_dir_all(root).unwrap();
27730    }
27731
27732    #[tokio::test]
27733    async fn workspace_file_write_requires_controller_and_rejects_stale_revision() {
27734        let root = temporary_workspace_root("workspace-file-write-controller");
27735        std::fs::create_dir_all(root.join("src")).unwrap();
27736        std::fs::write(root.join("src/lib.rs"), b"old\n").unwrap();
27737        let workspace_id = WorkspaceId::new("primary").unwrap();
27738        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27739        let catalog = active_registry().unwrap();
27740        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27741        let shared = NodeShared::new(
27742            handle,
27743            "fixture-token".to_owned(),
27744            NodeId::new("node-1").unwrap(),
27745            vec![workspace],
27746            vec![agent("claude")],
27747        );
27748        let path = RepositoryPath::utf8("src/lib.rs".to_owned()).unwrap();
27749        let expected_revision = workspace_file_revision(b"old\n");
27750        let request = NodeRequest::WriteWorkspaceFile {
27751            workspace_id: workspace_id.clone(),
27752            path: path.clone(),
27753            expected_revision: expected_revision.clone(),
27754            text: "new\n".to_owned(),
27755        };
27756        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
27757            .await
27758            .unwrap_err();
27759        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27760        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
27761            .await
27762            .unwrap_err();
27763        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
27764        shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
27765        let response = process_request_inner(&shared, 77, ClientRole::Operator, request).await.unwrap();
27766        let NodeResponse::WorkspaceFileWritten { file } = response else {
27767            panic!("workspace file write returned another response");
27768        };
27769        assert_eq!(file.revision, Some(workspace_file_revision(b"new\n")));
27770        assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
27771        let stale = process_request_inner(
27772            &shared,
27773            77,
27774            ClientRole::Operator,
27775            NodeRequest::WriteWorkspaceFile {
27776                workspace_id,
27777                path,
27778                expected_revision,
27779                text: "lost update\n".to_owned(),
27780            },
27781        )
27782        .await
27783        .unwrap_err();
27784        assert_eq!(stale.code, NodeFailureCode::RepositoryFileRevisionConflict);
27785        assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
27786        std::fs::remove_dir_all(root).unwrap();
27787    }
27788
27789    #[tokio::test]
27790    async fn workspace_entry_create_requires_controller_and_never_overwrites() {
27791        let root = temporary_workspace_root("workspace-entry-create-controller");
27792        std::fs::create_dir_all(root.join("src")).unwrap();
27793        let workspace_id = WorkspaceId::new("primary").unwrap();
27794        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27795        let catalog = active_registry().unwrap();
27796        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27797        let shared = NodeShared::new(
27798            handle,
27799            "fixture-token".to_owned(),
27800            NodeId::new("node-1").unwrap(),
27801            vec![workspace],
27802            vec![agent("claude")],
27803        );
27804        let path = RepositoryPath::utf8("src/new.rs".to_owned()).unwrap();
27805        let request = NodeRequest::CreateWorkspaceFile {
27806            workspace_id: workspace_id.clone(),
27807            path: path.clone(),
27808        };
27809        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
27810            capability.as_str() == NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY
27811        }));
27812        assert!(!request_uses_unnegotiated_capability(
27813            &request,
27814            &baseline_capabilities().unwrap(),
27815        ));
27816        assert!(request_uses_unnegotiated_capability(&request, &[]));
27817        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
27818            .await
27819            .unwrap_err();
27820        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27821        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
27822            .await
27823            .unwrap_err();
27824        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
27825        shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
27826
27827        let response = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
27828            .await
27829            .unwrap();
27830        let NodeResponse::WorkspaceFileCreated { file } = response else {
27831            panic!("workspace file create returned another response");
27832        };
27833        assert_eq!(file.workspace_id, workspace_id);
27834        assert_eq!(file.path, path);
27835        assert_eq!(file.revision, Some(workspace_file_revision(&[])));
27836        assert_eq!(std::fs::read(root.join("src/new.rs")).unwrap(), Vec::<u8>::new());
27837
27838        let duplicate = process_request_inner(&shared, 77, ClientRole::Operator, request)
27839            .await
27840            .unwrap_err();
27841        assert_eq!(duplicate.code, NodeFailureCode::RepositoryEntryAlreadyExists);
27842
27843        let directory_path = RepositoryPath::utf8("src/nested".to_owned()).unwrap();
27844        let response = process_request_inner(
27845            &shared,
27846            77,
27847            ClientRole::Operator,
27848            NodeRequest::CreateWorkspaceDirectory {
27849                workspace_id: workspace_id.clone(),
27850                path: directory_path.clone(),
27851            },
27852        )
27853        .await
27854        .unwrap();
27855        assert_eq!(
27856            response,
27857            NodeResponse::WorkspaceDirectoryCreated {
27858                workspace_id: workspace_id.clone(),
27859                entry: WorkspaceEntry {
27860                    relative_path: directory_path,
27861                    kind: WorkspaceEntryKind::Directory,
27862                },
27863            },
27864        );
27865        assert!(root.join("src/nested").is_dir());
27866
27867        let missing_parent = process_request_inner(
27868            &shared,
27869            77,
27870            ClientRole::Operator,
27871            NodeRequest::CreateWorkspaceFile {
27872                workspace_id: workspace_id.clone(),
27873                path: RepositoryPath::utf8("missing/new.rs".to_owned()).unwrap(),
27874            },
27875        )
27876        .await
27877        .unwrap_err();
27878        assert_eq!(missing_parent.code, NodeFailureCode::RepositoryParentNotFound);
27879        let non_directory_parent = process_request_inner(
27880            &shared,
27881            77,
27882            ClientRole::Operator,
27883            NodeRequest::CreateWorkspaceFile {
27884                workspace_id,
27885                path: RepositoryPath::utf8("src/new.rs/child".to_owned()).unwrap(),
27886            },
27887        )
27888        .await
27889        .unwrap_err();
27890        assert_eq!(
27891            non_directory_parent.code,
27892            NodeFailureCode::RepositoryParentNotDirectory,
27893        );
27894        std::fs::remove_dir_all(root).unwrap();
27895    }
27896
27897    #[tokio::test]
27898    async fn workspace_entry_create_timeout_cancels_before_commit_without_late_mutation() {
27899        let root = temporary_workspace_root("workspace-entry-create-timeout-cancel");
27900        std::fs::create_dir_all(root.join("src")).unwrap();
27901        #[cfg(windows)]
27902        let root = std::fs::canonicalize(root).unwrap();
27903        let path = RepositoryPath::utf8("src/late.rs".to_owned()).unwrap();
27904        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
27905        let barrier = Arc::new(std::sync::Barrier::new(2));
27906        let done = Arc::new(AtomicBool::new(false));
27907        let task_commit_state = Arc::clone(&commit_state);
27908        let task_barrier = Arc::clone(&barrier);
27909        let task_done = Arc::clone(&done);
27910        let task_root = root.clone();
27911        let task = tokio::task::spawn_blocking(move || {
27912            task_barrier.wait();
27913            let result = create_workspace_file_on_disk(
27914                &task_root,
27915                &path,
27916                &task_commit_state,
27917            );
27918            task_done.store(true, Ordering::Release);
27919            result
27920        });
27921
27922        let error = settle_workspace_entry_create(
27923            task,
27924            Arc::clone(&commit_state),
27925            Duration::from_millis(10),
27926            "fixture deadline",
27927            "fixture task failed",
27928        )
27929        .await
27930        .unwrap_err();
27931        assert_eq!(error.code, NodeFailureCode::RepositoryEntryCreateTimedOut);
27932        assert_eq!(
27933            commit_state.load(Ordering::Acquire),
27934            WORKSPACE_ENTRY_CREATE_CANCELED,
27935        );
27936
27937        barrier.wait();
27938        timeout(Duration::from_secs(1), async {
27939            while !done.load(Ordering::Acquire) {
27940                tokio::task::yield_now().await;
27941            }
27942        })
27943        .await
27944        .expect("canceled blocking create did not settle");
27945        assert!(!root.join("src/late.rs").exists());
27946        std::fs::remove_dir_all(root).unwrap();
27947    }
27948}
27949
27950#[cfg(test)]
27951mod provisional_spawn_tests {
27952    use super::*;
27953    use std::sync::Arc;
27954
27955    fn shared() -> NodeShared {
27956        let catalog = active_registry().unwrap();
27957        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27958        let workspace = WorkspaceConfig::new(
27959            WorkspaceId::new("primary").unwrap(),
27960            std::env::current_dir().unwrap(),
27961        )
27962        .unwrap()
27963        .with_worktree_service_mode(WorktreeServiceMode::Manual);
27964        NodeShared::new(
27965            handle,
27966            "fixture-token".to_owned(),
27967            NodeId::new("node-provisional-test").unwrap(),
27968            vec![workspace],
27969            vec![AgentId::new("claude").unwrap()],
27970        )
27971    }
27972
27973    /// Provisional draft map: sessionize by instance id only; 50 sequential
27974    /// mark/claim cycles never collide, and a claimed draft is never reaped.
27975    #[test]
27976    fn provisional_spawn_keys_are_unique_and_sessionize_prevents_reap() {
27977        let shared = shared();
27978        let ws = WorkspaceId::new("primary").unwrap();
27979        let mut ids = Vec::with_capacity(50);
27980        for i in 1u64..=50 {
27981            let id = AgentInstanceId(i);
27982            ids.push(id);
27983            let address = SessionAddress {
27984                workspace_id: ws.clone(),
27985                session: SessionKey {
27986                    instance_id: id,
27987                    generation: SessionGeneration(1),
27988                },
27989            };
27990            shared.mark_spawn_provisional(address);
27991        }
27992        assert_eq!(
27993            shared
27994                .provisional_spawns
27995                .lock()
27996                .unwrap_or_else(|p| p.into_inner())
27997                .len(),
27998            50
27999        );
28000        for id in ids.iter().step_by(2) {
28001            shared.sessionize_spawn(*id);
28002        }
28003        assert_eq!(
28004            shared
28005                .provisional_spawns
28006                .lock()
28007                .unwrap_or_else(|p| p.into_inner())
28008                .len(),
28009            25
28010        );
28011        assert!(shared.take_due_provisional_spawns().is_empty());
28012        {
28013            let mut map = shared
28014                .provisional_spawns
28015                .lock()
28016                .unwrap_or_else(|p| p.into_inner());
28017            let past = Instant::now() - Duration::from_secs(30);
28018            for (_, (_, at)) in map.iter_mut() {
28019                *at = past;
28020            }
28021        }
28022        let due = shared.take_due_provisional_spawns();
28023        assert_eq!(due.len(), 25);
28024        let due_ids: std::collections::BTreeSet<_> =
28025            due.iter().map(|a| a.session.instance_id.0).collect();
28026        let expected: std::collections::BTreeSet<_> =
28027            ids.iter().skip(1).step_by(2).map(|id| id.0).collect();
28028        assert_eq!(due_ids, expected);
28029        assert!(shared
28030            .provisional_spawns
28031            .lock()
28032            .unwrap_or_else(|p| p.into_inner())
28033            .is_empty());
28034    }
28035
28036    /// Twenty parallel provisional marks must each get their own instance key;
28037    /// killing/sessionizing one id must not touch the others.
28038    #[test]
28039    fn provisional_spawn_parallel_marks_isolate_kills_by_instance_id() {
28040        let shared = Arc::new(shared());
28041        let ws = WorkspaceId::new("primary").unwrap();
28042        let mut handles = Vec::new();
28043        for i in 1u64..=20 {
28044            let shared = Arc::clone(&shared);
28045            let ws = ws.clone();
28046            handles.push(std::thread::spawn(move || {
28047                let id = AgentInstanceId(i);
28048                let address = SessionAddress {
28049                    workspace_id: ws,
28050                    session: SessionKey {
28051                        instance_id: id,
28052                        generation: SessionGeneration(1),
28053                    },
28054                };
28055                shared.mark_spawn_provisional(address);
28056                id
28057            }));
28058        }
28059        let mut ids = Vec::new();
28060        for h in handles {
28061            ids.push(h.join().unwrap());
28062        }
28063        ids.sort_by_key(|id| id.0);
28064        assert_eq!(ids.len(), 20);
28065        ids.dedup();
28066        assert_eq!(ids.len(), 20, "parallel marks must not share instance keys");
28067        assert_eq!(
28068            shared
28069                .provisional_spawns
28070                .lock()
28071                .unwrap_or_else(|p| p.into_inner())
28072                .len(),
28073            20
28074        );
28075        shared.sessionize_spawn(AgentInstanceId(7));
28076        let left: Vec<_> = shared
28077            .provisional_spawns
28078            .lock()
28079            .unwrap_or_else(|p| p.into_inner())
28080            .keys()
28081            .map(|id| id.0)
28082            .collect();
28083        assert!(!left.contains(&7));
28084        assert_eq!(left.len(), 19);
28085    }
28086}
28087
28088
28089#[cfg(unix)]
28090extern "C" {
28091    #[link_name = "geteuid"]
28092    fn libc_geteuid() -> u32;
28093}
28094
28095#[cfg(test)]
28096mod call_home_link_tests {
28097    use super::*;
28098
28099    #[test]
28100    fn call_home_backoff_is_capped_and_monotonic() {
28101        let mut last = Duration::ZERO;
28102        for attempt in 1..20 {
28103            let next = call_home_backoff(attempt);
28104            assert!(next >= last);
28105            assert!(next <= Duration::from_secs(8));
28106            last = next;
28107        }
28108        assert_eq!(call_home_backoff(0), Duration::from_millis(500));
28109    }
28110
28111    #[tokio::test]
28112    async fn call_home_socket_gets_keepalive() {
28113        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
28114        let addr = listener.local_addr().unwrap();
28115        let (client, _server) = tokio::join!(tokio::net::TcpStream::connect(addr), listener.accept());
28116        let client = client.unwrap();
28117        enable_call_home_keepalive(&client).unwrap();
28118        let sock = socket2::SockRef::from(&client);
28119        assert!(sock.keepalive().unwrap());
28120        #[cfg(any(target_os = "linux", target_os = "macos"))]
28121        assert_eq!(sock.tcp_keepalive_time().unwrap(), CALL_HOME_KEEPALIVE_IDLE);
28122    }
28123}